feat(pr-shepherd): org-wide PR staleness watcher with Slack escalation - #111
feat(pr-shepherd): org-wide PR staleness watcher with Slack escalation#111khaliqgant wants to merge 10 commits into
Conversation
…uard - handleInboxMessage() now posts the LLM answer to SLACK_CHANNEL via slackClient().post() instead of logging it to nowhere. If no channel is configured (dry-run), logs inbox.no-channel and returns cleanly. - LOCAL_READ_ONLY input added to persona.ts (default: false). Guard added at the top of evaluateLedger() — local instances skip the cron escalation path entirely, preventing AR-448-style double-pings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handleInboxMessage() now uses ctx.relay.dm(sender) to reply back to whoever sent the DM — so `workforce agent pr-shepherd` local runs get the answer in the terminal, and agent-to-agent queries (from chief, trajectory-lead) get a proper reply. Falls back to SLACK_CHANNEL if the sender can't be resolved from the event envelope. Pattern matches hn-monitor's resolveRelaySender implementation. Previous commit posted unconditionally to Slack, which was wrong for the terminal chat use case and diverged from the hn-monitor pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ened pull_request.converted_to_draft and pull_request.ready_for_review are not in the known-trigger registry. Removed. Draft state is read from the PR payload on opened/edited/synchronize events instead. Added pull_request.reopened which IS in the registry and is useful for catching PRs that were closed and reopened. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…channel guard Without SLACK_CHANNEL, escalation degrades to log-only regardless of which instance runs the cron path. The workspace-scoped escalation record dedup prevents double-pinging across instances. The flag added friction without adding safety. Both surfaces now work identically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Slack message trigger: responds to @mentions in SLACK_CHANNEL with ledger-grounded answers, replies in-thread via replyTo. Channel guard and bot-message filter match joke-bot pattern. SLACK_BOT_USER_ID input is optional — when set enforces strict @mention check, when unset responds to any non-bot message in the channel. - Compiled persona.json to pr-shepherd/.workforce/build/ so `agentworkforce agent pr-shepherd` works locally without a deploy step. - Three surfaces now unified on the same workspace-scoped ledger: Slack @mention, relay DM (ctx.relay.dm reply), local harness session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
📝 WalkthroughWalkthroughThe PR adds the PR Shepherd agent and persona. The agent maintains a workspace pull request ledger, performs organization-wide backfill, evaluates stale pull requests, sends escalations, and answers relay or Slack questions from ledger data. ChangesPR Shepherd workflow
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant GitHub
participant PR Shepherd
participant Workspace Memory
participant Slack
participant Chief
GitHub->>PR Shepherd: deliver webhook events
PR Shepherd->>Workspace Memory: update PR ledger
PR Shepherd->>Workspace Memory: read ledger during evaluation
PR Shepherd->>Slack: post escalation or thread reply
PR Shepherd->>Chief: log escalation and persist record
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 272b0ca17b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (const item of items) { | ||
| try { | ||
| entries.push(JSON.parse(item.content) as PrLedgerEntry); |
There was a problem hiding this comment.
Deduplicate ledger snapshots before evaluating PRs
Because ctx.memory.save is append-only, every webhook creates another item for the same ledger key, but this loop parses every recalled item as an independent PR. After a PR closes, for example, the newest snapshot is skipped while its older open snapshots can still be classified and escalated; repeated updates also consume the 500-item cap and eventually exclude other PRs. Select only the newest snapshot per {owner, repo, prNumber} before evaluation and Q&A.
Useful? React with 👍 / 👎.
| const res = await fetch( | ||
| `https://api.github.com/orgs/${org}/repos?type=all&per_page=100&page=${page}`, | ||
| { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } } | ||
| ); |
There was a problem hiding this comment.
Authenticate the org-wide GitHub backfill
These raw REST requests never include credentials from the declared GitHub integration. Consequently private repositories are omitted or return 404, and an org-wide crawl is constrained by GitHub's unauthenticated rate limit; with the documented 137 repositories, the per-repository requests alone cannot complete reliably. Use an authenticated integration/provider client or explicitly attach the GitHub App token.
Useful? React with 👍 / 👎.
| const backfillDone = await isBackfillDone(ctx); | ||
| if (!backfillDone) { | ||
| await backfillLedger(ctx); | ||
| await markBackfillDone(ctx); |
There was a problem hiding this comment.
Mark backfill complete only after a successful crawl
If the initial org request fails, backfillLedger catches the exception and returns normally, and per-repository failures are likewise swallowed, so this unconditional marker permanently disables retries after a partial or empty first crawl. The old, quiet PRs that require backfill then remain invisible until they emit a webhook. Propagate or return backfill success and write the marker only after the crawl completes without unresolved failures.
Useful? React with 👍 / 👎.
| // Rung 3 is a Chief DM — escalate() handles it for rung 3. Not time-gated | ||
| // further here; rung 2 fires it on the first evaluation after rung 2 fires. | ||
| // (Intentionally simple for V1 — revisit after measuring rung-2 traffic.) |
There was a problem hiding this comment.
Dispatch the third escalation rung
When a stale PR already has a rung-2 record, the evaluation loop performs no further action: it never calls escalate(..., 3, ...). Thus the advertised final Chief escalation is unreachable for every PR, despite escalate containing a rung-3 branch. Add the intended rung-3 transition and its dedupe/timing check here.
Useful? React with 👍 / 👎.
| if (conclusion === 'success' || conclusion === 'skipped' || conclusion === 'neutral') { | ||
| entry.ciStatus = 'passing'; | ||
| } else if (conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'action_required') { | ||
| entry.ciStatus = 'failing'; |
There was a problem hiding this comment.
Aggregate all checks before declaring CI passing
For PRs with multiple check runs, each completion overwrites the entire ciStatus. If a required check fails and any unrelated successful, skipped, or neutral check completes afterward, the PR is recorded as passing and the ci-red escalation never fires even though CI remains red. Track check identities or query the combined check state and report passing only when no relevant check is pending or failing.
Useful? React with 👍 / 👎.
| const items = await ctx.memory.recall(`pr ledger ${key}`, { | ||
| tags: [LEDGER_TAG, `pr-shepherd:key:${key}`], | ||
| limit: 1 |
There was a problem hiding this comment.
Load the newest ledger snapshot before applying events
After the first update, this key has multiple append-only memory items, but semantic recall with limit: 1 does not guarantee that the returned item is the newest. A later webhook can therefore be applied to an older snapshot and resurrect closedAt, discard a review decision, or revert CI state. Recall multiple matches and explicitly select the maximum createdAt before carrying state forward.
Useful? React with 👍 / 👎.
| github: [ | ||
| { on: 'pull_request.opened' }, | ||
| { on: 'pull_request.edited' }, // trajectory pointer back-annotated via PATCH /pulls/{number} | ||
| { on: 'pull_request.closed' }, |
There was a problem hiding this comment.
Clear closed state when a pull request reopens
The agent subscribes to pull_request.reopened, but updateLedger has no branch that resets closedAt. Once a tracked PR has been closed and reopened, every cron evaluation continues skipping it as closed, so it can never become stale or escalate again.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 17
🧹 Nitpick comments (6)
pr-shepherd/agent.ts (5)
97-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFix the typo in the exported type name
StalenesssBin.The name has three
scharacters in "Stalenesss". This is an exported public type. Rename it before consumers adopt it. Update the references at Lines 100, 920, 1083, and 1146.♻️ Proposed rename
-export type StalenesssBin = 'awaiting-review' | 'awaiting-author' | 'ci-red' | 'abandoned'; +export type StalenessBin = 'awaiting-review' | 'awaiting-author' | 'ci-red' | 'abandoned'; export interface StalenessResult { - bin: StalenesssBin; + bin: StalenessBin; daysSinceRelevantActivity: number; reason: string; }🤖 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 `@pr-shepherd/agent.ts` around lines 97 - 103, Rename the exported type StalenesssBin to StalenessBin and update all references, including the StalenessResult.bin annotation and the usages in the staleness classification logic. Preserve the existing union members and behavior.
810-835: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the dead draft branches.
Line 810 skips every draft PR with
continue. Execution reaches Line 827 only whenpr.draftis falsy. Thereforepr.draft ?? falseis alwaysfalse, andpr.draft ? null : 'awaiting-review'is always'awaiting-review'.♻️ Proposed cleanup
- isDraft: pr.draft ?? false, + isDraft: false, openedAt: pr.created_at, // Use updated_at as a proxy for last human activity at backfill // time. Webhooks will correct this as events arrive. This is an // honest approximation — better than openedAt for old PRs. lastHumanActivityAt: pr.updated_at, lastBotActivityAt: null, ciStatus: null, - reviewState: pr.draft ? null : 'awaiting-review', + reviewState: 'awaiting-review',🤖 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 `@pr-shepherd/agent.ts` around lines 810 - 835, Remove the draft-specific skip and eliminate the now-unreachable draft handling in the ledger backfill flow around ledgerKey and PrLedgerEntry. Since processed entries are non-drafts, assign isDraft directly to false and reviewState directly to 'awaiting-review', preserving the existing behavior for eligible pull requests.
454-461: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake the 30-day open-PR rule configurable and rename its reason.
Every other threshold is an input. This one hardcodes
30. The bin is alsoabandoned, but the condition ignores activity. A PR with a commit yesterday that has been open for 31 days is labelled abandoned. The reason string is accurate, but the bin label is not, and the bin drives the emoji, the dedupe key, and the rung-2 threshold at Line 372.Consider a
STALE_MAX_OPEN_DAYSinput and a separate bin, or drop the rule.🤖 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 `@pr-shepherd/agent.ts` around lines 454 - 461, The open-PR rule in the relevant classification function must no longer hardcode 30 days or reuse the abandoned bin. Add and use a configurable STALE_MAX_OPEN_DAYS input, account for recent activity when applying the rule, and assign a distinct stale-oriented bin with a matching reason so downstream emoji, deduplication, and rung-2 threshold handling remain correct.
616-636: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
handleInboxMessageandhandleSlackMessageduplicate the ledger-context and prompt block, and neither caps its size. The shared root cause is one copied block: load all entries, filter open, join everyformatLedgerSummary, build a near-identical prompt, and callctx.llm.completebehind the same 45 s timeout. The two prompts already differ only in the empty-ledger string and one mrkdwn instruction, so they will drift. Neither copy limits the entry count, so the prompt grows with the ledger and can exceed theclaude-haiku-4-5context window.
pr-shepherd/agent.ts#L616-L636: extract a sharedanswerFromLedger(ctx, question, opts)helper here that caps the entries included in the context and takes the mrkdwn instruction as an option. Return the answer and the open-PR count.pr-shepherd/agent.ts#L738-L758: replace the duplicated block with a call to that helper, passing the Slack mrkdwn option.🤖 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 `@pr-shepherd/agent.ts` around lines 616 - 636, The ledger prompt construction and LLM call are duplicated without limiting ledger entries. In pr-shepherd/agent.ts:616-636, extract an answerFromLedger(ctx, question, opts) helper that caps included entries, accepts the mrkdwn instruction option, preserves the appropriate empty-ledger wording, uses the shared 45-second timeout, and returns both the answer and open-PR count. In pr-shepherd/agent.ts:738-758, replace the duplicate block with this helper call, passing the Slack mrkdwn option and using its returned values.
977-984: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe 90-day memory TTL expires the backfill flag and re-runs the org-wide crawl.
persona.tssetsmemory.ttlDays: 90at Line 78.markBackfillDonewrites a single record and never refreshes it. After 90 days the record expires,isBackfillDonereturnsfalse, andbackfillLedgercrawls all 137 repositories again.The crawl itself is idempotent, because Line 813 skips keys that already exist. The cost is a full re-crawl inside one cron tick. Log the re-run so the operator can distinguish it from the first tick.
🤖 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 `@pr-shepherd/agent.ts` around lines 977 - 984, Prevent the 90-day backfill marker from expiring by refreshing or renewing the record written by markBackfillDone, while preserving isBackfillDone’s existing check. If the marker is unavailable and backfillLedger must run again, emit a clear operator-facing log identifying the crawl as a rerun rather than the initial backfill.pr-shepherd/persona.ts (1)
143-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the duplicated
ORGdefault.The default
'AgentWorkforce'appears here and again inagent.tsat Line 791 asresolveInput(ctx, 'ORG') ?? 'AgentWorkforce'.resolveInputalready falls back tospec?.defaultat Line 1159, so the agent-side literal is unreachable and the two values can drift.♻️ Proposed cleanup in `pr-shepherd/agent.ts`
- const org = resolveInput(ctx, 'ORG') ?? 'AgentWorkforce'; + const org = resolveInput(ctx, 'ORG'); + if (!org) { + ctx.log('error', 'pr-shepherd.backfill.no-org', {}); + return false; + }🤖 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 `@pr-shepherd/persona.ts` around lines 143 - 147, Remove the redundant 'AgentWorkforce' fallback from the ORG handling in resolveInput usage within agent.ts, relying on the ORG specification’s default instead. Preserve resolveInput’s existing spec?.default behavior and leave the ORG definition in persona.ts as the single source of truth.
🤖 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 `@pr-shepherd/agent.ts`:
- Around line 862-878: Both GitHub REST helpers use unauthenticated requests
without timeouts. In pr-shepherd/agent.ts lines 862-878, extract a shared
githubGet helper that attaches the org-wide GitHub App Bearer token and an
AbortSignal.timeout, then use it in listOrgRepos. In pr-shepherd/agent.ts lines
895-912, update listOpenPrs to call githubGet and propagate HTTP 404 errors
instead of treating them as an empty PR list.
- Around line 1090-1094: Update formatEscalationHeader to escape entry.title
before interpolating it into the Slack mrkdwn message, converting &, <, and > to
their Slack-safe entities while preserving the existing PR link markup and title
content.
- Around line 538-545: Wrap the `client.post` call in the escalation flow with
error handling matching the existing missing-`thread.ts` handling, so post
failures are logged without rethrowing. Ensure execution still reaches
`saveEscalationRecord` using the delivered header’s `post.ts` and `post.ref`,
allowing `evaluateLedger` to continue processing remaining entries.
- Around line 432-441: Update PrLedgerEntry and updateLedger to maintain a
separate lastReviewerActivityAt field, setting it only for non-author
pull_request_review.submitted and pull_request_review_comment.created events. In
the awaiting-review classification, use this reviewer timestamp (with the last
push as the appropriate fallback) rather than lastHumanActivityAt, while
preserving the existing threshold and reason semantics.
- Around line 924-935: Update the recall options in loadLedgerEntry,
loadEscalationRecord, and isBackfillDone to include scope: 'workspace', matching
the scope used by saveLedgerEntry, saveEscalationRecord, markBackfillDone, and
loadAllLedgerEntries. Preserve the existing keys, tags, limits, and result
handling.
- Around line 339-340: Validate the numeric value derived from resolveInput(ctx,
'ESCALATION_RUNG2_MULTIPLIER') before assigning rung2Multiplier, falling back to
the default multiplier when parsing yields NaN or another invalid value. Ensure
the validated multiplier is used by both classifyStaleness and the rung-2
escalation comparison so malformed configuration cannot disable stale
classification or escalation.
- Around line 616-636: Bound the open ledger data before constructing the prompt
in the handler around loadAllLedgerEntries and formatLedgerSummary. Limit the
entries to a safe maximum, preserve the existing summary formatting for included
entries, and add an explicit prompt note indicating that the ledger was
truncated when additional open entries were omitted.
- Around line 330-334: Update backfillLedger to return a boolean success signal:
return true only after successful completion and false in its top-level catch
after logging the error. In the backfill flow around isBackfillDone, store that
result and call markBackfillDone only when backfillLedger succeeds, allowing
failed backfills to retry.
- Around line 359-368: Introduce a MAX_ESCALATIONS_PER_TICK limit and track rung
escalations during the processing loop around escalationKey,
loadEscalationRecord, and escalate. Stop initiating further escalations once the
per-tick cap is reached, while preserving deduplication so unprocessed entries
resume on the next tick; apply the limit to actual escalation sends without
preventing normal evaluation of already-recorded entries.
- Around line 1021-1041: Restrict values returned by readField in
extractTrajectoryPointer to bounded, safe characters and lengths before they
reach Slack: allow UUID-formatted session_ref values and similarly constrained
identifiers for work_unit_id and work_unit_surface, returning null for invalid
or oversized values. Preserve the existing empty-pointer behavior and literal
field lookups, with sessionRef specifically validated against the documented
UUID format.
- Around line 376-386: Implement the missing rung-3 trigger in the evaluation
loop: after rung 2 has already been recorded or escalated, call escalate with
rung 3 on the next eligible tick, passing the appropriate existing Slack
reference and incrementing escalated consistently. Update the conditions around
the rung-2 check so rung 3 is not fired during the same evaluation, while
preserving the existing rung-1 and rung-2 behavior and the escalate function’s
rung-3 branch.
- Around line 1043-1069: Update extractPr to validate prData.number before
constructing the PrRef; return null when it is absent or invalid so ledgerKey
never receives an undefined PR number. Also remove the current-time fallback for
prData.created_at and preserve a missing value in the returned reference so the
30-day age logic does not treat old PRs as newly opened.
- Around line 213-248: Update the pull-request event handling around the
existing event branches to refresh entry.isDraft from pr.isDraft for every
pull_request event, including synchronize and edited events, rather than relying
on the unsubscribed converted_to_draft and ready_for_review branches. Add a
github.pull_request.reopened branch that clears entry.closedAt so evaluateLedger
can process the reopened PR again, while preserving the existing activity and
review-state updates.
- Around line 720-769: Update the Slack response call in the inbound-message
handler to use slackClient.reply(chanId, threadTs, answer) instead of post with
a replyTo option. Keep the existing timeout configuration and receipt logging
behavior unchanged.
In `@pr-shepherd/persona.ts`:
- Around line 12-18: The documentation still describes the removed
LOCAL_READ_ONLY flag and local read-only behavior. Update the architecture
docstring in persona.ts and the related agent.ts docstring to remove references
to LOCAL_READ_ONLY and instructions to set it, while preserving the current
cloud-writer/local-observer behavior without documenting a nonexistent input.
- Around line 113-117: Update the DRY_RUN configuration entry to default to
"true", ensuring deployments remain read-only unless the operator explicitly
opts into live Slack posting by setting the environment variable to "false".
- Around line 108-112: Update the SLACK_BOT_USER_ID input description to state
that messages are ignored when the ID is unset, rather than claiming the agent
responds to any non-bot message. Keep the existing guidance about mention-only
handling when the ID is configured.
---
Nitpick comments:
In `@pr-shepherd/agent.ts`:
- Around line 97-103: Rename the exported type StalenesssBin to StalenessBin and
update all references, including the StalenessResult.bin annotation and the
usages in the staleness classification logic. Preserve the existing union
members and behavior.
- Around line 810-835: Remove the draft-specific skip and eliminate the
now-unreachable draft handling in the ledger backfill flow around ledgerKey and
PrLedgerEntry. Since processed entries are non-drafts, assign isDraft directly
to false and reviewState directly to 'awaiting-review', preserving the existing
behavior for eligible pull requests.
- Around line 454-461: The open-PR rule in the relevant classification function
must no longer hardcode 30 days or reuse the abandoned bin. Add and use a
configurable STALE_MAX_OPEN_DAYS input, account for recent activity when
applying the rule, and assign a distinct stale-oriented bin with a matching
reason so downstream emoji, deduplication, and rung-2 threshold handling remain
correct.
- Around line 616-636: The ledger prompt construction and LLM call are
duplicated without limiting ledger entries. In pr-shepherd/agent.ts:616-636,
extract an answerFromLedger(ctx, question, opts) helper that caps included
entries, accepts the mrkdwn instruction option, preserves the appropriate
empty-ledger wording, uses the shared 45-second timeout, and returns both the
answer and open-PR count. In pr-shepherd/agent.ts:738-758, replace the duplicate
block with this helper call, passing the Slack mrkdwn option and using its
returned values.
- Around line 977-984: Prevent the 90-day backfill marker from expiring by
refreshing or renewing the record written by markBackfillDone, while preserving
isBackfillDone’s existing check. If the marker is unavailable and backfillLedger
must run again, emit a clear operator-facing log identifying the crawl as a
rerun rather than the initial backfill.
In `@pr-shepherd/persona.ts`:
- Around line 143-147: Remove the redundant 'AgentWorkforce' fallback from the
ORG handling in resolveInput usage within agent.ts, relying on the ORG
specification’s default instead. Preserve resolveInput’s existing spec?.default
behavior and leave the ORG definition in persona.ts as the single source of
truth.
🪄 Autofix
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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6bc52270-b8f0-412a-9f47-7a95b0b0e4d2
📒 Files selected for processing (2)
pr-shepherd/agent.tspr-shepherd/persona.ts
| const thresholds = resolveThresholds(ctx); | ||
| const rung2Multiplier = Number(resolveInput(ctx, 'ESCALATION_RUNG2_MULTIPLIER') ?? '2'); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Guard threshold parsing against NaN.
Number() returns NaN for any non-numeric input. resolveInput returns the raw string, so a value such as "3d" or "two" produces NaN.
Every comparison then evaluates to false:
- In
classifyStaleness,days >= NaNisfalsefor all four bins, so the agent classifies nothing as stale and posts nothing. The logs still report a successful run. - At Line 376,
rung1AgeMs >= thresholdMs * NaNisfalse, so rung 2 never fires.
One malformed input silently disables the whole agent. Parse with a validated fallback.
🛠️ Proposed fix
+function numberInput(ctx: WorkforceCtx, name: string, fallback: number): number {
+ const raw = resolveInput(ctx, name);
+ const n = Number(raw);
+ if (raw !== undefined && (!Number.isFinite(n) || n <= 0)) {
+ ctx.log('warn', 'pr-shepherd.input.invalid-number', { name, raw, fallback });
+ return fallback;
+ }
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}Then use it in both places:
const thresholds = resolveThresholds(ctx);
- const rung2Multiplier = Number(resolveInput(ctx, 'ESCALATION_RUNG2_MULTIPLIER') ?? '2');
+ const rung2Multiplier = numberInput(ctx, 'ESCALATION_RUNG2_MULTIPLIER', 2); function resolveThresholds(ctx: WorkforceCtx): StaleThresholds {
return {
- awaitingReviewDays: Number(resolveInput(ctx, 'STALE_AWAITING_REVIEW_DAYS') ?? '3'),
- awaitingAuthorDays: Number(resolveInput(ctx, 'STALE_AWAITING_AUTHOR_DAYS') ?? '2'),
- ciRedDays: Number(resolveInput(ctx, 'STALE_CI_RED_DAYS') ?? '1'),
- abandonedDays: Number(resolveInput(ctx, 'STALE_ABANDONED_DAYS') ?? '14')
+ awaitingReviewDays: numberInput(ctx, 'STALE_AWAITING_REVIEW_DAYS', 3),
+ awaitingAuthorDays: numberInput(ctx, 'STALE_AWAITING_AUTHOR_DAYS', 2),
+ ciRedDays: numberInput(ctx, 'STALE_CI_RED_DAYS', 1),
+ abandonedDays: numberInput(ctx, 'STALE_ABANDONED_DAYS', 14)
};
}🤖 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 `@pr-shepherd/agent.ts` around lines 339 - 340, Validate the numeric value
derived from resolveInput(ctx, 'ESCALATION_RUNG2_MULTIPLIER') before assigning
rung2Multiplier, falling back to the default multiplier when parsing yields NaN
or another invalid value. Ensure the validated multiplier is used by both
classifyStaleness and the rung-2 escalation comparison so malformed
configuration cannot disable stale classification or escalation.
| // Rung 1: first escalation for this PR in this bin. | ||
| const rung1Key = escalationKey(entry.owner, entry.repo, entry.prNumber, staleness.bin, 1); | ||
| const rung1Record = await loadEscalationRecord(ctx, rung1Key); | ||
|
|
||
| if (!rung1Record) { | ||
| await escalate(ctx, entry, staleness, 1, channel, isDryRun); | ||
| escalated++; | ||
| evaluated++; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Cap escalations per tick to prevent an alert storm on the first live run.
The loop fires rung 1 for every stale entry with no per-tick limit. escalate posts two Slack messages per rung-1 entry.
The docstring at Line 788 states the org has about 240 open PRs. Backfill seeds all of them with lastHumanActivityAt set to updated_at, so any PR untouched for 3 days already exceeds STALE_AWAITING_REVIEW_DAYS. When DRY_RUN flips to false, the first tick can post several hundred Slack messages in one burst. Slack rate limits reject part of that burst, and each rejection throws through the loop per the issue at Lines 538-545.
Add a MAX_ESCALATIONS_PER_TICK input. The remaining entries fire on the next hourly tick, because the dedupe records make the loop resumable.
🛠️ Proposed fix
let evaluated = 0;
let escalated = 0;
let skipped = 0;
+ const maxPerTick = numberInput(ctx, 'MAX_ESCALATIONS_PER_TICK', 25);
for (const entry of entries) {
+ if (escalated >= maxPerTick) {
+ ctx.log('warn', 'pr-shepherd.evaluate.tick-cap-reached', { maxPerTick, remaining: entries.length - evaluated - skipped });
+ break;
+ }🤖 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 `@pr-shepherd/agent.ts` around lines 359 - 368, Introduce a
MAX_ESCALATIONS_PER_TICK limit and track rung escalations during the processing
loop around escalationKey, loadEscalationRecord, and escalate. Stop initiating
further escalations once the per-tick cap is reached, while preserving
deduplication so unprocessed entries resume on the next tick; apply the limit to
actual escalation sends without preventing normal evaluation of already-recorded
entries.
| function extractPr(eventType: string, d: Record<string, unknown>): PrRef | null { | ||
| const prData = ( | ||
| d.pull_request ?? | ||
| (Array.isArray((d.check_run as Record<string, unknown> | undefined)?.pull_requests) && | ||
| ((d.check_run as Record<string, unknown>).pull_requests as unknown[])[0]) ?? | ||
| null | ||
| ) as Record<string, unknown> | null; | ||
|
|
||
| if (!prData) return null; | ||
|
|
||
| const repo = d.repository as Record<string, unknown> | undefined; | ||
| const owner = (repo?.owner as Record<string, unknown> | undefined)?.login as string | undefined; | ||
| const repoName = repo?.name as string | undefined; | ||
|
|
||
| if (!owner || !repoName) return null; | ||
|
|
||
| return { | ||
| owner, | ||
| repo: repoName, | ||
| number: prData.number as number, | ||
| title: (prData.title as string | undefined) ?? '', | ||
| url: (prData.html_url as string | undefined) ?? '', | ||
| author: ((prData.user as Record<string, unknown> | undefined)?.login as string | undefined) ?? 'unknown', | ||
| isDraft: (prData.draft as boolean | undefined) ?? false, | ||
| createdAt: (prData.created_at as string | undefined) ?? new Date().toISOString(), | ||
| body: (prData.body as string | undefined) ?? null | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Guard prData.number before building the ledger key.
Line 1062 casts prData.number to number with no fallback, while every other field on the returned object has a default. If the field is absent, extractPr returns a PrRef with number: undefined.
ledgerKey at Line 917 then produces owner/repo/undefined. Every PR in that repository with a missing number collides on one ledger entry, and the entry is unusable. Return null instead, which the caller already handles at Line 181.
Line 1067 has a smaller version of the same problem. When created_at is absent, openedAt becomes the current time, so an old PR appears newly opened and never reaches the 30-day rule at Line 455.
🛠️ Proposed fix
if (!owner || !repoName) return null;
+ const number = prData.number;
+ if (typeof number !== 'number' || !Number.isInteger(number)) return null;
+
return {
owner,
repo: repoName,
- number: prData.number as number,
+ number,📝 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.
| function extractPr(eventType: string, d: Record<string, unknown>): PrRef | null { | |
| const prData = ( | |
| d.pull_request ?? | |
| (Array.isArray((d.check_run as Record<string, unknown> | undefined)?.pull_requests) && | |
| ((d.check_run as Record<string, unknown>).pull_requests as unknown[])[0]) ?? | |
| null | |
| ) as Record<string, unknown> | null; | |
| if (!prData) return null; | |
| const repo = d.repository as Record<string, unknown> | undefined; | |
| const owner = (repo?.owner as Record<string, unknown> | undefined)?.login as string | undefined; | |
| const repoName = repo?.name as string | undefined; | |
| if (!owner || !repoName) return null; | |
| return { | |
| owner, | |
| repo: repoName, | |
| number: prData.number as number, | |
| title: (prData.title as string | undefined) ?? '', | |
| url: (prData.html_url as string | undefined) ?? '', | |
| author: ((prData.user as Record<string, unknown> | undefined)?.login as string | undefined) ?? 'unknown', | |
| isDraft: (prData.draft as boolean | undefined) ?? false, | |
| createdAt: (prData.created_at as string | undefined) ?? new Date().toISOString(), | |
| body: (prData.body as string | undefined) ?? null | |
| }; | |
| function extractPr(eventType: string, d: Record<string, unknown>): PrRef | null { | |
| const prData = ( | |
| d.pull_request ?? | |
| (Array.isArray((d.check_run as Record<string, unknown> | undefined)?.pull_requests) && | |
| ((d.check_run as Record<string, unknown>).pull_requests as unknown[])[0]) ?? | |
| null | |
| ) as Record<string, unknown> | null; | |
| if (!prData) return null; | |
| const repo = d.repository as Record<string, unknown> | undefined; | |
| const owner = (repo?.owner as Record<string, unknown> | undefined)?.login as string | undefined; | |
| const repoName = repo?.name as string | undefined; | |
| if (!owner || !repoName) return null; | |
| const number = prData.number; | |
| if (typeof number !== 'number' || !Number.isInteger(number)) return null; | |
| return { | |
| owner, | |
| repo: repoName, | |
| number, | |
| title: (prData.title as string | undefined) ?? '', | |
| url: (prData.html_url as string | undefined) ?? '', | |
| author: ((prData.user as Record<string, unknown> | undefined)?.login as string | undefined) ?? 'unknown', | |
| isDraft: (prData.draft as boolean | undefined) ?? false, | |
| createdAt: (prData.created_at as string | undefined) ?? new Date().toISOString(), | |
| body: (prData.body as string | undefined) ?? null | |
| }; |
🤖 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 `@pr-shepherd/agent.ts` around lines 1043 - 1069, Update extractPr to validate
prData.number before constructing the PrRef; return null when it is absent or
invalid so ledgerKey never receives an undefined PR number. Also remove the
current-time fallback for prData.created_at and preserve a missing value in the
returned reference so the 30-day age logic does not treat old PRs as newly
opened.
| function formatEscalationHeader(entry: PrLedgerEntry, staleness: StalenessResult, rung: number): string { | ||
| const emoji = BIN_EMOJI[staleness.bin]; | ||
| const rungLabel = rung === 1 ? '' : rung === 2 ? ' *(escalation)*' : ' *(final escalation → Chief)*'; | ||
| return `${emoji} *Stale PR — ${staleness.bin}*${rungLabel}: <${entry.url}|${entry.owner}/${entry.repo}#${entry.prNumber}> — ${entry.title}`; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Escape the PR title inside the Slack link.
Line 1093 builds <${entry.url}|${entry.owner}/...#${entry.prNumber}> — ${entry.title}. entry.title is copied verbatim from the GitHub payload and is author-controlled.
Slack mrkdwn treats <, >, and & as control characters. A title that contains > or < corrupts the surrounding link markup, so the alert renders without a working PR link. Escape the three characters before interpolation.
🛠️ Proposed fix
+/** Escape the three characters Slack mrkdwn treats as control characters. */
+function esc(s: string): string {
+ return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+}
+
function formatEscalationHeader(entry: PrLedgerEntry, staleness: StalenessResult, rung: number): string {
const emoji = BIN_EMOJI[staleness.bin];
const rungLabel = rung === 1 ? '' : rung === 2 ? ' *(escalation)*' : ' *(final escalation → Chief)*';
- return `${emoji} *Stale PR — ${staleness.bin}*${rungLabel}: <${entry.url}|${entry.owner}/${entry.repo}#${entry.prNumber}> — ${entry.title}`;
+ return `${emoji} *Stale PR — ${staleness.bin}*${rungLabel}: <${entry.url}|${entry.owner}/${entry.repo}#${entry.prNumber}> — ${esc(entry.title)}`;
}📝 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.
| function formatEscalationHeader(entry: PrLedgerEntry, staleness: StalenessResult, rung: number): string { | |
| const emoji = BIN_EMOJI[staleness.bin]; | |
| const rungLabel = rung === 1 ? '' : rung === 2 ? ' *(escalation)*' : ' *(final escalation → Chief)*'; | |
| return `${emoji} *Stale PR — ${staleness.bin}*${rungLabel}: <${entry.url}|${entry.owner}/${entry.repo}#${entry.prNumber}> — ${entry.title}`; | |
| } | |
| /** Escape the three characters Slack mrkdwn treats as control characters. */ | |
| function esc(s: string): string { | |
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |
| } | |
| function formatEscalationHeader(entry: PrLedgerEntry, staleness: StalenessResult, rung: number): string { | |
| const emoji = BIN_EMOJI[staleness.bin]; | |
| const rungLabel = rung === 1 ? '' : rung === 2 ? ' *(escalation)*' : ' *(final escalation → Chief)*'; | |
| return `${emoji} *Stale PR — ${staleness.bin}*${rungLabel}: <${entry.url}|${entry.owner}/${entry.repo}#${entry.prNumber}> — ${esc(entry.title)}`; | |
| } |
🤖 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 `@pr-shepherd/agent.ts` around lines 1090 - 1094, Update formatEscalationHeader
to escape entry.title before interpolating it into the Slack mrkdwn message,
converting &, <, and > to their Slack-safe entities while preserving the
existing PR link markup and title content.
| * Architecture (agreed 2026-08-11): | ||
| * • Webhook events → maintain the ledger (cloud only; only cloud receives webhooks) | ||
| * • Cron timer → evaluate ledger, fire escalation ladder | ||
| * • Cloud is the SOLE WRITER. Local instances are read-only observers. | ||
| * • Ledger stored in workspace-scoped memory (cloud-side, not a Relayfile | ||
| * projection — stale projections showed lag=0 while serving 3-day-old data). | ||
| * • work_unit_id is CARRIED IN from trajectory-lead-0811v3; never invented here. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the docstring: the LOCAL_READ_ONLY flag was removed.
Line 15 states that local instances are read-only observers. The PR description states that the redundant local read-only flag was removed. agent.ts Line 320 still instructs the reader to "set LOCAL_READ_ONLY=true", and no input by that name exists in the inputs block at Lines 101-148. resolveInput never reads it.
Correct both docstrings so they do not reference a flag that has no effect.
🤖 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 `@pr-shepherd/persona.ts` around lines 12 - 18, The documentation still
describes the removed LOCAL_READ_ONLY flag and local read-only behavior. Update
the architecture docstring in persona.ts and the related agent.ts docstring to
remove references to LOCAL_READ_ONLY and instructions to set it, while
preserving the current cloud-writer/local-observer behavior without documenting
a nonexistent input.
| DRY_RUN: { | ||
| description: 'Set to "true" to log what the escalation ladder would post without writing to Slack or recording escalation entries. Use for the initial read-only proof against real PRs.', | ||
| env: 'DRY_RUN', | ||
| default: 'false' | ||
| }, |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
DRY_RUN defaults to "false", which contradicts the stated deployment plan.
The PR description states that dry-run mode is enabled for the initial deployment. The persona docstring at Lines 31-33 states the same intent. The default here is "false", so the agent posts to Slack unless an operator sets the variable.
Combined with the unbounded escalation loop in agent.ts (see the comment at Lines 359-368), a deployment that omits the variable posts to Slack for every stale PR on the first tick after backfill.
Default to "true" and require an explicit opt-in to live posting.
🛠️ Proposed fix
DRY_RUN: {
- description: 'Set to "true" to log what the escalation ladder would post without writing to Slack or recording escalation entries. Use for the initial read-only proof against real PRs.',
+ description: 'Set to "false" to post escalations to Slack. Defaults to "true", which logs what the escalation ladder would post without writing to Slack or recording escalation entries.',
env: 'DRY_RUN',
- default: 'false'
+ default: 'true'
},📝 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.
| DRY_RUN: { | |
| description: 'Set to "true" to log what the escalation ladder would post without writing to Slack or recording escalation entries. Use for the initial read-only proof against real PRs.', | |
| env: 'DRY_RUN', | |
| default: 'false' | |
| }, | |
| DRY_RUN: { | |
| description: 'Set to "false" to post escalations to Slack. Defaults to "true", which logs what the escalation ladder would post without writing to Slack or recording escalation entries.', | |
| env: 'DRY_RUN', | |
| default: 'true' | |
| }, |
🤖 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 `@pr-shepherd/persona.ts` around lines 113 - 117, Update the DRY_RUN
configuration entry to default to "true", ensuring deployments remain read-only
unless the operator explicitly opts into live Slack posting by setting the
environment variable to "false".
There was a problem hiding this comment.
3 issues found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pr-shepherd/agent.ts">
<violation number="1" location="pr-shepherd/agent.ts:240">
P2: The `converted_to_draft`/`ready_for_review` handler branches are dead code: those two events were intentionally omitted from the `triggers.github` registry (noted in the comment at line 129), so `updateLedger` can never receive them and the branches at lines 240-250 never execute. The practical consequence is that a PR converted to draft is never marked `isDraft: true` and therefore stays eligible for staleness escalation even though drafts are meant to be excluded. Either register the two events so draft transitions are tracked, or remove the unreachable branches so the gap is explicit rather than silently misbehaving.</violation>
<violation number="2" location="pr-shepherd/agent.ts:866">
P1: The org backfill bypasses the configured GitHub integration with an unauthenticated direct `fetch`, so it cannot reliably enumerate the App-visible org repositories; implement the crawl through the Relayfile/typed GitHub client instead.</violation>
<violation number="3" location="pr-shepherd/agent.ts:1044">
P2: PR conversation comments never refresh ledger activity because `issue_comment` payloads store the PR under `issue`; include PR-shaped `d.issue` in extraction.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| const names: string[] = []; | ||
| let page = 1; | ||
| while (true) { | ||
| const res = await fetch( |
There was a problem hiding this comment.
P1: The org backfill bypasses the configured GitHub integration with an unauthenticated direct fetch, so it cannot reliably enumerate the App-visible org repositories; implement the crawl through the Relayfile/typed GitHub client instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 866:
<comment>The org backfill bypasses the configured GitHub integration with an unauthenticated direct `fetch`, so it cannot reliably enumerate the App-visible org repositories; implement the crawl through the Relayfile/typed GitHub client instead.</comment>
<file context>
@@ -0,0 +1,1173 @@
+ const names: string[] = [];
+ let page = 1;
+ while (true) {
+ const res = await fetch(
+ `https://api.github.com/orgs/${org}/repos?type=all&per_page=100&page=${page}`,
+ { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }
</file context>
| } | ||
|
|
||
| function extractPr(eventType: string, d: Record<string, unknown>): PrRef | null { | ||
| const prData = ( |
There was a problem hiding this comment.
P2: PR conversation comments never refresh ledger activity because issue_comment payloads store the PR under issue; include PR-shaped d.issue in extraction.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 1044:
<comment>PR conversation comments never refresh ledger activity because `issue_comment` payloads store the PR under `issue`; include PR-shaped `d.issue` in extraction.</comment>
<file context>
@@ -0,0 +1,1173 @@
+}
+
+function extractPr(eventType: string, d: Record<string, unknown>): PrRef | null {
+ const prData = (
+ d.pull_request ??
+ (Array.isArray((d.check_run as Record<string, unknown> | undefined)?.pull_requests) &&
</file context>
| } | ||
| } | ||
|
|
||
| if (eventType === 'github.pull_request.converted_to_draft') { |
There was a problem hiding this comment.
P2: The converted_to_draft/ready_for_review handler branches are dead code: those two events were intentionally omitted from the triggers.github registry (noted in the comment at line 129), so updateLedger can never receive them and the branches at lines 240-250 never execute. The practical consequence is that a PR converted to draft is never marked isDraft: true and therefore stays eligible for staleness escalation even though drafts are meant to be excluded. Either register the two events so draft transitions are tracked, or remove the unreachable branches so the gap is explicit rather than silently misbehaving.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 240:
<comment>The `converted_to_draft`/`ready_for_review` handler branches are dead code: those two events were intentionally omitted from the `triggers.github` registry (noted in the comment at line 129), so `updateLedger` can never receive them and the branches at lines 240-250 never execute. The practical consequence is that a PR converted to draft is never marked `isDraft: true` and therefore stays eligible for staleness escalation even though drafts are meant to be excluded. Either register the two events so draft transitions are tracked, or remove the unreachable branches so the gap is explicit rather than silently misbehaving.</comment>
<file context>
@@ -0,0 +1,1173 @@
+ }
+ }
+
+ if (eventType === 'github.pull_request.converted_to_draft') {
+ entry.isDraft = true;
+ }
</file context>
P1 — deduplicate ledger snapshots: loadAllLedgerEntries now groups by
{owner/repo/prNumber} keeping only the newest ledgerUpdatedAt snapshot;
loadLedgerEntry recalls 10 items and picks the highest ledgerUpdatedAt
so a webhook never applies to a stale snapshot.
P1 — authenticate backfill: listOrgRepos/listOpenPrs now call
githubHeaders() which injects GITHUB_TOKEN/GITHUB_APP_TOKEN as a Bearer
token (5 000 req/hr vs 60 unauthenticated; private repos no longer 404).
P1 — backfill success gate: backfillLedger returns boolean; markBackfillDone
only fires on true — a failed crawl retries on the next cron tick instead of
permanently disabling itself.
P1 — dispatch rung 3: the evaluation loop now calls escalate(..., 3, ...)
after rung 2 is recorded; the earlier code referenced rung 3 in a comment
but never reached it.
P1 — aggregate CI status: check_run.completed now writes per-check status
into entry.ciChecks and derives ciStatus by aggregation (any failing → failing;
any pending → pending; all passing → passing). A later lint-success no longer
masks a failing tests check.
P1 — load newest snapshot on update: loadLedgerEntry picks max ledgerUpdatedAt
from recalled items so events always apply to the current state.
P2 — reset closedAt on reopen: pull_request.reopened now sets closedAt=null
and reviewState='awaiting-review' so a closed-then-reopened PR re-enters
staleness evaluation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed all 7 Codex findings in 16027ff: P1 — deduplicate ledger snapshots ( P1 — authenticate backfill ( P1 — backfill success gate: P1 — dispatch rung 3: the evaluation loop now calls P1 — aggregate CI status ( P2 — reset closedAt on reopen: |
Local harness chat runs as a plain Claude session with the system prompt — ctx.memory.recall() is a cloud SDK API not available in local mode. Previous prompt said 'ground in ledger only' which caused the local agent to say 'I don't have access' and stop. Updated system prompt: when running locally (ledger unavailable), use the gh CLI to answer PR questions and explicitly label the source — 'From ledger' vs 'From live GitHub API'. Never silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
4 issues found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pr-shepherd/persona.ts">
<violation number="1" location="pr-shepherd/persona.ts:95">
P3: The new gh fallback instructions hardcode the org as `AgentWorkforce/<repo>`, but the persona reads the org from the configurable `ORG` env input. A deployment using a non-default ORG would have the local-harness fallback query the wrong org and present that as live PR state. Since prompts can't substitute env vars, consider noting in the prompt that the org comes from the ORG setting (or otherwise avoid baking the default org into the fallback command).</violation>
</file>
<file name="pr-shepherd/agent.ts">
<violation number="1" location="pr-shepherd/agent.ts:419">
P1: Rung 3 is recorded without notifying Chief: `escalate()` only logs the alert, so no one receives the final escalation. Send the relay DM before saving its dedupe record.</violation>
<violation number="2" location="pr-shepherd/agent.ts:889">
P1: A partial backfill is treated as successful, so any repo request failure permanently excludes its existing PRs after `markBackfillDone`. Track per-repo failures and return false (or persist a retry queue) until those repos are crawled.</violation>
<violation number="3" location="pr-shepherd/agent.ts:989">
P2: Loading only ten semantically ranked snapshots does not guarantee the latest ledger state; active PRs can still be evaluated from an old open snapshot and get stale alerts after closure or resolution. Retrieve all versions for this key or store an upsertable/current-state record.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| } | ||
|
|
||
| ctx.log('info', 'pr-shepherd.backfill.done', { seeded, skipped }); | ||
| return true; |
There was a problem hiding this comment.
P1: A partial backfill is treated as successful, so any repo request failure permanently excludes its existing PRs after markBackfillDone. Track per-repo failures and return false (or persist a retry queue) until those repos are crawled.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 889:
<comment>A partial backfill is treated as successful, so any repo request failure permanently excludes its existing PRs after `markBackfillDone`. Track per-repo failures and return false (or persist a retry queue) until those repos are crawled.</comment>
<file context>
@@ -847,13 +886,32 @@ async function backfillLedger(ctx: WorkforceCtx): Promise<void> {
}
ctx.log('info', 'pr-shepherd.backfill.done', { seeded, skipped });
+ return true;
} catch (err) {
ctx.log('error', 'pr-shepherd.backfill.error', { error: String(err) });
</file context>
| const rung3Key = escalationKey(entry.owner, entry.repo, entry.prNumber, staleness.bin, 3); | ||
| const rung3Record = await loadEscalationRecord(ctx, rung3Key); | ||
| if (!rung3Record) { | ||
| await escalate(ctx, entry, staleness, 3, channel, isDryRun); |
There was a problem hiding this comment.
P1: Rung 3 is recorded without notifying Chief: escalate() only logs the alert, so no one receives the final escalation. Send the relay DM before saving its dedupe record.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 419:
<comment>Rung 3 is recorded without notifying Chief: `escalate()` only logs the alert, so no one receives the final escalation. Send the relay DM before saving its dedupe record.</comment>
<file context>
@@ -378,9 +409,17 @@ export async function evaluateLedger(ctx: WorkforceCtx): Promise<void> {
+ const rung3Key = escalationKey(entry.owner, entry.repo, entry.prNumber, staleness.bin, 3);
+ const rung3Record = await loadEscalationRecord(ctx, rung3Key);
+ if (!rung3Record) {
+ await escalate(ctx, entry, staleness, 3, channel, isDryRun);
+ escalated++;
+ }
</file context>
| // stale snapshot (which could resurrect closedAt or revert review state). | ||
| const items = await ctx.memory.recall(`pr ledger ${key}`, { | ||
| tags: [LEDGER_TAG, `pr-shepherd:key:${key}`], | ||
| limit: 10 |
There was a problem hiding this comment.
P2: Loading only ten semantically ranked snapshots does not guarantee the latest ledger state; active PRs can still be evaluated from an old open snapshot and get stale alerts after closure or resolution. Retrieve all versions for this key or store an upsertable/current-state record.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 989:
<comment>Loading only ten semantically ranked snapshots does not guarantee the latest ledger state; active PRs can still be evaluated from an old open snapshot and get stale alerts after closure or resolution. Retrieve all versions for this key or store an upsertable/current-state record.</comment>
<file context>
@@ -922,16 +980,22 @@ function escalationKey(owner: string, repo: string, prNumber: number, bin: Stale
const items = await ctx.memory.recall(`pr ledger ${key}`, {
tags: [LEDGER_TAG, `pr-shepherd:key:${key}`],
- limit: 1
+ limit: 10
});
if (!items.length) return null;
</file context>
| 'When answering questions about PR state, use the ledger (ctx.memory) as the primary source — never invent data. ' + | ||
| 'State the staleness bin and the reason it fired in every alert. ' + | ||
| 'If you are running in a local harness where ctx.memory is not available, fall back to the GitHub CLI: ' + | ||
| '`gh pr list --state open --repo AgentWorkforce/<repo>` or `gh pr list --state open --json number,title,author,createdAt,reviewDecision --repo AgentWorkforce/<repo>` ' + |
There was a problem hiding this comment.
P3: The new gh fallback instructions hardcode the org as AgentWorkforce/<repo>, but the persona reads the org from the configurable ORG env input. A deployment using a non-default ORG would have the local-harness fallback query the wrong org and present that as live PR state. Since prompts can't substitute env vars, consider noting in the prompt that the org comes from the ORG setting (or otherwise avoid baking the default org into the fallback command).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/persona.ts, line 95:
<comment>The new gh fallback instructions hardcode the org as `AgentWorkforce/<repo>`, but the persona reads the org from the configurable `ORG` env input. A deployment using a non-default ORG would have the local-harness fallback query the wrong org and present that as live PR state. Since prompts can't substitute env vars, consider noting in the prompt that the org comes from the ORG setting (or otherwise avoid baking the default org into the fallback command).</comment>
<file context>
@@ -89,8 +89,13 @@ export default definePersona({
+ 'When answering questions about PR state, use the ledger (ctx.memory) as the primary source — never invent data. ' +
+ 'State the staleness bin and the reason it fired in every alert. ' +
+ 'If you are running in a local harness where ctx.memory is not available, fall back to the GitHub CLI: ' +
+ '`gh pr list --state open --repo AgentWorkforce/<repo>` or `gh pr list --state open --json number,title,author,createdAt,reviewDecision --repo AgentWorkforce/<repo>` ' +
+ 'to answer questions about current PR state. Always tell the user which data source you used: ' +
+ '"From ledger (cloud memory):" vs "From live GitHub API (ledger unavailable):". ' +
</file context>
The original deployment ran backfillLedger() unauthenticated and marked it done unconditionally (Codex P1 bug). The done flag persists in workspace memory across redeployments, so the authenticated re-crawl code never runs. FORCE_BACKFILL=true overrides the done check on the next cron tick so the full authenticated crawl runs. Set back to false after one tick to avoid re-seeding every hour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… bot ID
Root cause: Slack events arrive through relay and satisfy isRelaycastMessageEvent.
With relay DM checked before slack., Slack @mentions were routed to
handleInboxMessage where they silently dropped on "no-text". Fix: check
event.type.startsWith('slack.') BEFORE isRelaycastMessageEvent — identical
to joke-bot's confirmed-working handler ordering.
Also tighten SLACK_BOT_USER_ID handling to fail closed when unset (joke-bot
pattern): without the bot's user ID we cannot confirm the message is directed
at this agent and would answer every channel message. Previously we accepted
any non-bot message when SLACK_BOT_USER_ID was absent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pr-shepherd/agent.ts">
<violation number="1" location="pr-shepherd/agent.ts:366">
P2: FORCE_BACKFILL is a persistent persona input with no automatic reset. While it stays "true", this line forces `backfillDone` to false on every hourly cron tick, so `backfillLedger(ctx)` re-runs the full org-wide crawl (137+ repos, each paginated through the GitHub REST API) every single hour indefinitely. The crawl is idempotent (it skips existing ledger entries), so there's no data corruption, but it burns constant API quota and does pointless work the moment the operator forgets to flip the flag back to "false". Consider making the force effect one-shot: consume the flag after a successful backfill (e.g. record a "force consumed" marker in workspace memory and only honor FORCE_BACKFILL when that marker is absent), so a forgotten flag degrades to a single extra crawl rather than a permanent hourly crawl.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // this when the first backfill ran unauthenticated (partial results) and you | ||
| // want to re-seed with the authenticated GitHub token now available. | ||
| const forceBackfill = resolveInput(ctx, 'FORCE_BACKFILL') === 'true'; | ||
| const backfillDone = forceBackfill ? false : await isBackfillDone(ctx); |
There was a problem hiding this comment.
P2: FORCE_BACKFILL is a persistent persona input with no automatic reset. While it stays "true", this line forces backfillDone to false on every hourly cron tick, so backfillLedger(ctx) re-runs the full org-wide crawl (137+ repos, each paginated through the GitHub REST API) every single hour indefinitely. The crawl is idempotent (it skips existing ledger entries), so there's no data corruption, but it burns constant API quota and does pointless work the moment the operator forgets to flip the flag back to "false". Consider making the force effect one-shot: consume the flag after a successful backfill (e.g. record a "force consumed" marker in workspace memory and only honor FORCE_BACKFILL when that marker is absent), so a forgotten flag degrades to a single extra crawl rather than a permanent hourly crawl.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 366:
<comment>FORCE_BACKFILL is a persistent persona input with no automatic reset. While it stays "true", this line forces `backfillDone` to false on every hourly cron tick, so `backfillLedger(ctx)` re-runs the full org-wide crawl (137+ repos, each paginated through the GitHub REST API) every single hour indefinitely. The crawl is idempotent (it skips existing ledger entries), so there's no data corruption, but it burns constant API quota and does pointless work the moment the operator forgets to flip the flag back to "false". Consider making the force effect one-shot: consume the flag after a successful backfill (e.g. record a "force consumed" marker in workspace memory and only honor FORCE_BACKFILL when that marker is absent), so a forgotten flag degrades to a single extra crawl rather than a permanent hourly crawl.</comment>
<file context>
@@ -358,7 +358,12 @@ export async function evaluateLedger(ctx: WorkforceCtx): Promise<void> {
+ // this when the first backfill ran unauthenticated (partial results) and you
+ // want to re-seed with the authenticated GitHub token now available.
+ const forceBackfill = resolveInput(ctx, 'FORCE_BACKFILL') === 'true';
+ const backfillDone = forceBackfill ? false : await isBackfillDone(ctx);
if (!backfillDone) {
const ok = await backfillLedger(ctx);
</file context>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
pr-shepherd/agent.ts (1)
898-906: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winKeep partial backfills retryable.
The inner
catchrecords a repository failure, but Line 906 still returnstrue.evaluateLedgerthen records backfill completion and stops retries.Do not treat HTTP
404as an empty PR list. A repository with no open PRs returns200with[]. A404indicates an inaccessible or missing repository.Proposed fix
+ let complete = true; for (const repo of repos) { try { const prs = await listOpenPrs(org, repo); // ... } catch (repoErr) { + complete = false; ctx.log('warn', 'pr-shepherd.backfill.repo-error', { repo, error: String(repoErr) }); } } ctx.log('info', 'pr-shepherd.backfill.done', { seeded, skipped }); - return true; + return complete;- if (res.status === 404) break; if (!res.ok) throw new Error(/* ... */);Also applies to: 970-979
🤖 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 `@pr-shepherd/agent.ts` around lines 898 - 906, Make the backfill flow remain retryable when any repository fetch fails: track whether the per-repository catch in the backfill loop handled an error and return false instead of true when failures occurred, so evaluateLedger does not record completion. Preserve 200 responses with an empty PR list as successful, but ensure HTTP 404s follow the repository-error path rather than being converted into an empty list; apply the same behavior to the corresponding logic near the second referenced section.
♻️ Duplicate comments (3)
pr-shepherd/agent.ts (3)
420-429: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winLimit escalations per tick before sending rung 3.
escalatedhas no upper bound. The new rung-3 branch can send one additional Chief escalation for every eligible PR in one cron tick.Add
MAX_ESCALATIONS_PER_TICK. Stop new sends after the limit. Continue normal evaluation on the next tick.🤖 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 `@pr-shepherd/agent.ts` around lines 420 - 429, Bound the rung-3 dispatch in the escalation evaluation flow using a MAX_ESCALATIONS_PER_TICK constant. Before calling escalate in the rung3Record branch, only send when escalated is below the limit; otherwise skip the send while allowing normal evaluation to continue so deferred escalations can be handled on the next tick.
999-1015: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDo not deduplicate after an arbitrary recall cap.
ctx.memory.saveis append-only. A PR with more than ten snapshots can have its newest record omitted fromloadLedgerEntry. More than 500 saved snapshots can omit whole PRs fromloadAllLedgerEntriesbefore theMapruns.
pr-shepherd/agent.ts#L999-L1015: retrieve all snapshots for the exact PR key, or use a keyed workspace upsert.pr-shepherd/agent.ts#L1025-L1050: page deterministic ledger storage or use the same keyed upsert model before applying deduplication.#!/bin/bash set -euo pipefail rg -n -C5 'memory\.(save|recall)' pr-shepherd --glob '*.ts' rg -n -C6 'interface .*Memory|recall\(|save\(' node_modules/@agentworkforce --glob '*.d.ts' --glob '*.ts' 2>/dev/null || true🤖 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 `@pr-shepherd/agent.ts` around lines 999 - 1015, Replace the arbitrary recall limits in loadLedgerEntry and loadAllLedgerEntries with deterministic retrieval of all snapshots for each exact PR key, or migrate both flows to a keyed workspace upsert. Ensure deduplication by ledgerUpdatedAt occurs only after complete retrieval; update pr-shepherd/agent.ts lines 999-1015 and 1025-1050 accordingly.
1004-1007: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick winRead ledger entries from workspace scope.
saveLedgerEntrywrites workspace-scoped records, but this recall omitsscope: 'workspace'. The lookup can return no record and causeupdateLedgerto recreate a stale entry.Proposed fix
const items = await ctx.memory.recall(`pr ledger ${key}`, { tags: [LEDGER_TAG, `pr-shepherd:key:${key}`], + scope: 'workspace', limit: 10 });🤖 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 `@pr-shepherd/agent.ts` around lines 1004 - 1007, Update the ledger recall call in updateLedger to include the workspace scope option, matching saveLedgerEntry’s workspace-scoped writes while preserving the existing tags and limit.
🤖 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 `@pr-shepherd/agent.ts`:
- Around line 152-158: Update the Slack event handling path introduced around
handleSlackMessage so inbound Slack thread timestamps are replied to through the
supported Slack API, using slackClient.reply(chanId, threadTs, answer), or
resolve the corresponding relay message reference before calling replyTo. Do not
pass the Slack threadTs directly to replyTo, while preserving the existing
escalation behavior that uses relay message references.
---
Outside diff comments:
In `@pr-shepherd/agent.ts`:
- Around line 898-906: Make the backfill flow remain retryable when any
repository fetch fails: track whether the per-repository catch in the backfill
loop handled an error and return false instead of true when failures occurred,
so evaluateLedger does not record completion. Preserve 200 responses with an
empty PR list as successful, but ensure HTTP 404s follow the repository-error
path rather than being converted into an empty list; apply the same behavior to
the corresponding logic near the second referenced section.
---
Duplicate comments:
In `@pr-shepherd/agent.ts`:
- Around line 420-429: Bound the rung-3 dispatch in the escalation evaluation
flow using a MAX_ESCALATIONS_PER_TICK constant. Before calling escalate in the
rung3Record branch, only send when escalated is below the limit; otherwise skip
the send while allowing normal evaluation to continue so deferred escalations
can be handled on the next tick.
- Around line 999-1015: Replace the arbitrary recall limits in loadLedgerEntry
and loadAllLedgerEntries with deterministic retrieval of all snapshots for each
exact PR key, or migrate both flows to a keyed workspace upsert. Ensure
deduplication by ledgerUpdatedAt occurs only after complete retrieval; update
pr-shepherd/agent.ts lines 999-1015 and 1025-1050 accordingly.
- Around line 1004-1007: Update the ledger recall call in updateLedger to
include the workspace scope option, matching saveLedgerEntry’s workspace-scoped
writes while preserving the existing tags and limit.
🪄 Autofix
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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: dc4f947e-402e-4d95-9709-67779a935385
📒 Files selected for processing (2)
pr-shepherd/agent.tspr-shepherd/persona.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- pr-shepherd/persona.ts
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pr-shepherd/agent.ts">
<violation number="1" location="pr-shepherd/agent.ts:775">
P1: Slack Q&A is disabled for the documented optional-ID configuration because this early return drops every message; make the persona require/configure SLACK_BOT_USER_ID for Slack, or retain the documented fallback behavior.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // persona inputs to enable Slack Q&A. | ||
| const botUserId = resolveInput(ctx, 'SLACK_BOT_USER_ID')?.split('__')[0]; | ||
| if (!botUserId) { | ||
| ctx.log('warn', 'pr-shepherd.slack.skip', { reason: 'no-bot-user-id-configured' }); |
There was a problem hiding this comment.
P1: Slack Q&A is disabled for the documented optional-ID configuration because this early return drops every message; make the persona require/configure SLACK_BOT_USER_ID for Slack, or retain the documented fallback behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 775:
<comment>Slack Q&A is disabled for the documented optional-ID configuration because this early return drops every message; make the persona require/configure SLACK_BOT_USER_ID for Slack, or retain the documented fallback behavior.</comment>
<file context>
@@ -763,19 +766,28 @@ export async function handleSlackMessage(
- }
- question = rawText.replace(mention, '').trim();
+ if (!botUserId) {
+ ctx.log('warn', 'pr-shepherd.slack.skip', { reason: 'no-bot-user-id-configured' });
+ return;
}
</file context>
…onsumers Move the Slack message-handling helpers (readSlackMessage, skipReason, bareChannelId, stripLeadingMention, conversationKeyForSlack, postReply) out of inbox-buddy/lib/slack.ts into shared/slack.ts so every agent gets the correct handler ordering and fail-closed bot-ID guard without reimplementing them. - shared/slack.ts — new canonical stopgap (promotion target: @agentworkforce/delivery) - inbox-buddy/lib/slack.ts — now a thin re-export shim - pr-shepherd/agent.ts — handleSlackMessage uses readSlackMessage + skipReason + defaultSlack + postReply from shared/slack.ts; channel guard + bot filter are now one call instead of duplicated inline logic Docstring updated to note the real promotion target is @agentworkforce/delivery (Slack message-handling helpers added to workforce/packages/delivery/src/slack.ts mirroring the Telegram equivalents in telegram.ts). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="pr-shepherd/agent.ts">
<violation number="1" location="pr-shepherd/agent.ts:819">
P2: Top-level @mentions now produce a new channel message instead of replying in the mention’s thread. Preserve `msg.ts` as the fallback thread timestamp so Slack Q&A remains in-thread.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| } | ||
|
|
||
| const slack = defaultSlack(); | ||
| await postReply(ctx, slack, msg, answer); |
There was a problem hiding this comment.
P2: Top-level @mentions now produce a new channel message instead of replying in the mention’s thread. Preserve msg.ts as the fallback thread timestamp so Slack Q&A remains in-thread.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pr-shepherd/agent.ts, line 819:
<comment>Top-level @mentions now produce a new channel message instead of replying in the mention’s thread. Preserve `msg.ts` as the fallback thread timestamp so Slack Q&A remains in-thread.</comment>
<file context>
@@ -817,12 +815,9 @@ export async function handleSlackMessage(
- ctx.log('info', 'pr-shepherd.slack.replied', { channel: chanId, ts: result.ts });
- }
+ const slack = defaultSlack();
+ await postReply(ctx, slack, msg, answer);
+ ctx.log('info', 'pr-shepherd.slack.replied', { channel: bareChannelId(msg.channel) });
}
</file context>
| await postReply(ctx, slack, msg, answer); | |
| await postReply(ctx, slack, { ...msg, threadTs: msg.threadTs ?? msg.ts }, answer); |
Summary
Adds
pr-shepherd, a cloud persona that watches every open PR org-wide, classifies stale ones into four named bins, and escalates via Slack with a per-rung dedupe key.What it does
pull_request.*,pull_request_review.*,check_run.completed,issue_comment.createdevents update per-PR state as they landreplyTo, rung 3 DMs Chief; each rung is deduped by{owner}/{repo}/{prNumber}/{bin}/{rung}awaiting-review(3d),awaiting-author(2d),ci-red(1d),abandoned(14d)Architecture decisions
DRY_RUN=truedeploys evaluate and log but write nothing to Slack — used for the initial proof runtrajectory-pointerextractor parses<!-- trajectory: work_unit_id={id} work_unit_surface={surface} session_ref={uuid} -->from PR bodies — a preparation hook for the trajectory pipeline; never overwrites an existing non-null valueCommits
2393390— initial scaffold: agent.ts + persona.ts with webhook/cron/relay-DM handlers6343d62— reply directly to relay sender viactx.relay.dm(); Slack as fallbackcba9e40— removepull_request.converted_to_draftandpull_request.ready_for_review(not in trigger registry); addpull_request.reopened2e8bd87— removeLOCAL_READ_ONLYflag — redundant with the no-channel guard + workspace-scoped memory dedup272b0ca— add Slack @mention trigger + compile localpersona.jsonDeployed
Already running as
daeafe10-ada4-4de6-8d02-c3fa8188099aon workspacerw_7ccfea89in dry-run mode (DRY_RUN=true, no SLACK_CHANNEL). Webhooks live. First cron tick seeds the ledger.Test plan
pr-shepherd.evaluate.dry-runentries listing bins, reasons, and rung 1 targets — nothing posted to Slack<!-- trajectory: work_unit_id=X work_unit_surface=linear session_ref=Y -->on a PR body; next tick logspr-shepherd.ledger.trajectory-pointerwith non-nullwork_unit_id@pr-shepherd what PRs are awaiting review?inC0BJL95JU8G; agent replies in-thread with ledger stateagentworkforce agent pr-shepherdopens local harness with same ledger visible🤖 Generated with Claude Code
Summary by cubic
Adds
pr-shepherd, an org-wide PR staleness watcher that classifies stale PRs and escalates in Slack with safe dedupe and proper threading. Also centralizes Slack helpers inshared/slack.ts(with aninbox-buddyre-export) for consistent parsing, channel guard, and replies.New Features
FORCE_BACKFILL=truere-runs the crawl.ESCALATION_RUNG2_MULTIPLIER.SLACK_BOT_USER_IDcheck) and relay DMs that reply to the sender; local harness falls back togh.Bug Fixes
check_run.completed(any failing check →ci-red);pull_request.reopenedresetsclosedAtso reopened PRs re-enter evaluation.replyTofor rung 2; route Slack events before relay DMs to avoid drops; requireSLACK_BOT_USER_IDfor @mention handling; centralize helpers inshared/slack.tsand re-export frominbox-buddyto keep behavior consistent across agents.Written for commit be492fb. Summary will update on new commits.