@@ -117,18 +117,25 @@ const eventName = process.env.GITHUB_EVENT_NAME!;
117117// Whether this event is a pull_request event (PR review, not an issue).
118118const isPullRequest = eventName === "pull_request" ;
119119
120+ // Whether this event is an automated event with no associated issue or PR.
121+ // These events (schedule, release, deployment_status) don't have a target
122+ // issue/PR number for comment posting — results are committed to state only.
123+ const isAutomatedEvent = [ "schedule" , "release" , "deployment_status" ] . includes ( eventName ) ;
124+
120125// "owner/repo" format — used when calling the GitHub REST API via `gh api`.
121126const repo = process . env . GITHUB_REPOSITORY ! ;
122127
123128// Fall back to "main" if the repository's default branch is not set in the event.
124129const defaultBranch = event . repository ?. default_branch ?? "main" ;
125130
126131// The target number is the issue number for issue events, or the PR number for
127- // pull_request events. In GitHub, PRs are also issues, so the number can be used
128- // with both issue and PR API endpoints .
132+ // pull_request events. For automated events (schedule, release, deployment_status),
133+ // there is no target number — set to 0 .
129134const targetNumber : number = isPullRequest
130135 ? event . pull_request . number
131- : event . issue . number ;
136+ : isAutomatedEvent
137+ ? 0
138+ : event . issue ?. number ?? 0 ;
132139
133140// Read the committed `.pi` defaults and pass them explicitly to the runtime.
134141// This prevents provider/model drift from host-level config (for example a
@@ -214,16 +221,32 @@ async function main() {
214221 // ── Read title and body from the event payload ───────────────────────────────
215222 // For pull_request events, use the PR title and body.
216223 // For issue events, use the issue title and body from the webhook payload.
224+ // For automated events (schedule, release, deployment_status), title/body
225+ // are derived from the event context (handled by the router's skill prompt).
217226 // GitHub truncates string fields at 65 536 characters in webhook payloads, so
218227 // we fall back to the API only when the body hits that limit.
219228 let title : string ;
220229 let body : string ;
221230 if ( isPullRequest ) {
222231 title = event . pull_request . title ?? "" ;
223232 body = event . pull_request . body ?? "" ;
233+ } else if ( isAutomatedEvent ) {
234+ // Automated events don't have an issue/PR — the router builds the prompt
235+ // from the event payload. Title/body are placeholders for context lines.
236+ if ( eventName === "release" ) {
237+ title = `Release: ${ event . release ?. tag_name ?? "unknown" } ` ;
238+ body = event . release ?. body ?? "" ;
239+ } else if ( eventName === "deployment_status" ) {
240+ title = `Deployment: ${ event . deployment ?. environment ?? "unknown" } ` ;
241+ body = `Status: ${ event . deployment_status ?. state ?? "unknown" } ` ;
242+ } else {
243+ // schedule
244+ title = `Scheduled run: ${ event . schedule ?? "unknown cron" } ` ;
245+ body = "" ;
246+ }
224247 } else {
225- title = event . issue . title ?? "" ;
226- body = event . issue . body ?? "" ;
248+ title = event . issue ? .title ?? "" ;
249+ body = event . issue ? .body ?? "" ;
227250 if ( body . length >= 65536 ) {
228251 body = await gh ( "issue" , "view" , String ( targetNumber ) , "--json" , "body" , "--jq" , ".body" ) ;
229252 }
@@ -233,16 +256,16 @@ async function main() {
233256 // Each issue maps to exactly one `pi` session file via `state/issues/<n>.json`.
234257 // If a mapping exists AND the referenced session file is still present, we resume
235258 // the conversation by passing `--session <path>` to `pi`. Otherwise we start fresh.
236- // For pull_request events, session continuity is not needed — each review is
237- // a one-shot operation that starts fresh.
259+ // For pull_request and automated events, session continuity is not needed —
260+ // each run is a one-shot operation that starts fresh.
238261 mkdirSync ( issuesDir , { recursive : true } ) ;
239262 mkdirSync ( sessionsDir , { recursive : true } ) ;
240263
241264 let mode = "new" ;
242265 let sessionPath = "" ;
243- const mappingFile = isPullRequest ? "" : resolve ( issuesDir , `${ targetNumber } .json` ) ;
266+ const mappingFile = ( isPullRequest || isAutomatedEvent ) ? "" : resolve ( issuesDir , `${ targetNumber } .json` ) ;
244267
245- if ( ! isPullRequest && existsSync ( mappingFile ) ) {
268+ if ( ! isPullRequest && ! isAutomatedEvent && existsSync ( mappingFile ) ) {
246269 try {
247270 const mapping = JSON . parse ( readFileSync ( mappingFile , "utf-8" ) ) ;
248271 if ( existsSync ( mapping . sessionPath ) ) {
@@ -487,8 +510,9 @@ async function main() {
487510 // ── Persist issue → session mapping ─────────────────────────────────────────
488511 // Write (or overwrite) the mapping file so that the next run for this issue
489512 // can locate the correct session transcript and resume the conversation.
490- // For pull_request events, session mapping is skipped — each review is one-shot.
491- if ( latestSession && ! isPullRequest ) {
513+ // For pull_request and automated events, session mapping is skipped — each
514+ // run is a one-shot operation.
515+ if ( latestSession && ! isPullRequest && ! isAutomatedEvent ) {
492516 writeFileSync (
493517 mappingFile ,
494518 JSON . stringify ( {
@@ -498,7 +522,7 @@ async function main() {
498522 } , null , 2 ) + "\n"
499523 ) ;
500524 console . log ( `Saved mapping: issue #${ targetNumber } -> ${ latestSession } ` ) ;
501- } else if ( ! latestSession && ! isPullRequest ) {
525+ } else if ( ! latestSession && ! isPullRequest && ! isAutomatedEvent ) {
502526 console . log ( "Warning: no session file found to map" ) ;
503527 }
504528
@@ -557,6 +581,92 @@ async function main() {
557581 console . log ( `Persisted ${ routeResult . skill } result for #${ resultIdentifier } ` ) ;
558582 }
559583
584+ // ── Persist retro results ───────────────────────────────────────────────────
585+ // Retro reports are saved as date-stamped JSON in state/results/retro/ so
586+ // subsequent retros can compare trends across weeks.
587+ if ( routeResult && routeResult . skill === "retro" ) {
588+ const resultDir = resolve ( stateDir , "results" , "retro" ) ;
589+ mkdirSync ( resultDir , { recursive : true } ) ;
590+
591+ const dateStr = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
592+ const result = {
593+ skill : "retro" ,
594+ date : dateStr ,
595+ timestamp : new Date ( ) . toISOString ( ) ,
596+ status : "completed" ,
597+ commit : process . env . GITHUB_SHA ?? null ,
598+ } ;
599+ writeFileSync (
600+ resolve ( resultDir , `${ dateStr } .json` ) ,
601+ JSON . stringify ( result , null , 2 ) + "\n"
602+ ) ;
603+ console . log ( `Persisted retro result for ${ dateStr } ` ) ;
604+ }
605+
606+ // ── Persist benchmark results ───────────────────────────────────────────────
607+ // Benchmark results are saved as date-stamped JSON in state/benchmarks/history/
608+ // and the baseline file is referenced for comparison.
609+ if ( routeResult && routeResult . skill === "benchmark" ) {
610+ const historyDir = resolve ( stateDir , "benchmarks" , "history" ) ;
611+ mkdirSync ( historyDir , { recursive : true } ) ;
612+
613+ const dateStr = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
614+ const result = {
615+ skill : "benchmark" ,
616+ date : dateStr ,
617+ timestamp : new Date ( ) . toISOString ( ) ,
618+ status : "completed" ,
619+ commit : process . env . GITHUB_SHA ?? null ,
620+ } ;
621+ writeFileSync (
622+ resolve ( historyDir , `${ dateStr } .json` ) ,
623+ JSON . stringify ( result , null , 2 ) + "\n"
624+ ) ;
625+ console . log ( `Persisted benchmark result for ${ dateStr } ` ) ;
626+ }
627+
628+ // ── Persist canary results ──────────────────────────────────────────────────
629+ // Canary monitoring results track deployment health checks over time.
630+ if ( routeResult && routeResult . skill === "canary" ) {
631+ const resultDir = resolve ( stateDir , "results" , "canary" ) ;
632+ mkdirSync ( resultDir , { recursive : true } ) ;
633+
634+ const timestamp = new Date ( ) . toISOString ( ) ;
635+ const result = {
636+ skill : "canary" ,
637+ url : routeResult . context . url ?? null ,
638+ timestamp,
639+ status : "completed" ,
640+ commit : process . env . GITHUB_SHA ?? null ,
641+ } ;
642+ writeFileSync (
643+ resolve ( resultDir , `${ timestamp . slice ( 0 , 19 ) . replace ( / : / g, "-" ) } .json` ) ,
644+ JSON . stringify ( result , null , 2 ) + "\n"
645+ ) ;
646+ console . log ( `Persisted canary result` ) ;
647+ }
648+
649+ // ── Persist document-release results ────────────────────────────────────────
650+ // Track which releases have been documented by the agent.
651+ if ( routeResult && routeResult . skill === "document-release" ) {
652+ const resultDir = resolve ( stateDir , "results" , "releases" ) ;
653+ mkdirSync ( resultDir , { recursive : true } ) ;
654+
655+ const tagName = event . release ?. tag_name ?? "unknown" ;
656+ const result = {
657+ skill : "document-release" ,
658+ tag : tagName ,
659+ timestamp : new Date ( ) . toISOString ( ) ,
660+ status : "completed" ,
661+ commit : process . env . GITHUB_SHA ?? null ,
662+ } ;
663+ writeFileSync (
664+ resolve ( resultDir , `${ tagName . replace ( / [ ^ a - z A - Z 0 - 9 . _ - ] / g, "_" ) } .json` ) ,
665+ JSON . stringify ( result , null , 2 ) + "\n"
666+ ) ;
667+ console . log ( `Persisted document-release result for ${ tagName } ` ) ;
668+ }
669+
560670 // ── Commit and push state changes ───────────────────────────────────────────
561671 // Stage all changes (session log, mapping JSON, any files the agent edited),
562672 // commit only if the index is dirty, then push with a retry-on-conflict loop.
@@ -569,7 +679,9 @@ async function main() {
569679 // exitCode !== 0 means there are staged changes to commit.
570680 const commitMsg = isPullRequest
571681 ? `gstack-intelligence: review PR #${ targetNumber } `
572- : `gstack-intelligence: work on issue #${ targetNumber } ` ;
682+ : isAutomatedEvent
683+ ? `gstack-intelligence: ${ routeResult ?. skill ?? eventName } run`
684+ : `gstack-intelligence: work on issue #${ targetNumber } ` ;
573685 const commitResult = await run ( [ "git" , "commit" , "-m" , commitMsg ] ) ;
574686 if ( commitResult . exitCode !== 0 ) {
575687 console . error ( "git commit failed with exit code" , commitResult . exitCode ) ;
@@ -594,22 +706,28 @@ async function main() {
594706 // if the push later fails. The push failure throw (below) must come AFTER the
595707 // comment is posted — otherwise the throw would skip the comment entirely and
596708 // the user would get no reply.
709+ // For automated events (schedule, release, deployment_status), there is no
710+ // target issue/PR to comment on — results are committed to state only.
597711 // For pull_request events, post as a PR comment; for issue events, post as an
598712 // issue comment. Both use the same underlying GitHub API.
599- const trimmedText = agentText . trim ( ) ;
600- let commentBody = trimmedText . length > 0
601- ? trimmedText . slice ( 0 , MAX_COMMENT_LENGTH )
602- : `✅ The agent ran successfully but did not produce a text response. Check the repository for any file changes that were made.\n\nFor full details, see the [workflow run logs](https://github.com/${ repo } /actions).` ;
603- if ( ! pushSucceeded ) {
604- commentBody += `\n\n---\n⚠️ **Warning:** The agent's session state could not be pushed to the repository. Conversation context may not be preserved for follow-up comments. See the [workflow run logs](https://github.com/${ repo } /actions) for details.` ;
605- }
606- // Append the agent signature as a hidden HTML comment for bot-loop prevention.
607- // The router checks incoming comments for this signature and skips them.
608- commentBody += "\n" + AGENT_SIGNATURE ;
609- if ( isPullRequest ) {
610- await gh ( "pr" , "comment" , String ( targetNumber ) , "--body" , commentBody ) ;
713+ if ( ! isAutomatedEvent ) {
714+ const trimmedText = agentText . trim ( ) ;
715+ let commentBody = trimmedText . length > 0
716+ ? trimmedText . slice ( 0 , MAX_COMMENT_LENGTH )
717+ : `✅ The agent ran successfully but did not produce a text response. Check the repository for any file changes that were made.\n\nFor full details, see the [workflow run logs](https://github.com/${ repo } /actions).` ;
718+ if ( ! pushSucceeded ) {
719+ commentBody += `\n\n---\n⚠️ **Warning:** The agent's session state could not be pushed to the repository. Conversation context may not be preserved for follow-up comments. See the [workflow run logs](https://github.com/${ repo } /actions) for details.` ;
720+ }
721+ // Append the agent signature as a hidden HTML comment for bot-loop prevention.
722+ // The router checks incoming comments for this signature and skips them.
723+ commentBody += "\n" + AGENT_SIGNATURE ;
724+ if ( isPullRequest ) {
725+ await gh ( "pr" , "comment" , String ( targetNumber ) , "--body" , commentBody ) ;
726+ } else {
727+ await gh ( "issue" , "comment" , String ( targetNumber ) , "--body" , commentBody ) ;
728+ }
611729 } else {
612- await gh ( "issue" , "comment" , String ( targetNumber ) , "--body" , commentBody ) ;
730+ console . log ( `Automated event ( ${ eventName } ) — skipping comment posting, results committed to state` ) ;
613731 }
614732
615733 // Throw push failure AFTER the comment has been posted so the user always
0 commit comments