@sayansr26/agent-os 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +13 -1
  2. package/CHANGELOG.md +44 -0
  3. package/README.md +36 -2
  4. package/cursor/hooks/git-guard.mjs +40 -0
  5. package/cursor/skills/agent-os-init/SKILL.md +116 -0
  6. package/package.json +4 -4
  7. package/plugins/agent-os/.claude-plugin/plugin.json +3 -3
  8. package/plugins/agent-os/hooks/pre-edit.mjs +36 -7
  9. package/plugins/agent-os/hooks/session-resume.mjs +29 -15
  10. package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
  11. package/plugins/auto-handoff/LICENSE +21 -0
  12. package/plugins/auto-handoff/README.md +131 -0
  13. package/plugins/auto-handoff/UPSTREAM.md +22 -0
  14. package/plugins/auto-handoff/hooks/brief.ts +190 -0
  15. package/plugins/auto-handoff/hooks/config.ts +64 -0
  16. package/plugins/auto-handoff/hooks/hooks.json +1 -0
  17. package/plugins/auto-handoff/hooks/panel.tsx +45 -0
  18. package/plugins/auto-handoff/hooks/register.tsx +615 -0
  19. package/plugins/auto-handoff/hooks/server.ts +32 -0
  20. package/plugins/auto-handoff/hooks/templates.ts +17 -0
  21. package/plugins/auto-handoff/hooks/viewer.ts +173 -0
  22. package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
  23. package/plugins/auto-handoff/templates/brief.md +34 -0
  24. package/plugins/auto-handoff/templates/instructions.md +10 -0
  25. package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
  26. package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
  27. package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
  28. package/plugins/auto-handoff/tsconfig.json +4 -0
  29. package/src/cli.mjs +57 -12
  30. package/src/cursor.mjs +167 -0
  31. package/src/plugin.mjs +5 -4
  32. package/src/selftest.mjs +65 -1
  33. package/src/targets.mjs +18 -11
@@ -0,0 +1,58 @@
1
+ import { test, expect } from 'claude-code/testing'
2
+ import { linkify } from '../hooks/config.ts'
3
+
4
+ const PLUGIN = 'auto-handoff'
5
+ const PATH = '/home/me/.claude/state/auto-handoff/dbd019e5-410b-4d91-8db9-6755b2924e48.md'
6
+ const URL = 'http://100.85.122.99:3846/dbd019e5-410b-4d91-8db9-6755b2924e48.html'
7
+ const SEED = `[auto-handoff] ↪ Handoff from session dbd019e5. The previous session hit its context limit and was cleared. Read the brief at ${PATH} before doing anything else (readable copy: ${URL}) and follow its Instructions section. Open your first reply with the line "↪ Handoff from session dbd019e5".`
8
+
9
+ const row = (text: string, origin: Record<string, unknown>) => ({
10
+ component: 'UserMessage' as const,
11
+ props: { text, origin, isExpanded: false } as never,
12
+ })
13
+
14
+ test('linkify turns the brief path and viewer URL into markdown links', async () => {
15
+ const out = linkify(SEED)
16
+ expect(out).toContain(`[dbd019e5-410b-4d91-8db9-6755b2924e48.md](file://${PATH})`)
17
+ expect(out).toContain(`[${URL}](${URL})`)
18
+ // Everything else stays as written.
19
+ expect(out.startsWith('[auto-handoff] ↪ Handoff from session dbd019e5.')).toBe(true)
20
+ expect(out).toContain('follow its Instructions section.')
21
+ })
22
+
23
+ test('linkify leaves a seed with no viewer link alone except for the path', async () => {
24
+ const text = `[auto-handoff] ↪ Handoff from session abc. Read the brief at ${PATH} before doing anything else and follow its Instructions section.`
25
+ const out = linkify(text)
26
+ expect(out).toContain(`(file://${PATH})`)
27
+ expect(out).not.toContain('http')
28
+ })
29
+
30
+ test('the seed row draws as a Markdown with clickable links on every surface', async $ => {
31
+ for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) {
32
+ const ui = await $.ui.mount({ plugin: PLUGIN, surface, ...row(SEED, { kind: 'plugin', name: PLUGIN }) })
33
+ const md = await ui.find({ type: 'Markdown' })
34
+ expect(md).toBeDefined()
35
+ expect(md?.text).toContain(`(file://${PATH})`)
36
+ expect(md?.text).toContain(`[${URL}](${URL})`)
37
+ await ui.unmount()
38
+ }
39
+ })
40
+
41
+ test('rows from other plugins or the person are left to the engine', async ($, on) => {
42
+ // Stands for the engine's own drawing beneath the plugin: a plain Text of the row.
43
+ on('ui.render', { component: 'UserMessage' }, async ($, e) => {
44
+ const { Text } = $.ui.resolve(e)
45
+ return <Text>{e.props.text}</Text>
46
+ })
47
+ const cases: Array<[string, Record<string, unknown>]> = [
48
+ [SEED, { kind: 'plugin', name: 'some-other-plugin' }],
49
+ [SEED, { kind: 'composer' }],
50
+ ['[auto-handoff] Not run: the context is past the handoff threshold.', { kind: 'plugin', name: PLUGIN }],
51
+ ]
52
+ for (const [text, origin] of cases) {
53
+ const ui = await $.ui.mount({ plugin: PLUGIN, surface: 'terminal', ...row(text, origin) })
54
+ expect(await ui.find({ type: 'Markdown' })).toBeUndefined()
55
+ expect((await ui.find({ type: 'Text' }))?.text).toBe(text)
56
+ await ui.unmount()
57
+ }
58
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'claude-code/testing'
2
+ import { chainOf, parseBrief, renderPage, sections, viewerLink, withHeader } from '../hooks/viewer.ts'
3
+ import { SERVER_JS } from '../hooks/server.ts'
4
+
5
+ describe('viewer', () => {
6
+ test('parseBrief extracts header and body', () => {
7
+ const brief = `---
8
+ from: abc123
9
+ to: def456
10
+ chain: main-chain
11
+ tokens: 150000
12
+ at: 2026-10-04T12:00:00Z
13
+ cwd: /home/user/project
14
+ ---
15
+ ## Work in Progress
16
+ Building the viewer.
17
+
18
+ ## Next Step
19
+ Test it.`
20
+ const { header, body } = parseBrief(brief)
21
+ expect(header.from).toBe('abc123')
22
+ expect(header.chain).toBe('main-chain')
23
+ expect(body).toContain('## Work in Progress')
24
+ })
25
+
26
+ test('parseBrief handles briefs with no header', () => {
27
+ const brief = `## Work in Progress\nDoing stuff`
28
+ const { header, body } = parseBrief(brief)
29
+ expect(header).toEqual({})
30
+ expect(body).toBe(brief)
31
+ })
32
+
33
+ test('withHeader round-trips a brief', () => {
34
+ const header = { from: 'abc', to: 'def', tokens: '150000' }
35
+ const body = '## Work\nStuff'
36
+ const result = withHeader(header, body)
37
+ const { header: h2, body: b2 } = parseBrief(result)
38
+ expect(h2.from).toBe('abc')
39
+ expect(h2.to).toBe('def')
40
+ expect(b2).toBe(body)
41
+ })
42
+
43
+ test('viewerLink is the short served link, or the local file with no server', () => {
44
+ expect(viewerLink({ host: '100.64.0.1', port: '3846' }, '/pages', 'abc12345')).toBe('http://100.64.0.1:3846/abc12345')
45
+ expect(viewerLink(undefined, '/pages', 'abc12345')).toBe('file:///pages/abc12345.html')
46
+ expect(viewerLink(undefined, 'C:\\Users\\x/.claude/pages', 'abc12345')).toBe('file:///C:/Users/x/.claude/pages/abc12345.html')
47
+ })
48
+
49
+ test('renderPage links the chain and escapes a brief that tries to close the script tag', () => {
50
+ const a = { id: 'aaaaaaaa-1', header: { chain: 'aaaaaaaa-1', at: '2026-10-04T00:00:00Z', to: 'bbbbbbbb-2' }, body: '## Work in Progress\nFirst.' }
51
+ const b = { id: 'bbbbbbbb-2', header: { chain: 'aaaaaaaa-1', from: 'aaaaaaaa-1', at: '2026-10-04T01:00:00Z' }, body: '## Next Step\n</script><b>x</b>' }
52
+ const page = renderPage(b, [a, b])
53
+ expect(page).toContain('href="aaaaaaaa-1.html"')
54
+ expect(page).toContain('Handoff Chain')
55
+ expect(page).not.toContain('</script><b>')
56
+ })
57
+
58
+ test('previous and next follow the chain, even where a header lost its from', () => {
59
+ const e = (id: string, at: string, header: Record<string, string>) => ({ id, header: { at, ...header }, body: '' })
60
+ const a = e('aaaaaaaa-1', '1', { to: 'bbbbbbbb-2' })
61
+ const b = e('bbbbbbbb-2', '2', { to: 'cccccccc-3' }) // a reload dropped its from
62
+ const c = e('cccccccc-3', '3', { from: 'bbbbbbbb-2', to: 'dddddddd-4' })
63
+ const mid = renderPage(b, [a, b, c])
64
+ expect(mid).toContain('<a href="aaaaaaaa-1.html">← previous</a>')
65
+ expect(mid).toContain('<a href="cccccccc-3.html">next →</a>')
66
+ const last = renderPage(c, [a, b, c])
67
+ expect(last).toContain('next: dddddddd, still running')
68
+ expect(last).not.toContain('href="dddddddd-4.html"')
69
+ expect(renderPage(a, [a, b, c])).not.toContain('← previous')
70
+ })
71
+
72
+ test('sections extracts visible sections', () => {
73
+ const body = `## Work in Progress
74
+ Doing X
75
+
76
+ ## Instructions
77
+ Don't read this
78
+
79
+ ## Next Step
80
+ Do Y
81
+
82
+ ## How to Use This Brief
83
+ Also don't read`
84
+ const visible = sections(body)
85
+ expect(visible.length).toBe(2)
86
+ expect(visible[0]).toContain('Work in Progress')
87
+ expect(visible[1]).toContain('Next Step')
88
+ })
89
+
90
+ test('chainOf joins a run whose chain id broke partway, through from/to links', () => {
91
+ const e = (id: string, at: string, header: Record<string, string>) => ({ id, header: { at, ...header }, body: '' })
92
+ const entries = [
93
+ e('a', '1', { chain: 'a', to: 'b' }),
94
+ e('b', '2', { chain: 'a', from: 'a', to: 'c' }),
95
+ e('c', '3', { chain: 'c', to: 'd' }), // the reload lost its from
96
+ e('d', '4', { chain: 'c', from: 'c' }),
97
+ e('x', '5', { chain: 'x' }),
98
+ ]
99
+ expect(chainOf(entries, 'd').map(x => x.id)).toEqual(['a', 'b', 'c', 'd'])
100
+ expect(chainOf(entries, 'a').map(x => x.id)).toEqual(['a', 'b', 'c', 'd'])
101
+ expect(chainOf(entries, 'x').map(x => x.id)).toEqual(['x'])
102
+ })
103
+
104
+ // A second session finds the port taken. Its child used to die with an unhandled EADDRINUSE
105
+ // stack trace in the log; it now says so in one line and exits 0. (Run live: see the 0.8.2 commit.)
106
+ test('the server exits quietly when the port is taken', () => {
107
+ expect(SERVER_JS).toContain("err.code === 'EADDRINUSE'")
108
+ expect(SERVER_JS).toContain('process.exit(0)')
109
+ expect(SERVER_JS.indexOf(".on('error'")).toBeLessThan(SERVER_JS.indexOf('.listen('))
110
+ })
111
+ })
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json",
3
+ "compilerOptions": { "allowImportingTsExtensions": true, "noEmit": true }
4
+ }
package/src/cli.mjs CHANGED
@@ -10,6 +10,7 @@ import { setupState, describeState } from "../plugins/agent-os/skills/init/scrip
10
10
  import { applyScope, describe } from "./claude-setup.mjs";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import { compile, TARGETS } from "./targets.mjs";
13
+ import { enabled as cursorEnabled } from "./cursor.mjs";
13
14
 
14
15
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
15
16
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
@@ -35,11 +36,23 @@ Options
35
36
  --force overwrite files agent-os did not generate (it refuses by default)
36
37
  --no-plugin skip installing or updating the Claude Code plugins during init
37
38
  --no-auto-route
38
- init: install agent-os but not the auto-route model router
39
+ init: leave out the auto-route model router
40
+ --no-auto-handoff
41
+ init: leave out the auto-handoff session handoff
39
42
  --global init: also write ~/.claude settings without asking
40
43
  --no-global init: leave ~/.claude alone
41
44
  `;
42
45
 
46
+ /** Will this init/sync compile for Claude Code? Mirrors how scaffold picks targets. */
47
+ function claudeTargeted(root, found) {
48
+ try {
49
+ const cfg = JSON.parse(readFileSync(join(root, DIR, "config.json"), "utf8"));
50
+ return (cfg.targets || []).includes("claude-code");
51
+ } catch {}
52
+ const present = found.filter((t) => t.present).map((t) => t.id);
53
+ return present.includes("claude-code") || !present.length;
54
+ }
55
+
43
56
  function scaffold(root, found) {
44
57
  const present = found.filter((t) => t.present).map((t) => t.id);
45
58
  mkdirSync(join(root, DIR, "rules"), { recursive: true });
@@ -93,6 +106,17 @@ export async function main(argv) {
93
106
 
94
107
  if (cmd === "help" || argv.includes("-h") || argv.includes("--help")) { console.log(USAGE); return; }
95
108
 
109
+ // A repository Rulegate owns: init would adopt Rulegate's generated CLAUDE.md and
110
+ // AGENTS.md as this tool's source, and every write after that fights `rulegate check`.
111
+ // That happened to Rulegate's own repository (its T112), so both writers stop here.
112
+ if ((cmd === "init" || cmd === "sync") && existsSync(join(root, ".rulegate"))) {
113
+ console.error(`\n This repository uses Rulegate (.rulegate/ exists), which replaces agent-os.`);
114
+ console.error(` agent-os ${cmd} would treat Rulegate's generated files as its source. Nothing was written.`);
115
+ console.error(`\n Use ${bold("npx rulegate sync")} here instead.\n`);
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+
96
120
  const found = detect(root);
97
121
 
98
122
  if (cmd === "detect") {
@@ -139,18 +163,24 @@ export async function main(argv) {
139
163
  let adopted = new Set();
140
164
 
141
165
  let before = null;
166
+ // The SETUP state is Claude Code's: CLAUDE.md, .claude/settings.json, the
167
+ // plugin. In a project that does not target Claude Code every item reads
168
+ // MISSING and the verdict never leaves REPAIR, so it is not shown there.
169
+ const forClaude = claudeTargeted(root, found);
142
170
  if (cmd === "init") {
143
171
  console.log(`\n${bold("agent-os init")} ${root}\n`);
144
172
  // Decide fresh vs repair before touching anything, and say which, so a
145
173
  // second run on a set-up project reads as a repair rather than a re-init.
146
174
  before = setupState(root);
147
- for (const l of describeState(before)) console.log(` ${l}`);
148
- console.log({
149
- fresh: `\n ${dim("Fresh setup: creating everything.")}`,
150
- repair: `\n ${dim("Existing setup: fixing the items marked MISSING, leaving the rest as it is.")}`,
151
- healthy: `\n ${dim("Setup is complete: re-syncing and checking the plugin for updates.")}`,
152
- }[before.status]);
153
- console.log("");
175
+ if (forClaude) {
176
+ for (const l of describeState(before)) console.log(` ${l}`);
177
+ console.log({
178
+ fresh: `\n ${dim("Fresh setup: creating everything.")}`,
179
+ repair: `\n ${dim("Existing setup: fixing the items marked MISSING, leaving the rest as it is.")}`,
180
+ healthy: `\n ${dim("Setup is complete: re-syncing and checking the plugin for updates.")}`,
181
+ }[before.status]);
182
+ console.log("");
183
+ }
154
184
  console.log(summarise(found));
155
185
  const hadSource = existsSync(join(root, DIR));
156
186
  const { present, taken } = scaffold(root, found);
@@ -207,7 +237,7 @@ export async function main(argv) {
207
237
  const cur = readIfExists(root, f.path);
208
238
  if (cur === null) return true; // nothing there yet
209
239
  if (cur.includes(BANNER)) return true; // we wrote it
210
- if (TARGETS[f.target]?.merge) return true; // merged in place, nothing lost
240
+ if (f.merged) return true; // merged in place, nothing lost
211
241
  if (adopted.has(f.path)) return true; // init just took this as the source
212
242
  if (matches(root, f.path, f.content) === true) return true; // already identical
213
243
  return false;
@@ -267,7 +297,7 @@ Pick one:
267
297
  }
268
298
 
269
299
  if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
270
- // Both come from the same marketplace, so it is added or refreshed once:
300
+ // All come from the same marketplace, so it is added or refreshed once:
271
301
  // by the first plugin whose run gets that far.
272
302
  let refresh = true;
273
303
  for (const plugin of PLUGINS.filter((p) => p === PLUGIN || !argv.includes(`--no-${p}`))) {
@@ -285,10 +315,25 @@ Pick one:
285
315
  if (r.reason === "installed" || r.reason === "updated")
286
316
  console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
287
317
  }
288
- console.log(`\n ${dim("--no-plugin skips this; --no-auto-route skips only the model router")}`);
318
+ console.log(`\n ${dim("--no-plugin skips this; --no-auto-route / --no-auto-handoff skip one mod")}`);
319
+ }
320
+
321
+ if (cmd !== "check" && targets.includes("cursor") && cursorEnabled(src.config)) {
322
+ const n = (re) => files.filter((f) => re.test(f.path)).length;
323
+ console.log(`\n${bold("Cursor")} ${dim("agent-os layer, compiled from this package")}\n`);
324
+ console.log(` ${n(/^\.cursor\/agents\/agent-os-/)} subagents agent-os-feature-cartographer, -architect, -builder, -tester, -reviewer, …`);
325
+ console.log(` ${n(/^\.cursor\/skills\/agent-os-[^/]+\/SKILL\.md$/)} skills /agent-os-init, /agent-os-map, /agent-os-memory`);
326
+ console.log(` hooks session start, generated-file guard${src.config.cursor?.gitProtection === false ? "" : ", git write protection"} ${dim("(.cursor/hooks.json)")}`);
327
+ console.log(` agent memory .claude/agent-memory/agent-os-<agent>/ ${dim("— shared with Claude Code")}`);
328
+ if (cmd === "init") console.log(`\n ${dim("Reload the Cursor window, then run /agent-os-init in Agent chat.")}`);
329
+ }
330
+
331
+ if (cmd === "init" && !dry && !forClaude) {
332
+ const labels = targets.map((t) => TARGETS[t]?.label || t);
333
+ console.log(`\n${bold("Result")} ${labels.length ? `compiled for ${labels.join(", ")}` : "AGENTS.md only"}`);
289
334
  }
290
335
 
291
- if (cmd === "init" && !dry) {
336
+ if (cmd === "init" && !dry && forClaude) {
292
337
  const after = setupState(root);
293
338
  console.log(`\n${bold("Result")} ${before.status} → ${after.status}`);
294
339
  if (after.missing.length) {
package/src/cursor.mjs ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The agent-os layer for Cursor: the same agents, skills and hooks the Claude
3
+ * Code plugin ships, compiled into the places Cursor reads them.
4
+ *
5
+ * .cursor/agents/agent-os-<name>.md the seven agents, as subagents
6
+ * .cursor/skills/agent-os-{init,map,memory}/ the three skills
7
+ * .cursor/hooks/agent-os/ the session and pre-edit hooks, plus
8
+ * git write protection
9
+ * .cursor/hooks.json merged: user hooks are kept
10
+ * .cursor/rules/agent-os-agents.mdc when to use which agent, always on
11
+ *
12
+ * Everything is generated from this package, never from `.agent-os/`, so `sync`
13
+ * keeps it current the way `claude plugin update` does for Claude Code.
14
+ *
15
+ * Agent memory stays in `.claude/agent-memory/agent-os-<name>/`: the directory
16
+ * Claude Code's plugin agents use, so a project on both tools has one map.
17
+ */
18
+ import { readdirSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { BANNER, parseFrontmatter } from "./source.mjs";
22
+ import { GIT_DENY } from "../plugins/agent-os/skills/init/scripts/settings.mjs";
23
+
24
+ const PKG = join(fileURLToPath(new URL(".", import.meta.url)), "..");
25
+ const PLUGIN = join(PKG, "plugins/agent-os");
26
+ const OWN = join(PKG, "cursor");
27
+ const HOOKS = ".cursor/hooks/agent-os";
28
+
29
+ const read = (p) => readFileSync(p, "utf8");
30
+ const md = (fields, body) => `---\n${fields}---\n\n<!-- ${BANNER} -->\n\n${body.trim()}\n`;
31
+ const js = (text) => text.startsWith("#!")
32
+ ? text.replace(/^(#!.*\n)/, `$1// ${BANNER}\n`)
33
+ : `// ${BANNER}\n${text}`;
34
+
35
+ /** `{ "cursor": { "agentOs": false } }` turns the whole layer off. */
36
+ export const enabled = (config) => config?.cursor?.agentOs !== false;
37
+ const gitGuard = (config) => config?.cursor?.gitProtection !== false;
38
+
39
+ /** Claude Code names and commands, rewritten to what this layer installs. */
40
+ export function translate(text) {
41
+ return text
42
+ .replace(/agent-os:([a-z][a-z-]*)/g, "agent-os-$1")
43
+ .replace(/node "\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/\w+\/scripts\/(\w+)\.mjs"/g, "npx -y @sayansr26/agent-os $1");
44
+ }
45
+
46
+ /** A description is what the parent reads to pick a subagent, so it names Cursor's files. */
47
+ const describe = (d) => JSON.stringify(translate(String(d || ""))
48
+ .replace(/CLAUDE\.md/g, "AGENTS.md").replace(/\.claude\/rules\//g, ".cursor/rules/"));
49
+
50
+ const READ_AS = `> **Running in Cursor.** This text was written for Claude Code. Read it as:
51
+ > \`CLAUDE.md\` → \`AGENTS.md\` (edit \`.agent-os/AGENTS.md\`, then \`npx @sayansr26/agent-os sync\`);
52
+ > \`.claude/rules/\` → \`.cursor/rules/\` (edit \`.agent-os/rules/\`, then sync);
53
+ > an agent \`<name>\` → the \`agent-os-<name>\` subagent; \`/agent-os:<skill>\` → \`/agent-os-<skill>\`;
54
+ > "the Agent tool" or "dispatch" → delegate to that subagent.`;
55
+
56
+ function agents() {
57
+ const dir = join(PLUGIN, "agents");
58
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).sort().map((f) => {
59
+ const { fields, body } = parseFrontmatter(read(join(dir, f)));
60
+ const name = `agent-os-${fields.name}`;
61
+ // Claude Code creates the memory directory and preloads MEMORY.md for an
62
+ // agent with `memory: project`. Cursor does neither, so the agent is told.
63
+ const memory = `> **Your memory directory** is \`.claude/agent-memory/${name}/\` at the project root
64
+ > (shared with Claude Code's agent-os agents). Create it if it does not exist. Cursor does
65
+ > not load \`MEMORY.md\` for you — read it yourself before anything else.
66
+ >
67
+ ${READ_AS}`;
68
+ return {
69
+ path: `.cursor/agents/${name}.md`,
70
+ content: md(
71
+ `name: ${name}\ndescription: ${describe(fields.description)}\nmodel: inherit\n`,
72
+ `${memory}\n\n${translate(body)}`),
73
+ };
74
+ });
75
+ }
76
+
77
+ function skills() {
78
+ const out = [];
79
+ const refs = join(PLUGIN, "skills/init/references");
80
+ // init is rewritten for Cursor; its references are shared, read through the table.
81
+ {
82
+ const { fields, body } = parseFrontmatter(read(join(OWN, "skills/agent-os-init/SKILL.md")));
83
+ out.push({ path: ".cursor/skills/agent-os-init/SKILL.md",
84
+ content: md(`name: ${fields.name}\ndescription: ${JSON.stringify(fields.description)}\n`, body) });
85
+ for (const f of readdirSync(refs).filter((f) => f.endsWith(".md")).sort())
86
+ out.push({ path: `.cursor/skills/agent-os-init/references/${f}`,
87
+ content: `<!-- ${BANNER} -->\n\n${READ_AS}\n\n${translate(read(join(refs, f))).trim()}\n` });
88
+ }
89
+ // map and memory carry over as they are, renamed. Their scripts run through
90
+ // the CLI, which bundles the same files, so no script is copied.
91
+ for (const s of ["map", "memory"]) {
92
+ const { fields, body } = parseFrontmatter(read(join(PLUGIN, "skills", s, "SKILL.md")));
93
+ out.push({ path: `.cursor/skills/agent-os-${s}/SKILL.md`,
94
+ content: md(`name: agent-os-${s}\ndescription: ${describe(fields.description)}\n`, `${READ_AS}\n\n${translate(body)}`) });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function hookScripts(config) {
100
+ const out = [
101
+ { path: `${HOOKS}/hooks/session-resume.mjs`, content: js(read(join(PLUGIN, "hooks/session-resume.mjs"))) },
102
+ { path: `${HOOKS}/hooks/pre-edit.mjs`, content: js(read(join(PLUGIN, "hooks/pre-edit.mjs"))) },
103
+ { path: `${HOOKS}/lib/features.mjs`, content: js(read(join(PLUGIN, "lib/features.mjs"))) },
104
+ ];
105
+ if (gitGuard(config)) out.push(
106
+ { path: `${HOOKS}/hooks/git-guard.mjs`, content: js(read(join(OWN, "hooks/git-guard.mjs"))) },
107
+ { path: `${HOOKS}/lib/git-deny.mjs`,
108
+ content: js(`/** The same rules Claude Code gets as permissions.deny (settings.mjs). */\nexport const GIT_DENY = ${JSON.stringify(GIT_DENY, null, 2)};\n`) },
109
+ );
110
+ return out;
111
+ }
112
+
113
+ /** The agent contract, always on — the Cursor half of CLAUDE.md's Step 4b section. */
114
+ const contract = () => ({
115
+ path: ".cursor/rules/agent-os-agents.mdc",
116
+ content: md("description: When to use each agent-os subagent\nalwaysApply: true\n", `## Agents in this project (agent-os)
117
+
118
+ Delegate to these subagents without being asked:
119
+
120
+ - Before changing an existing feature: ask \`agent-os-feature-cartographer\` how
121
+ it is built. In plan mode ask it read-only, and have it file the map as the
122
+ first step after plan mode ends.
123
+ - New subsystem, cross-module change, shared data model: \`agent-os-architect\` first.
124
+ - Writing the change: \`agent-os-builder\`, given the map and the files to change.
125
+ - Done means: \`agent-os-tester\` verified it and \`agent-os-reviewer\` found no
126
+ rule violations.
127
+ - Once verified: \`agent-os-documenter\` records it.
128
+ - Work spanning several of these, or that cannot be stated in one sentence:
129
+ \`agent-os-orchestrator\`.
130
+
131
+ \`/agent-os-map\` builds the architecture map, \`/agent-os-memory\` inspects agent
132
+ memory, \`/agent-os-init\` sets up or repairs all of this.
133
+
134
+ Generated files carry a banner. Edit \`.agent-os/\`, then run
135
+ \`npx @sayansr26/agent-os sync\`; never edit the output.`),
136
+ });
137
+
138
+ /** Every file of the layer except hooks.json. */
139
+ export function cursorFiles(src) {
140
+ if (!enabled(src.config)) return [];
141
+ return [...agents(), ...skills(), ...hookScripts(src.config), contract()];
142
+ }
143
+
144
+ const OURS = (h) => String(h?.command || "").includes(`${HOOKS}/`);
145
+
146
+ /**
147
+ * Merge the agent-os hooks into `.cursor/hooks.json`. Hooks the user wrote are
148
+ * kept; earlier agent-os entries are replaced, so a removed hook goes away.
149
+ */
150
+ export function mergeHooks(src, existing) {
151
+ if (!existing && !enabled(src.config)) return [];
152
+ const cfg = existing ? JSON.parse(existing) : { version: 1, hooks: {} };
153
+ cfg.version ||= 1;
154
+ cfg.hooks ||= {};
155
+ for (const ev of Object.keys(cfg.hooks)) {
156
+ cfg.hooks[ev] = (cfg.hooks[ev] || []).filter((h) => !OURS(h));
157
+ if (!cfg.hooks[ev].length) delete cfg.hooks[ev];
158
+ }
159
+ if (enabled(src.config)) {
160
+ const add = (ev, h) => (cfg.hooks[ev] ||= []).push(h);
161
+ add("sessionStart", { command: `node ${HOOKS}/hooks/session-resume.mjs --cursor` });
162
+ add("preToolUse", { command: `node ${HOOKS}/hooks/pre-edit.mjs --cursor`, matcher: "Write" });
163
+ if (gitGuard(src.config))
164
+ add("beforeShellExecution", { command: `node ${HOOKS}/hooks/git-guard.mjs`, matcher: "\\bgit\\b" });
165
+ }
166
+ return [{ path: ".cursor/hooks.json", content: JSON.stringify(cfg, null, 2) + "\n" }];
167
+ }
package/src/plugin.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * Install or update the Claude Code half of agent-os during `init`.
3
3
  *
4
4
  * The CLI compiles rules for every tool. Claude Code additionally gets the
5
- * agents, skills, per-agent memory and hooks, which ship as a plugin, and
6
- * auto-route, a mod that picks the model and effort per turn. Both live in
7
- * this repo's marketplace. `init` brings each to the latest version whatever
5
+ * agents, skills, per-agent memory and hooks, which ship as a plugin; plus two
6
+ * mods: auto-route, which picks the model and effort per turn, and
7
+ * auto-handoff, which hands a full session to a fresh one instead of
8
+ * compacting. All three live in this repo's marketplace. `init` brings each to the latest version whatever
8
9
  * state it finds:
9
10
  *
10
11
  * not installed marketplace add + install, at project scope
@@ -28,7 +29,7 @@ export const MARKETPLACE = "sayansr26/agent-os";
28
29
  export const MARKETPLACE_NAME = "sayan-plugins";
29
30
  export const PLUGIN = "agent-os";
30
31
  // Installed by `init` in this order; each can be skipped with --no-<name>.
31
- export const PLUGINS = [PLUGIN, "auto-route"];
32
+ export const PLUGINS = [PLUGIN, "auto-route", "auto-handoff"];
32
33
  const idOf = (plugin) => `${plugin}@${MARKETPLACE_NAME}`;
33
34
 
34
35
  const run = (args, cwd) =>
package/src/selftest.mjs CHANGED
@@ -57,6 +57,39 @@ try {
57
57
  ok(read(".claude/skills/demo/SKILL.md") !== null, "skills -> .claude/skills");
58
58
  ok(read(".cline/skills/demo/SKILL.md") !== null, "skills -> .cline/skills");
59
59
 
60
+ console.log("\n Cursor gets the agent-os layer");
61
+ {
62
+ const agents = ["architect", "builder", "documenter", "feature-cartographer", "orchestrator", "reviewer", "tester"];
63
+ ok(agents.every((a) => read(`.cursor/agents/agent-os-${a}.md`)?.startsWith(`---\nname: agent-os-${a}\n`)), "7 subagents in .cursor/agents/, Cursor-legal names");
64
+ const carto = read(".cursor/agents/agent-os-feature-cartographer.md");
65
+ ok(carto.includes(".claude/agent-memory/agent-os-feature-cartographer/"), "subagent memory shared with Claude Code's directory");
66
+ ok(!/agent-os:[a-z]/.test(carto) && !/^memory:|^tools:/m.test(carto), "no Claude-only names or frontmatter left");
67
+ ok(["init", "map", "memory"].every((s) => read(`.cursor/skills/agent-os-${s}/SKILL.md`)?.includes(`name: agent-os-${s}\n`)), "skills /agent-os-init, -map, -memory");
68
+ ok(!read(".cursor/skills/agent-os-memory/SKILL.md").includes("CLAUDE_PLUGIN_ROOT"), "skill scripts run through the CLI");
69
+ ok(read(".cursor/rules/agent-os-agents.mdc")?.includes("alwaysApply: true"), "agent contract is an always-on rule");
70
+ const hooks = JSON.parse(read(".cursor/hooks.json"));
71
+ ok(hooks.version === 1 && hooks.hooks.sessionStart && hooks.hooks.preToolUse && hooks.hooks.beforeShellExecution, "hooks.json: sessionStart, preToolUse, beforeShellExecution");
72
+
73
+ const hook = (script, args, input) => spawnSync(process.execPath, [join(root, ".cursor/hooks/agent-os/hooks", script), ...args],
74
+ { cwd: root, encoding: "utf8", input, env: { ...process.env, CURSOR_PROJECT_DIR: root } }).stdout;
75
+ const start = JSON.parse(hook("session-resume.mjs", ["--cursor"], ""));
76
+ ok(start.additional_context?.includes("`agent-os-feature-cartographer`"), "session start returns additional_context with Cursor names");
77
+ const pre = (file) => JSON.parse(hook("pre-edit.mjs", ["--cursor"], JSON.stringify({ tool_input: { file_path: join(root, file) } })));
78
+ ok(pre(".cursor/rules/api.mdc").permission === "deny", "pre-edit denies an edit to a generated rule");
79
+ ok(pre(".cursor/agents/agent-os-builder.md").permission === "deny", "pre-edit denies an edit to a generated subagent");
80
+ ok(pre("src/new.ts").permission === "allow", "pre-edit answers allow, never empty, for everything else");
81
+ const git = (command) => JSON.parse(hook("git-guard.mjs", [], JSON.stringify({ command }))).permission;
82
+ ok(git("git status") === "allow" && git("git log --oneline") === "allow", "read-only git allowed");
83
+ ok(["git push", "git commit -m x", "cd a && git reset --hard", "git -C x add .", "A=1 git rebase main"].every((c) => git(c) === "deny"), "git writes denied, chained and flag forms too");
84
+
85
+ writeFileSync(join(root, ".cursor/hooks.json"), JSON.stringify({ version: 1, hooks: { afterFileEdit: [{ command: "./fmt.sh" }] } }));
86
+ cli(["sync"], root);
87
+ const merged = JSON.parse(read(".cursor/hooks.json")).hooks;
88
+ ok(merged.afterFileEdit?.[0]?.command === "./fmt.sh" && merged.sessionStart?.length === 1, "hooks.json merged: user hook kept, ours added once");
89
+ cli(["sync"], root);
90
+ ok(JSON.parse(read(".cursor/hooks.json")).hooks.preToolUse.length === 1, "re-sync does not duplicate hooks");
91
+ }
92
+
60
93
  console.log("\n drift");
61
94
  ok(cli(["check"], root).status === 0, "check passes when in sync");
62
95
  // Realistic drift: someone edits the body of a generated file and leaves the
@@ -102,10 +135,14 @@ try {
102
135
  ok(/marketplace add sayansr26\/agent-os|plugin marketplace add|not on PATH/.test(pi.stdout),
103
136
  "init names the marketplace step or says why it could not run it");
104
137
  ok(pi.stdout.includes("auto-route@sayan-plugins"), "init sets up auto-route by default");
138
+ ok(pi.stdout.includes("auto-handoff@sayan-plugins"), "init sets up auto-handoff by default");
105
139
  ok((pi.stdout.match(/marketplace add/g) || []).length <= 1, "the marketplace is added once, not per plugin");
106
140
  const na = cli(["init", "--dry-run", "--no-auto-route"], r4);
107
141
  ok(!/install auto-route@sayan-plugins/.test(na.stdout) && /install agent-os@sayan-plugins/.test(na.stdout),
108
142
  "--no-auto-route keeps agent-os and skips auto-route");
143
+ const nh = cli(["init", "--dry-run", "--no-auto-handoff"], r4);
144
+ ok(!/install auto-handoff@sayan-plugins/.test(nh.stdout) && /install auto-route@sayan-plugins/.test(nh.stdout),
145
+ "--no-auto-handoff skips only auto-handoff");
109
146
  rmSync(r4, { recursive: true, force: true });
110
147
 
111
148
  // A hand-written file at a generated path must survive a sync.
@@ -225,7 +262,34 @@ try {
225
262
  const oc = JSON.parse(read("opencode.json"));
226
263
  ok(oc.model === "anthropic/x", "existing keys preserved");
227
264
  ok(oc.instructions.includes("KEEP.md"), "existing instructions preserved");
228
- } finally { rmSync(root, { recursive: true, force: true }); rmSync(HOME, { recursive: true, force: true }); }
265
+ } finally { rmSync(root, { recursive: true, force: true }); }
266
+
267
+ console.log("\n a Cursor-only init does not report Claude Code items");
268
+ const cu = mkdtempSync(join(tmpdir(), "agent-os-cursor-"));
269
+ try {
270
+ mkdirSync(join(cu, ".agent-os"), { recursive: true });
271
+ writeFileSync(join(cu, ".agent-os/config.json"), JSON.stringify({ targets: ["cursor"] }));
272
+ const r = cli(["init", "--no-global"], cu);
273
+ ok(r.status === 0, "init exits 0");
274
+ ok(!r.stdout.includes("SETUP") && !r.stdout.includes("still to do") && !r.stdout.includes("CLAUDE.md"), "no Claude Code SETUP or to-do list");
275
+ ok(/Result.*compiled for Cursor/.test(r.stdout) && r.stdout.includes("/agent-os-init"), "result names Cursor and the next step");
276
+ ok(!existsSync(join(cu, ".claude/settings.json")), "nothing written for Claude Code");
277
+ writeFileSync(join(cu, ".agent-os/config.json"), JSON.stringify({ targets: ["cursor"], cursor: { gitProtection: false } }));
278
+ cli(["sync"], cu);
279
+ ok(!JSON.parse(readFileSync(join(cu, ".cursor/hooks.json"), "utf8")).hooks.beforeShellExecution, "gitProtection: false removes the git hook");
280
+ } finally { rmSync(cu, { recursive: true, force: true }); }
281
+
282
+ console.log("\n a Rulegate repository is left alone");
283
+ const rg = mkdtempSync(join(tmpdir(), "agent-os-rulegate-"));
284
+ try {
285
+ mkdirSync(join(rg, ".rulegate/rules"), { recursive: true });
286
+ writeFileSync(join(rg, "CLAUDE.md"), "<!-- Generated by Rulegate -->\n# Project\n");
287
+ for (const cmd of ["init", "sync"]) {
288
+ const r = cli([cmd, "--no-plugin", "--no-global"], rg);
289
+ ok(r.status === 1 && /uses Rulegate/.test(r.stderr), `${cmd} refuses with .rulegate/ present`);
290
+ }
291
+ ok(!existsSync(join(rg, ".agent-os")) && !existsSync(join(rg, "AGENTS.md")), "nothing written");
292
+ } finally { rmSync(rg, { recursive: true, force: true }); rmSync(HOME, { recursive: true, force: true }); }
229
293
 
230
294
  console.log(`\n${fail ? `FAILED (${fail})` : "PASSED"}\n`);
231
295
  process.exit(fail ? 1 : 0);
package/src/targets.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * existing JSON config receive the current contents and return the merged file.
8
8
  */
9
9
  import { BANNER } from "./source.mjs";
10
+ import { cursorFiles, mergeHooks } from "./cursor.mjs";
10
11
 
11
12
  const hdr = (comment) => `${comment} ${BANNER}\n`;
12
13
  const mdHeader = `<!-- ${BANNER} -->\n\n`;
@@ -54,13 +55,19 @@ export const TARGETS = {
54
55
  // ---- translate ----------------------------------------------------------
55
56
  cursor: {
56
57
  label: "Cursor",
57
- // .mdc, and a rule without frontmatter is ignored entirely.
58
- files: (src) => src.rules.map((r) => ({
59
- path: `.cursor/rules/${r.name}.mdc`,
60
- content: `---\ndescription: ${r.description || r.name}\n` +
61
- (r.always ? "alwaysApply: true\n" : `globs: ${r.paths.join(",")}\nalwaysApply: false\n`) +
62
- `---\n\n${mdHeader}${r.body}\n`,
63
- })),
58
+ // .mdc, and a rule without frontmatter is ignored entirely. Then the
59
+ // agent-os layer — subagents, skills, hooks — which src/cursor.mjs builds.
60
+ files: (src) => [
61
+ ...src.rules.map((r) => ({
62
+ path: `.cursor/rules/${r.name}.mdc`,
63
+ content: `---\ndescription: ${r.description || r.name}\n` +
64
+ (r.always ? "alwaysApply: true\n" : `globs: ${r.paths.join(",")}\nalwaysApply: false\n`) +
65
+ `---\n\n${mdHeader}${r.body}\n`,
66
+ })),
67
+ ...cursorFiles(src),
68
+ ],
69
+ merge: mergeHooks,
70
+ mergeFrom: ".cursor/hooks.json",
64
71
  },
65
72
 
66
73
  windsurf: {
@@ -179,10 +186,10 @@ export function compile(src, targetIds, readExisting) {
179
186
  for (const id of targetIds) {
180
187
  const t = TARGETS[id];
181
188
  if (!t) continue;
182
- const files = t.merge
183
- ? t.merge(src, readExisting(t.mergeFrom))
184
- : t.files(src);
185
- out.push(...files.map((f) => ({ ...f, target: id })));
189
+ // A target writes files, merges into one existing config, or both. Merged
190
+ // files are marked: they are rewritten in place with nothing of the user's lost.
191
+ if (t.files) out.push(...t.files(src).map((f) => ({ ...f, target: id })));
192
+ if (t.merge) out.push(...t.merge(src, readExisting(t.mergeFrom)).map((f) => ({ ...f, target: id, merged: true })));
186
193
  }
187
194
  return out;
188
195
  }