-
Notifications
You must be signed in to change notification settings - Fork 0
feat(overseer): emit AGENT_NOTIFY_SUMMARY from Cursor (Half B, piece 1) #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/overseer-contract-invisible
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { | ||
| buildNotifyRuleContent, | ||
| HAPI_SESSION_RULE_SENTINEL, | ||
| installCursorNotifyRuleOverlay | ||
| } from './cursorNotifyRuleOverlay'; | ||
|
|
||
| describe('buildNotifyRuleContent', () => { | ||
| it('includes alwaysApply frontmatter, sentinel, and the contract line', () => { | ||
| const content = buildNotifyRuleContent(); | ||
| expect(content.startsWith('---\nalwaysApply: true\n---')).toBe(true); | ||
| expect(content).toContain(HAPI_SESSION_RULE_SENTINEL); | ||
| expect(content).toContain('AGENT_NOTIFY_SUMMARY {"version":1,'); | ||
| expect(content).toContain('"status":"done|blocked|needs_review|needs_decision|failed|stalled"'); | ||
| }); | ||
|
|
||
| it('bakes in project and agent id when provided', () => { | ||
| const content = buildNotifyRuleContent({ project: 'overseer-summary-emit', agentId: 'peer-7' }); | ||
| expect(content).toContain('"agent":"peer-7"'); | ||
| expect(content).toContain('"project":"overseer-summary-emit"'); | ||
| }); | ||
|
|
||
| it('falls back to placeholders and sanitizes hostile input', () => { | ||
| const content = buildNotifyRuleContent({ project: '"}{evil', agentId: ' ' }); | ||
| expect(content).toContain('"agent":"<agent-id>"'); | ||
| // quotes/braces stripped, leaving only safe chars | ||
| expect(content).toContain('"project":"evil"'); | ||
| }); | ||
|
|
||
| it('reads as benign session tracking, never surveillance', () => { | ||
| const content = buildNotifyRuleContent().toLowerCase(); | ||
| expect(content).toContain('session tracking'); | ||
| expect(content).not.toContain('overseer'); | ||
| expect(content).not.toContain('surveillance'); | ||
| expect(content).not.toContain('monitor'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('installCursorNotifyRuleOverlay', () => { | ||
| let cwd: string; | ||
|
|
||
| beforeEach(() => { | ||
| cwd = mkdtempSync(join(tmpdir(), 'hapi-notify-rule-')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| rmSync(cwd, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| const rulePathOf = (root: string) => join(root, '.cursor', 'rules', 'hapi-session.mdc'); | ||
|
|
||
| it('writes the rule file and reports its path', () => { | ||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| expect(overlay.rulePath).toBe(rulePathOf(cwd)); | ||
| expect(existsSync(overlay.rulePath)).toBe(true); | ||
| expect(readFileSync(overlay.rulePath, 'utf-8')).toContain(HAPI_SESSION_RULE_SENTINEL); | ||
| }); | ||
|
|
||
| it('cleanup removes our file and prunes dirs it created', () => { | ||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| overlay.cleanup(); | ||
| expect(existsSync(overlay.rulePath)).toBe(false); | ||
| expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(false); | ||
| expect(existsSync(join(cwd, '.cursor'))).toBe(false); | ||
| }); | ||
|
|
||
| it('backs up and restores a pre-existing user rule verbatim', () => { | ||
| const rulePath = rulePathOf(cwd); | ||
| mkdirSync(join(cwd, '.cursor', 'rules'), { recursive: true }); | ||
| const userContent = '---\nalwaysApply: false\n---\n# my own rule\n'; | ||
| writeFileSync(rulePath, userContent, 'utf-8'); | ||
|
|
||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| // ours is installed over it | ||
| expect(readFileSync(rulePath, 'utf-8')).toContain(HAPI_SESSION_RULE_SENTINEL); | ||
|
|
||
| overlay.cleanup(); | ||
| // user's file restored exactly, dirs preserved (we did not create them) | ||
| expect(readFileSync(rulePath, 'utf-8')).toBe(userContent); | ||
| expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(true); | ||
| }); | ||
|
|
||
| it('does not prune a .cursor dir that has other content', () => { | ||
| mkdirSync(join(cwd, '.cursor'), { recursive: true }); | ||
| writeFileSync(join(cwd, '.cursor', 'mcp.json'), '{}', 'utf-8'); | ||
|
|
||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| overlay.cleanup(); | ||
|
|
||
| // our rule + the rules dir we created are gone... | ||
| expect(existsSync(overlay.rulePath)).toBe(false); | ||
| expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(false); | ||
| // ...but the pre-existing .cursor dir (with sibling content) survives | ||
| expect(existsSync(join(cwd, '.cursor'))).toBe(true); | ||
| expect(existsSync(join(cwd, '.cursor', 'mcp.json'))).toBe(true); | ||
| }); | ||
|
|
||
| it('treats a sentinel-bearing file (prior/concurrent session) as ours, not a backup', () => { | ||
| const rulePath = rulePathOf(cwd); | ||
| mkdirSync(join(cwd, '.cursor', 'rules'), { recursive: true }); | ||
| writeFileSync(rulePath, buildNotifyRuleContent({ project: 'stale' }), 'utf-8'); | ||
|
|
||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| overlay.cleanup(); | ||
| // removed, not "restored" — the sentinel file was ours | ||
| expect(existsSync(rulePath)).toBe(false); | ||
| }); | ||
|
|
||
| it('never deletes a user file that replaced ours mid-session', () => { | ||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| const userContent = 'the user clobbered our rule with their own\n'; | ||
| writeFileSync(overlay.rulePath, userContent, 'utf-8'); | ||
|
|
||
| overlay.cleanup(); | ||
| expect(readFileSync(overlay.rulePath, 'utf-8')).toBe(userContent); | ||
| }); | ||
|
|
||
| it('cleanup is idempotent', () => { | ||
| const overlay = installCursorNotifyRuleOverlay({ cwd }); | ||
| overlay.cleanup(); | ||
| expect(() => overlay.cleanup()).not.toThrow(); | ||
| expect(existsSync(overlay.rulePath)).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| /** | ||
| * Per-session, workspace-local Cursor rule overlay for session status summaries. | ||
| * | ||
| * cursor-agent has no HAPI-controlled system-prompt channel (unlike claude/codex/ | ||
| * grok/opencode). It only discovers rules from workspace `.cursor/rules/*.mdc` | ||
| * files (plain `.md` is ignored) and global `~/.cursor` user rules. Editing the | ||
| * global user rules would pollute the operator's non-HAPI Cursor experience, so | ||
| * we install a transient, repo-local rule for the lifetime of a session and | ||
| * remove it on teardown. | ||
| * | ||
| * The rule asks the agent to end each response with a one-line machine-readable | ||
| * status summary that this workspace's session tracking records. The line shape | ||
| * mirrors `AGENT_NOTIFY_CONTRACT_INLINE_PREFIX` in `shared/src/overseerEvents.ts`. | ||
| * | ||
| * Non-clobbering discipline (mirrors how a config overlay behaves): if the user | ||
| * already has a file at the same path we back up its contents and restore them on | ||
| * cleanup; a file that already carries our sentinel is one of ours (a prior or | ||
| * concurrent session in the same cwd), so we never treat it as user content. All | ||
| * fs work is fail-open — a missing rule must never crash a session. | ||
| */ | ||
|
|
||
| import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { logger } from '@/ui/logger'; | ||
|
|
||
| const RULE_FILENAME = 'hapi-session.mdc'; | ||
|
|
||
| /** | ||
| * Hidden marker identifying files this overlay owns. Lets us distinguish a | ||
| * user's pre-existing rule (back up + restore) from one written by another HAPI | ||
| * session sharing the cwd (safe to overwrite / remove). | ||
| */ | ||
| export const HAPI_SESSION_RULE_SENTINEL = '<!-- hapi:session-summary-rule -->'; | ||
|
|
||
| export interface CursorNotifyRuleOverlay { | ||
| /** Absolute path of the rule file this overlay manages. */ | ||
| readonly rulePath: string; | ||
| /** Restore any pre-existing user file / remove ours, prune dirs we created. */ | ||
| cleanup: () => void; | ||
| } | ||
|
|
||
| export interface InstallCursorNotifyRuleOverlayOptions { | ||
| /** Workspace root cursor-agent is spawned in (session.path). */ | ||
| cwd: string; | ||
| /** Optional project name to bake into the example line. */ | ||
| project?: string | null; | ||
| /** Optional agent id to bake into the example line. */ | ||
| agentId?: string | null; | ||
| } | ||
|
|
||
| /** | ||
| * Build the `.mdc` rule content. `alwaysApply: true` so cursor-agent applies it | ||
| * to every turn without the agent having to opt in. Kept deliberately benign and | ||
| * functional: it reads as ordinary session-tracking config, not surveillance. | ||
| */ | ||
| export function buildNotifyRuleContent(opts: { project?: string | null; agentId?: string | null } = {}): string { | ||
| const project = sanitizeToken(opts.project) ?? '<project>'; | ||
| const agentId = sanitizeToken(opts.agentId) ?? '<agent-id>'; | ||
| const exampleLine = `AGENT_NOTIFY_SUMMARY {"version":1,"agent":"${agentId}","project":"${project}","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}`; | ||
|
|
||
| return [ | ||
| '---', | ||
| 'alwaysApply: true', | ||
| '---', | ||
| HAPI_SESSION_RULE_SENTINEL, | ||
| '', | ||
| '# Session status summary', | ||
| '', | ||
| 'End every response with a single machine-readable status line so this', | ||
| "workspace's session tracking can record progress. Put it on its own final", | ||
| 'line, after all other content, with no code fences or backticks:', | ||
| '', | ||
| exampleLine, | ||
| '', | ||
| 'Guidance:', | ||
| '- status: pick the closest value; use "blocked" if unsure.', | ||
| '- action: concrete next step (required when status is "done" and follow-up', | ||
| ' remains); 12 words or fewer.', | ||
| '- summary: one-line triage of what this turn did.', | ||
| '- Keep it as the very last line of every response.', | ||
| '' | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| /** | ||
| * Install the rule file at `<cwd>/.cursor/rules/hapi-session.mdc`, backing up any | ||
| * pre-existing user file. Returns an overlay handle whose `cleanup()` restores | ||
| * the prior state. Never throws: on failure it returns a no-op cleanup so callers | ||
| * can wire it unconditionally. | ||
| */ | ||
| export function installCursorNotifyRuleOverlay( | ||
| opts: InstallCursorNotifyRuleOverlayOptions | ||
| ): CursorNotifyRuleOverlay { | ||
| const cursorDir = join(opts.cwd, '.cursor'); | ||
| const rulesDir = join(cursorDir, 'rules'); | ||
| const rulePath = join(rulesDir, RULE_FILENAME); | ||
|
|
||
| // Dirs we create so cleanup can prune exactly what we added (deepest first). | ||
| const createdDirs: string[] = []; | ||
| // Verbatim contents of a user's pre-existing file, restored on cleanup. | ||
| let preExistingContent: string | null = null; | ||
| let cleaned = false; | ||
|
|
||
| try { | ||
| if (!existsSync(cursorDir)) { | ||
| mkdirSync(cursorDir, { recursive: true }); | ||
| createdDirs.push(cursorDir); | ||
|
Comment on lines
+105
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With a stale or deleted Useful? React with 👍 / 👎. |
||
| } | ||
| if (!existsSync(rulesDir)) { | ||
| mkdirSync(rulesDir, { recursive: true }); | ||
| createdDirs.push(rulesDir); | ||
| } | ||
|
|
||
| if (existsSync(rulePath)) { | ||
| const existing = safeRead(rulePath); | ||
| // A file that already carries our sentinel belongs to HAPI (prior or | ||
| // concurrent session) — do not treat it as user content to preserve. | ||
| if (existing !== null && !existing.includes(HAPI_SESSION_RULE_SENTINEL)) { | ||
| preExistingContent = existing; | ||
| } | ||
| } | ||
|
|
||
| writeFileSync(rulePath, buildNotifyRuleContent(opts), 'utf-8'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a checked-out workspace contains Useful? React with 👍 / 👎. |
||
| } catch (error) { | ||
| logger.debug('[cursor-notify-rule] install failed', error); | ||
| } | ||
|
|
||
| const cleanup = (): void => { | ||
| if (cleaned) return; | ||
| cleaned = true; | ||
| try { | ||
| if (preExistingContent !== null) { | ||
| // Restore the user's file exactly as it was. | ||
| writeFileSync(rulePath, preExistingContent, 'utf-8'); | ||
|
Comment on lines
+132
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a workspace already has Useful? React with 👍 / 👎. |
||
| return; | ||
| } | ||
|
|
||
| // Only remove the file if it is still ours (a user may have replaced | ||
| // it mid-session; never delete their content). | ||
| if (existsSync(rulePath)) { | ||
| const current = safeRead(rulePath); | ||
| if (current === null || current.includes(HAPI_SESSION_RULE_SENTINEL)) { | ||
| rmSync(rulePath, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| // Prune dirs we created, deepest first, only while empty. | ||
| for (const dir of [...createdDirs].reverse()) { | ||
| if (isEmptyDir(dir)) { | ||
| rmdirSync(dir); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| logger.debug('[cursor-notify-rule] cleanup failed', error); | ||
| } | ||
| }; | ||
|
|
||
| return { rulePath, cleanup }; | ||
| } | ||
|
|
||
| function safeRead(path: string): string | null { | ||
| try { | ||
| return readFileSync(path, 'utf-8'); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function isEmptyDir(path: string): boolean { | ||
| try { | ||
| return readdirSync(path).length === 0; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Keep only characters safe to bake into a JSON string example (no quotes/braces/ | ||
| * newlines). Returns null for empty/whitespace so callers fall back to the | ||
| * placeholder token. | ||
| */ | ||
| function sanitizeToken(value: string | null | undefined): string | null { | ||
| if (typeof value !== 'string') return null; | ||
| const cleaned = value.replace(/[^A-Za-z0-9._\- /]/g, '').trim(); | ||
| return cleaned.length > 0 ? cleaned : null; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this overlay makes Cursor append
AGENT_NOTIFY_SUMMARY, terminal-driven sessions that switch to remote still render assistant text fromhandleAgentMessageviamessageBuffer.addMessage(message.text, 'assistant'), andOpencodeDisplayformatsmsg.contentwithoutstripAgentContract. In that TTY path humans will see the supposedly hidden machine line every turn, so strip it before adding to the Ink buffer while still sending the raw text to the hub.Useful? React with 👍 / 👎.