Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions granola/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@
* → ask the model "is this a prospect call, and what did they ask for?"
* → if yes: file a Linear issue, then have the coding agent open a PR for it
*/
import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime';
import {
defineAgent,
draftFile,
encodeSegment,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx
} from '@agentworkforce/runtime';

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

interface Ask {
isProspect: boolean;
Expand All @@ -21,7 +33,6 @@ export default defineAgent({
// `event` to the declared granola trigger, so there's no clock case here).
const notePath = readNotePath(event.payload);
if (!notePath || !notePath.includes('/granola/notes/')) return; // ignore folders/other writes
if (!ctx.linear) throw new Error('granola-prospect requires the linear integration');

const transcript = await readNote(ctx, notePath);
if (!transcript) return;
Expand All @@ -33,18 +44,44 @@ export default defineAgent({
}

const teamId = await resolveTeamId(ctx);
const issue = await ctx.linear.createIssue({ teamId, title: ask.title, description: ask.summary });
ctx.log('info', 'granola-prospect.issue-created', { url: issue.url });
const client = vfsClient();
const created = await writeJsonFile(
client,
'linear',
'createIssue',
`/linear/issues/${draftFile('create issue')}`,
{ teamId, title: ask.title, description: ask.summary }
);
// The writeback worker returns a receipt carrying the real issue URL/id once
// the Linear create lands. Without a receipt we can't link the issue or
// address a follow-up comment, so log and continue with the implementation.
const issueUrl = created.receipt?.url;
const issueId = created.receipt?.id ?? created.receipt?.identifier;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: PR-link comment can be dropped when createIssue receipt arrives late because issueId is captured once and never refreshed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At granola/agent.ts, line 59:

<comment>PR-link comment can be dropped when `createIssue` receipt arrives late because `issueId` is captured once and never refreshed.</comment>

<file context>
@@ -33,18 +44,44 @@ export default defineAgent({
+  // the Linear create lands. Without a receipt we can't link the issue or
+  // address a follow-up comment, so log and continue with the implementation.
+  const issueUrl = created.receipt?.url;
+  const issueId = created.receipt?.id ?? created.receipt?.identifier;
+  if (!issueUrl) {
+    ctx.log('warn', 'granola-prospect.issue.no-receipt', { draftPath: created.path });
</file context>

if (!issueUrl) {
ctx.log('warn', 'granola-prospect.issue.no-receipt', { draftPath: created.path });
} else {
ctx.log('info', 'granola-prospect.issue-created', { url: issueUrl });
}

// The cloud materializes the github repo into the sandbox (ctx.sandbox.cwd)
// via relayfile — no clone, no gh/git. The GitHub integration opens the PR.
const run = await ctx.harness.run({
cwd: ctx.sandbox.cwd,
prompt: `A prospect asked for the following. Comprehensively implement it (every change needed to fully address the ask), then open a GitHub pull request with your changes — the GitHub integration opens it, do not use git or the \`gh\` CLI. Put the PR URL on the last line.\n\nLinear issue: ${issue.url}\n\n${ask.summary}`
prompt: `A prospect asked for the following. Comprehensively implement it (every change needed to fully address the ask), then open a GitHub pull request with your changes — the GitHub integration opens it, do not use git or the \`gh\` CLI. Put the PR URL on the last line.\n\nLinear issue: ${issueUrl ?? '(pending)'}\n\n${ask.summary}`
});

const prUrl = run.output.match(/https?:\/\/\S*\/pull\/\d+/g)?.pop();
if (prUrl) await ctx.linear.comment(issue.id, `:rocket: Implementation PR: ${prUrl}`);
if (prUrl && issueId) {
await writeJsonFile(
client,
'linear',
'comment',
`/linear/issues/${encodeSegment(issueId)}/comments/${draftFile('comment')}`,
{ body: `:rocket: Implementation PR: ${prUrl}` }
);
} else if (prUrl) {
ctx.log('warn', 'granola-prospect.comment-skipped.no-issue-id', { prUrl, draftPath: created.path });
}
}
});

Expand Down
23 changes: 20 additions & 3 deletions hn-monitor/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@
* → summarize with ctx.llm
* → post to Slack
*/
import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime';
import {
defineAgent,
draftFile,
encodeSegment,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx
} from '@agentworkforce/runtime';

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

interface Story {
id: number;
Expand All @@ -21,7 +33,6 @@ export default defineAgent({
schedules: [{ name: 'scan', cron: '0 9,17 * * *', tz: 'America/New_York' }],
handler: async (ctx, event) => {
if (event.source !== 'cron') return;
if (!ctx.slack) throw new Error('hn-monitor requires the slack integration');

const channel = input(ctx, 'SLACK_CHANNEL');
if (!channel) throw new Error('SLACK_CHANNEL is required');
Expand All @@ -37,7 +48,13 @@ export default defineAgent({
return;
}

await ctx.slack.post(channel, await summarize(ctx, fresh));
await writeJsonFile(
vfsClient(),
'slack',
'post',
`/slack/channels/${encodeSegment(channel)}/messages/${draftFile('message')}`,
{ text: await summarize(ctx, fresh) }
);
await saveSeen(ctx, [...seen, ...fresh.map((s) => s.id)].slice(-200));
}
});
Expand Down
47 changes: 41 additions & 6 deletions linear/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,29 @@
* The repo is already in the sandbox: the cloud materializes the github
* integration's repo into ctx.sandbox.cwd via relayfile, so there's no clone.
*/
import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime';
import {
defineAgent,
draftFile,
encodeSegment,
readJsonFile,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx
} from '@agentworkforce/runtime';

interface LinearIssue {
id?: string;
identifier?: string;
title: string;
description: string | null;
url?: string;
[key: string]: unknown;
}

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

export default defineAgent({
// Two Linear triggers — `on` autocompletes Linear's catalog events.
Expand All @@ -21,7 +43,8 @@ export default defineAgent({
},
handler: async (ctx, event) => {
if (event.source !== 'linear') return;
if (!ctx.linear) throw new Error('linear-implementer requires the linear integration');

const client = vfsClient();

// The comment path only fires when someone @-mentions the agent (configurable
// via MENTION, e.g. "@agentrelay") — and never on the agent's own reply.
Expand All @@ -31,7 +54,12 @@ export default defineAgent({

const issueId = readIssueId(event.payload);
if (!issueId) return;
const issue = await ctx.linear.getIssue(issueId);
const issue = await readJsonFile<LinearIssue>(
client,
'linear',
'getIssue',
`/linear/issues/${encodeSegment(issueId)}.json`
);
Comment on lines +57 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The readJsonFile call can return null or undefined if the issue is not found or the VFS read fails. We should add a guard check to prevent a potential runtime crash when passing issue to parseRepo(issue).

  const issue = await readJsonFile<LinearIssue>(\n    client,\n    'linear',\n    'getIssue',\n    \`/linear/issues/${encodeSegment(issueId)}.json\`\n  );\n  if (!issue) {\n    ctx.log('warn', 'linear-implementer.issue-not-found', { issueId });\n    return;\n  }


// The issue may name its own target repo (a github URL); if so, tell the agent
// to work there — otherwise it uses the materialized repo.
Expand All @@ -50,9 +78,16 @@ export default defineAgent({
});

const prUrl = findPrUrl(run.output);
await ctx.linear.comment(
issueId,
prUrl ? `:rocket: Opened a PR: ${prUrl}` : "I worked on this but couldn't open a PR — check the run logs."
await writeJsonFile(
client,
'linear',
'comment',
`/linear/issues/${encodeSegment(issueId)}/comments/${draftFile('comment')}`,
{
body: prUrl
? `:rocket: Opened a PR: ${prUrl}`
: "I worked on this but couldn't open a PR — check the run logs."
}
);
}
});
Expand Down
121 changes: 86 additions & 35 deletions repo-hygiene/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,37 @@
* repo-hygiene handler.
*
* GitHub PR opened/synchronized
* -> read PR metadata + diff through Relayfile-backed ctx.github
* -> read PR metadata + diff through the Relayfile-backed github VFS
* -> run a read-only hygiene diagnosis in the materialized repo
* -> post a concise PR comment
* -> create a Notion journal page for the run
* -> optionally post a Slack summary
*/
import { defineAgent, type WorkforceCtx, type WorkforceProviderEvent } from '@agentworkforce/runtime';
import {
defineAgent,
draftFile,
encodeSegment,
readJsonFile,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx,
type WorkforceProviderEvent
} from '@agentworkforce/runtime';

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

interface GithubPrMeta {
title?: string;
body?: string;
author?: string;
base?: string;
head?: string;
diff?: string;
[key: string]: unknown;
}

interface PrRef {
owner: string;
Expand Down Expand Up @@ -46,29 +70,45 @@ export default defineAgent({
handler: async (ctx, event) => {
if (event.source !== 'github') return;
if (event.type !== 'pull_request.opened' && event.type !== 'pull_request.synchronize') return;
if (!ctx.github) throw new Error('repo-hygiene requires the github integration');
if (!ctx.notion) throw new Error('repo-hygiene requires the notion integration');

const pr = readPr(event);
if (!pr) return;

const details = await ctx.github.getPr(pr);
const report = await diagnose(ctx, pr, details.diff);
const client = vfsClient();
const details = await readJsonFile<GithubPrMeta>(
client,
'github',
'getPr',
`/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/pulls/${pr.number}/meta.json`
);
Comment on lines +78 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The readJsonFile call can return null or undefined if the file is missing or the VFS read fails. We should add a guard check to prevent a potential runtime crash when accessing details.diff.

  const details = await readJsonFile<GithubPrMeta>(\n    client,\n    'github',\n    'getPr',\n    \`/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/pulls/${pr.number}/meta.json\`\n  );\n  if (!details) {\n    ctx.log('warn', 'repo-hygiene.pr-details-not-found', { pr });\n    return;\n  }

const report = await diagnose(ctx, pr, details.diff ?? '');
const body = renderPrComment(pr, report);

await ctx.github.comment(pr, body);
await writeJsonFile(
client,
'github',
'comment',
`/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/issues/${pr.number}/comments/${draftFile('comment')}`,
{ body }
);
let notionUrl: string | undefined;
try {
const notionPage = await writeNotionJournal(ctx, pr, event, report, body);
const notionPage = await writeNotionJournal(ctx, client, pr, event, report, body);
notionUrl = notionPage.url;
await rememberRun(ctx, pr, event, report, notionUrl);
} catch (error) {
ctx.log('warn', 'repo-hygiene.journal-failed', { error: serializeError(error) });
}

const channel = input(ctx, 'SLACK_CHANNEL');
if (channel && ctx.slack) {
await ctx.slack.post(channel, renderSlackSummary(pr, report, notionUrl));
if (channel) {
await writeJsonFile(
client,
'slack',
'post',
`/slack/channels/${encodeSegment(channel)}/messages/${draftFile('message')}`,
{ text: renderSlackSummary(pr, report, notionUrl) }
);
}
}
});
Expand Down Expand Up @@ -249,41 +289,52 @@ function renderPrComment(pr: PrRef, report: HygieneReport): string {

async function writeNotionJournal(
ctx: WorkforceCtx,
client: IntegrationClientOptions,
pr: PrRef,
event: WorkforceProviderEvent,
report: HygieneReport,
prComment: string
): Promise<{ id: string; url?: string }> {
): Promise<{ id?: string; url?: string }> {
const databaseId = input(ctx, 'NOTION_DATABASE_ID');
if (!databaseId) throw new Error('NOTION_DATABASE_ID is required');

const title = `${pr.owner}/${pr.repo}#${pr.number} hygiene - ${new Date(event.occurredAt).toISOString().slice(0, 10)}`;
const page = await ctx.notion!.createPage(
{ database_id: databaseId },
const page = await writeJsonFile(
client,
'notion',
'createPage',
`/notion/databases/${encodeSegment(databaseId)}/pages/${draftFile('page')}`,
{
Name: { title: [{ text: { content: title } }] },
Repository: { rich_text: [{ text: { content: `${pr.owner}/${pr.repo}` } }] },
PR: { number: pr.number },
Confidence: { select: { name: report.confidence } },
Findings: { number: report.findings.length },
Trigger: { rich_text: [{ text: { content: event.type } }] }
},
notionBlocks([
`PR: ${pr.url}`,
`Commit: ${pr.headSha ?? 'unknown'}`,
`Summary: ${report.summary}`,
'',
'Findings:',
...(report.findings.length ? report.findings.map((f) => `- [${f.severity}] ${f.title}: ${f.recommendation}`) : ['- none']),
'',
'Follow-ups:',
...(report.followUps.length ? report.followUps.map((f) => `- ${f}`) : ['- none']),
'',
'PR comment:',
prComment
].join('\n'))
properties: {
Name: { title: [{ text: { content: title } }] },
Repository: { rich_text: [{ text: { content: `${pr.owner}/${pr.repo}` } }] },
PR: { number: pr.number },
Confidence: { select: { name: report.confidence } },
Findings: { number: report.findings.length },
Trigger: { rich_text: [{ text: { content: event.type } }] }
},
children: notionBlocks([
`PR: ${pr.url}`,
`Commit: ${pr.headSha ?? 'unknown'}`,
`Summary: ${report.summary}`,
'',
'Findings:',
...(report.findings.length ? report.findings.map((f) => `- [${f.severity}] ${f.title}: ${f.recommendation}`) : ['- none']),
'',
'Follow-ups:',
...(report.followUps.length ? report.followUps.map((f) => `- ${f}`) : ['- none']),
'',
'PR comment:',
prComment
].join('\n'))
}
);
return { id: page.id, url: page.url };
// Only surface a real Notion URL when the writeback worker returned one;
// page.path is the in-mount draft, not a clickable Notion link.
if (!page.receipt?.url) {
ctx.log('warn', 'repo-hygiene.notion-page.no-receipt', { draftPath: page.path });
}
return { id: page.receipt?.id, url: page.receipt?.url };
}

function notionBlocks(markdown: string): Array<Record<string, unknown>> {
Expand Down
Loading