@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-alpha.0

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 (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. package/studio/server/state/harness-token.ts +0 -0
@@ -0,0 +1,37 @@
1
+ import type { AgentHarness } from './adapter'
2
+
3
+ /**
4
+ * Register the concrete local harness adapters and reuse their process-level
5
+ * caches across domains.
6
+ */
7
+ import { ClaudeCodeHarness } from './claude/adapter'
8
+ import { CodexHarness } from './codex/adapter'
9
+ import { MockHarness } from './mock/adapter'
10
+
11
+ const harnesses: Record<string, () => AgentHarness> = {
12
+ claude: () => new ClaudeCodeHarness(),
13
+ codex: () => new CodexHarness(),
14
+ mock: () => new MockHarness(),
15
+ }
16
+
17
+ const instances = new Map<string, AgentHarness>()
18
+
19
+ export function hasHarness(id: string): boolean {
20
+ return !!harnesses[id]
21
+ }
22
+
23
+ export function getHarnessById(id: string): AgentHarness {
24
+ let harness = instances.get(id)
25
+ if (!harness) {
26
+ harness = (harnesses[id] ?? harnesses.claude)()
27
+ instances.set(id, harness)
28
+ }
29
+ return harness
30
+ }
31
+
32
+ /** All registered harnesses (id + label) — for the (currently locked) UI selector. */
33
+ export function listHarnesses(selected?: string): { id: string; label: string }[] {
34
+ return Object.entries(harnesses)
35
+ .filter(([id]) => id !== 'mock' || id === selected)
36
+ .map(([id, make]) => ({ id, label: make().label }))
37
+ }
@@ -0,0 +1,75 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { updateSettings } from '../../state/settings'
7
+ import {
8
+ getHarness,
9
+ getHarnessSelection,
10
+ resolveHarnessConfiguration,
11
+ setHarnessSelection,
12
+ } from './selection'
13
+
14
+ const roots: string[] = []
15
+ const previous = process.env.DOMAIN_STUDIO_HARNESS
16
+
17
+ afterEach(() => {
18
+ if (previous === undefined) delete process.env.DOMAIN_STUDIO_HARNESS
19
+ else process.env.DOMAIN_STUDIO_HARNESS = previous
20
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ test('persists a per-domain Codex selection', () => {
24
+ delete process.env.DOMAIN_STUDIO_HARNESS
25
+ const rootA = mkdtempSync(join(tmpdir(), 'studio-harness-a-'))
26
+ const rootB = mkdtempSync(join(tmpdir(), 'studio-harness-b-'))
27
+ roots.push(rootA, rootB)
28
+ expect(getHarnessSelection(rootA)).toMatchObject({ id: 'claude', source: 'default' })
29
+ expect(getHarnessSelection(rootB)).toMatchObject({ id: 'claude', source: 'default' })
30
+
31
+ setHarnessSelection(rootA, 'codex')
32
+ expect(getHarnessSelection(rootA)).toEqual({
33
+ id: 'codex',
34
+ locked: false,
35
+ source: 'domain',
36
+ })
37
+ expect(getHarness(rootA).id).toBe('codex')
38
+ expect(getHarnessSelection(rootB)).toEqual({
39
+ id: 'claude',
40
+ locked: false,
41
+ source: 'default',
42
+ })
43
+ expect(getHarness(rootB).id).toBe('claude')
44
+ })
45
+
46
+ test('environment selection locks the GUI override', () => {
47
+ const root = mkdtempSync(join(tmpdir(), 'studio-harness-'))
48
+ roots.push(root)
49
+ process.env.DOMAIN_STUDIO_HARNESS = 'codex'
50
+ expect(getHarnessSelection(root)).toEqual({
51
+ id: 'codex',
52
+ locked: true,
53
+ source: 'environment',
54
+ })
55
+ expect(() => setHarnessSelection(root, 'claude')).toThrow('locked to codex')
56
+ })
57
+
58
+ test('resolves persisted Claude max and Ultracode effort modes', async () => {
59
+ delete process.env.DOMAIN_STUDIO_HARNESS
60
+ const root = mkdtempSync(join(tmpdir(), 'studio-harness-effort-'))
61
+ roots.push(root)
62
+ updateSettings(root, { agentEffort: 'max' })
63
+
64
+ const resolved = await resolveHarnessConfiguration(root)
65
+ expect(resolved.ok).toBe(true)
66
+ if (!resolved.ok) return
67
+ expect(resolved.configuration.harness.id).toBe('claude')
68
+ expect(resolved.configuration.effort).toBe('max')
69
+
70
+ updateSettings(root, { agentEffort: 'ultracode' })
71
+ const ultracode = await resolveHarnessConfiguration(root)
72
+ expect(ultracode.ok).toBe(true)
73
+ if (!ultracode.ok) return
74
+ expect(ultracode.configuration.effort).toBe('ultracode')
75
+ })
@@ -0,0 +1,77 @@
1
+ import type { AgentEffort, StudioSettings } from '../../../shared/types'
2
+ import type { AgentHarness } from './adapter'
3
+
4
+ import { effectiveAgentEffort } from '../../../shared/agent-effort'
5
+ import { readSettings } from '../../state/settings'
6
+ import { readJson, writeJson } from '../../state/store'
7
+ import { resolveHarnessEnv } from './gateway/config'
8
+ import { getHarnessById, hasHarness } from './registry'
9
+
10
+ const SELECTION_FILE = '.cache/agent/harness.json'
11
+
12
+ export interface HarnessSelection {
13
+ id: string
14
+ locked: boolean
15
+ source: 'environment' | 'domain' | 'default'
16
+ }
17
+
18
+ export interface HarnessConfiguration {
19
+ harness: AgentHarness
20
+ settings: StudioSettings
21
+ model?: string
22
+ effort?: AgentEffort
23
+ env: Record<string, string>
24
+ }
25
+
26
+ export type HarnessConfigurationResult =
27
+ | { ok: true; configuration: HarnessConfiguration }
28
+ | { ok: false; error: string }
29
+
30
+ export function getHarnessSelection(root: string): HarnessSelection {
31
+ const environment = process.env.DOMAIN_STUDIO_HARNESS?.trim().toLowerCase()
32
+ if (environment && hasHarness(environment))
33
+ return { id: environment, locked: true, source: 'environment' }
34
+ const stored = readJson<{ id?: string }>(root, SELECTION_FILE, {})
35
+ const id = stored.id?.toLowerCase()
36
+ if (id && hasHarness(id)) return { id, locked: false, source: 'domain' }
37
+ return { id: 'claude', locked: false, source: 'default' }
38
+ }
39
+
40
+ export function setHarnessSelection(root: string, id: string): HarnessSelection {
41
+ const normalized = id.trim().toLowerCase()
42
+ if (!hasHarness(normalized) || normalized === 'mock') throw new Error(`unknown harness: ${id}`)
43
+ const current = getHarnessSelection(root)
44
+ if (current.locked)
45
+ throw new Error(`the harness is locked to ${current.id} by DOMAIN_STUDIO_HARNESS / --harness`)
46
+ writeJson(root, SELECTION_FILE, { id: normalized })
47
+ return { id: normalized, locked: false, source: 'domain' }
48
+ }
49
+
50
+ export function getHarness(root: string): AgentHarness {
51
+ return getHarnessById(getHarnessSelection(root).id)
52
+ }
53
+
54
+ /** Resolve the selected adapter and every per-domain option used to invoke it. */
55
+ export async function resolveHarnessConfiguration(
56
+ root: string,
57
+ harness = getHarness(root),
58
+ ): Promise<HarnessConfigurationResult> {
59
+ const settings = readSettings(root)
60
+ const model = settings.agentModels[harness.id]?.trim() || undefined
61
+ const effort = effectiveAgentEffort(harness.capabilities.effortLevels, settings.agentEffort)
62
+ const environment =
63
+ harness.capabilities.gateway === 'anthropic'
64
+ ? await resolveHarnessEnv(root)
65
+ : { ok: true as const, env: {} }
66
+ if (!environment.ok) return { ok: false, error: environment.error }
67
+ return {
68
+ ok: true,
69
+ configuration: {
70
+ harness,
71
+ settings,
72
+ model,
73
+ effort,
74
+ env: environment.env,
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,95 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { scanClaudeSkills } from './claude/skills'
7
+ import { scanCodexSkills } from './codex/skills'
8
+ import { readSkillContent } from './skills'
9
+
10
+ const roots: string[] = []
11
+
12
+ afterEach(() => {
13
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
14
+ })
15
+
16
+ function skill(dir: string, command: string, frontmatter: string): string {
17
+ const target = join(dir, command)
18
+ mkdirSync(target, { recursive: true })
19
+ const path = join(target, 'SKILL.md')
20
+ writeFileSync(path, `---\n${frontmatter}\n---\n\nInstructions for ${command}.\n`)
21
+ return path
22
+ }
23
+
24
+ test('Codex skill discovery prefers the nearest project skill and preserves disabled plugins', () => {
25
+ const parent = mkdtempSync(join(tmpdir(), 'studio-skills-'))
26
+ roots.push(parent)
27
+ const root = join(parent, 'domain')
28
+ mkdirSync(root)
29
+ const parentSkill = skill(
30
+ join(parent, '.agents', 'skills'),
31
+ 'project-check',
32
+ 'name: Parent\ndescription: parent',
33
+ )
34
+ const nearestSkill = skill(
35
+ join(root, '.agents', 'skills'),
36
+ 'project-check',
37
+ `name: Nearest
38
+ description: >
39
+ First line of a folded description.
40
+ Second line stays visible.`,
41
+ )
42
+ const pluginRoot = join(parent, 'plugin')
43
+ const pluginSkill = skill(
44
+ join(pluginRoot, 'skills'),
45
+ 'review',
46
+ 'name: Review\ndescription: Disabled review tool',
47
+ )
48
+
49
+ const found = scanCodexSkills(root, [{ name: 'demo', path: pluginRoot, enabled: false }])
50
+ const project = found.find((item) => item.command === 'project-check')!
51
+ const plugin = found.find((item) => item.command === 'demo:review')!
52
+
53
+ expect(project).toMatchObject({
54
+ name: 'Nearest',
55
+ description: 'First line of a folded description. Second line stays visible.',
56
+ source: 'project',
57
+ loaded: true,
58
+ path: nearestSkill,
59
+ })
60
+ expect(plugin).toMatchObject({
61
+ name: 'Review',
62
+ source: 'plugin',
63
+ plugin: 'demo',
64
+ loaded: false,
65
+ path: pluginSkill,
66
+ })
67
+ expect(readSkillContent(found, 'demo:review')).toEqual({
68
+ command: 'demo:review',
69
+ content: `---\nname: Review\ndescription: Disabled review tool\n---\n\nInstructions for review.\n`,
70
+ path: pluginSkill,
71
+ })
72
+ })
73
+
74
+ test('Claude skill discovery owns Claude project precedence without leaking Codex policy', () => {
75
+ const parent = mkdtempSync(join(tmpdir(), 'studio-claude-skills-'))
76
+ roots.push(parent)
77
+ const root = join(parent, 'domain')
78
+ mkdirSync(root)
79
+ skill(join(root, '.agents', 'skills'), 'review', 'name: Shared\ndescription: shared fallback')
80
+ const claudeSkill = skill(
81
+ join(root, '.claude', 'skills'),
82
+ 'review',
83
+ 'name: Claude Review\ndescription: Claude-native project skill',
84
+ )
85
+
86
+ const reviews = scanClaudeSkills(root).filter((item) => item.command === 'review')
87
+ expect(reviews).toEqual([
88
+ expect.objectContaining({
89
+ name: 'Claude Review',
90
+ source: 'project',
91
+ loaded: true,
92
+ path: claudeSkill,
93
+ }),
94
+ ])
95
+ })
@@ -0,0 +1,100 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import type { LoadoutSkill } from '../../../shared/types'
7
+
8
+ function readSkillMeta(skillMd: string): { name?: string; description?: string } {
9
+ let text: string
10
+ try {
11
+ text = readFileSync(skillMd, 'utf8')
12
+ } catch {
13
+ return {}
14
+ }
15
+ if (!text.startsWith('---')) return {}
16
+ const end = text.indexOf('\n---', 3)
17
+ const fm = end >= 0 ? text.slice(3, end) : text.slice(3)
18
+ try {
19
+ const meta = parseYaml(fm) as { name?: unknown; description?: unknown } | null
20
+ return {
21
+ name: typeof meta?.name === 'string' ? meta.name : undefined,
22
+ description: typeof meta?.description === 'string' ? meta.description.trim() : undefined,
23
+ }
24
+ } catch {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ export function scanSkillDir(
30
+ dir: string,
31
+ source: LoadoutSkill['source'],
32
+ plugin: string | undefined,
33
+ commandPrefix: string,
34
+ loaded: boolean,
35
+ out: LoadoutSkill[],
36
+ seen: Set<string>,
37
+ ): void {
38
+ if (!existsSync(dir)) return
39
+ let entries: string[]
40
+ try {
41
+ entries = readdirSync(dir)
42
+ } catch {
43
+ return
44
+ }
45
+ for (const entry of entries) {
46
+ const skillMd = join(dir, entry, 'SKILL.md')
47
+ if (!existsSync(skillMd)) continue
48
+ const command = commandPrefix + entry
49
+ if (seen.has(command)) continue
50
+ seen.add(command)
51
+ const meta = readSkillMeta(skillMd)
52
+ out.push({
53
+ command,
54
+ name: meta.name || entry,
55
+ description: meta.description,
56
+ source,
57
+ plugin,
58
+ loaded,
59
+ path: skillMd,
60
+ })
61
+ }
62
+ }
63
+
64
+ export function scanAncestors(
65
+ root: string,
66
+ dirs: string[],
67
+ out: LoadoutSkill[],
68
+ seen: Set<string>,
69
+ ): void {
70
+ const home = homedir()
71
+ let current = root
72
+ for (let i = 0; i < 12 && current !== home; i++) {
73
+ for (const dir of dirs)
74
+ scanSkillDir(join(current, dir), 'project', undefined, '', true, out, seen)
75
+ const parent = dirname(current)
76
+ if (parent === current) break
77
+ current = parent
78
+ }
79
+ }
80
+
81
+ export function readSkillContent(
82
+ skills: LoadoutSkill[],
83
+ command: string,
84
+ ): { command: string; content: string; path: string } | null {
85
+ const skill = skills.find((candidate) => candidate.command === command)
86
+ if (!skill?.path) return null
87
+ try {
88
+ return { command, content: readFileSync(skill.path, 'utf8'), path: skill.path }
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ export function reconcileLoadedSkills(
95
+ installed: LoadoutSkill[],
96
+ loadedCommands: string[],
97
+ ): LoadoutSkill[] {
98
+ const loaded = new Set(loadedCommands)
99
+ return installed.map((skill) => ({ ...skill, loaded: loaded.has(skill.command) }))
100
+ }
@@ -0,0 +1,95 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+
5
+ const ROOT = resolve(import.meta.dir)
6
+
7
+ function sourceFiles(dir = ROOT): string[] {
8
+ return readdirSync(dir)
9
+ .flatMap((entry) => {
10
+ const path = join(dir, entry)
11
+ if (statSync(path).isDirectory()) return sourceFiles(path)
12
+ return path.endsWith('.ts') && !path.endsWith('.test.ts') ? [path] : []
13
+ })
14
+ .sort()
15
+ }
16
+
17
+ function imports(file: string): string[] {
18
+ const source = readFileSync(file, 'utf8')
19
+ const found: string[] = []
20
+ const pattern = /(?:from\s+|import\s*\()\s*['"]([^'"]+)['"]/g
21
+ for (const match of source.matchAll(pattern)) {
22
+ const specifier = match[1]
23
+ if (!specifier.startsWith('.')) continue
24
+ const target = resolve(dirname(file), specifier)
25
+ const candidate = target.endsWith('.ts') ? target : `${target}.ts`
26
+ if (candidate.startsWith(ROOT)) found.push(candidate)
27
+ }
28
+ return found
29
+ }
30
+
31
+ function agentPath(file: string): string {
32
+ return relative(ROOT, file).replaceAll('\\', '/')
33
+ }
34
+
35
+ test('agent concepts form an acyclic dependency graph', () => {
36
+ const graph = new Map(sourceFiles().map((file) => [file, imports(file)]))
37
+ const visiting = new Set<string>()
38
+ const visited = new Set<string>()
39
+ const stack: string[] = []
40
+ const cycles: string[][] = []
41
+
42
+ const visit = (file: string) => {
43
+ if (visiting.has(file)) {
44
+ const start = stack.indexOf(file)
45
+ cycles.push([...stack.slice(start), file].map(agentPath))
46
+ return
47
+ }
48
+ if (visited.has(file)) return
49
+ visiting.add(file)
50
+ stack.push(file)
51
+ for (const dependency of graph.get(file) ?? []) if (graph.has(dependency)) visit(dependency)
52
+ stack.pop()
53
+ visiting.delete(file)
54
+ visited.add(file)
55
+ }
56
+
57
+ for (const file of graph.keys()) visit(file)
58
+ expect(cycles).toEqual([])
59
+ })
60
+
61
+ test('leaf concepts do not depend on orchestration or HTTP ownership', () => {
62
+ const violations: string[] = []
63
+ for (const file of sourceFiles()) {
64
+ const source = agentPath(file)
65
+ for (const dependency of imports(file).map(agentPath)) {
66
+ if (
67
+ source.startsWith('harness/') &&
68
+ (dependency.startsWith('run/') ||
69
+ dependency.startsWith('bridge/') ||
70
+ dependency.startsWith('prompts/') ||
71
+ dependency === 'routes.ts')
72
+ )
73
+ violations.push(`${source} -> ${dependency}`)
74
+ if (
75
+ source.startsWith('prompts/') &&
76
+ (dependency.startsWith('run/') ||
77
+ dependency.startsWith('bridge/') ||
78
+ dependency.startsWith('harness/') ||
79
+ dependency === 'routes.ts')
80
+ )
81
+ violations.push(`${source} -> ${dependency}`)
82
+ if (
83
+ source.startsWith('bridge/') &&
84
+ (dependency.startsWith('run/') ||
85
+ dependency.startsWith('prompts/') ||
86
+ dependency.startsWith('harness/claude/') ||
87
+ dependency.startsWith('harness/codex/') ||
88
+ dependency.startsWith('harness/mock/') ||
89
+ dependency === 'routes.ts')
90
+ )
91
+ violations.push(`${source} -> ${dependency}`)
92
+ }
93
+ }
94
+ expect(violations).toEqual([])
95
+ })
@@ -0,0 +1,12 @@
1
+ import type { StudioEvent } from '../../shared/types'
2
+
3
+ export type Notify = (event: StudioEvent) => void
4
+
5
+ /** Keep optional UI delivery failures from corrupting committed agent state. */
6
+ export function emitStudioEvent(notify: Notify, event: StudioEvent): void {
7
+ try {
8
+ notify(event)
9
+ } catch {
10
+ /* the persisted state remains authoritative; clients can refetch */
11
+ }
12
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * agent/schema-map.ts — focused anchor descriptions for thread and Ask prompts.
2
+ * prompts/anchors.ts — focused anchor descriptions for turn and Ask prompts.
3
3
  * The whole-domain schema map is intentionally not embedded in agent turns; the
4
4
  * agent reads schema/ directly when it needs broader context.
5
5
  */
6
- import type { Comment, IrMethod, JsonSchema, SchemaIR, SchemaOverlay } from '../../shared/types'
6
+ import type { Comment, IrMethod, JsonSchema, SchemaIR, SchemaOverlay } from '../../../shared/types'
7
7
 
8
8
  /** Terse JSON-Schema type label (mirrors the client's format.tsx describe/typeLabel). */
9
9
  function propType(s: JsonSchema | undefined): string {
@@ -0,0 +1,34 @@
1
+ import type { SchemaIR, SchemaOverlay } from '../../../shared/types'
2
+
3
+ import { describeAnchor } from './anchors'
4
+
5
+ export function buildAskSystemPrompt(): string {
6
+ return [
7
+ 'You are answering a QUICK side question from inside Domain Studio about ONE element',
8
+ 'of an Astrale domain. This is an ephemeral aside, shown in a small popover and then',
9
+ 'discarded — it is NOT part of the main build conversation.',
10
+ '- Answer directly and concisely (usually 1–4 sentences). Lead with the answer.',
11
+ '- You have the same local tool surface and permission mode as the main agent. You may',
12
+ ' inspect files, run commands, use web/search tools if available, and edit files when',
13
+ ' the user explicitly asks for a change. Keep any edits tightly scoped and say what changed.',
14
+ '- No preamble, no machine-state/JSON block, no thread protocol — just the answer.',
15
+ ].join('\n')
16
+ }
17
+
18
+ export interface AskParts {
19
+ anchorRef: string
20
+ excerpt: string
21
+ question: string
22
+ ir: SchemaIR | null
23
+ overlay?: SchemaOverlay
24
+ }
25
+
26
+ /** Build one focused side-question prompt. */
27
+ export function buildAskPrompt(parts: AskParts): string {
28
+ const target = describeAnchor(parts.anchorRef, parts.ir, parts.overlay)
29
+ const named = parts.excerpt && parts.excerpt !== parts.anchorRef ? ` (${parts.excerpt})` : ''
30
+ const lines = [`Quick question about \`${parts.anchorRef}\`${named} in this domain.`]
31
+ if (target) lines.push('', 'Target:', target)
32
+ lines.push('', `Question: ${parts.question.trim()}`)
33
+ return lines.join('\n')
34
+ }
@@ -1,18 +1,5 @@
1
- /**
2
- * agent/prompt.ts the scaffolding that tells a harness-agnostic agent who it
3
- * is and exactly how to answer. Two pieces:
4
- * - the SYSTEM prompt (appended to the harness default) = the durable protocol.
5
- * - the TURN prompt = the handoff data (context + changes + the threads
6
- * awaiting a reply + the machine-state block). Reuses buildCopyMarkdown so
7
- * the live loop and the copy/paste flow stay byte-for-byte consistent.
8
- */
9
- import type { Comment, ContextItem, DocMeta, SchemaIR, SchemaOverlay } from '../../shared/types'
10
-
11
- import { buildCopyMarkdown } from '../state/copy'
12
- import { describeAnchor, resolveThreadAnchors } from './schema-map'
13
-
14
- /** The reply protocol — appended to the harness's own system prompt. */
15
- export function buildSystemPrompt(opts: { bridge: boolean }): string {
1
+ /** Build the durable Studio protocol appended to a harness system prompt. */
2
+ export function buildSystemPrompt(options: { bridge: boolean }): string {
16
3
  const lines: string[] = [
17
4
  'You are the build agent for an Astrale domain, driven from **Domain Studio** — a local',
18
5
  'GUI where the user pins comment threads onto schema classes, methods, views, data, etc.',
@@ -79,7 +66,7 @@ export function buildSystemPrompt(opts: { bridge: boolean }): string {
79
66
  ' "thread":[ ...every existing entry PLUS your new author entries... ] } ] }. Merge is by id;',
80
67
  ' a thread whose last entry is not yours is resent next turn, so always answer every thread.',
81
68
  ]
82
- if (opts.bridge) {
69
+ if (options.bridge) {
83
70
  lines.push(
84
71
  '',
85
72
  'PREFERRED CHANNEL — the **domain-studio** MCP tools are connected. Use them as your',
@@ -109,94 +96,3 @@ export function buildSystemPrompt(opts: { bridge: boolean }): string {
109
96
  )
110
97
  return lines.join('\n')
111
98
  }
112
-
113
- export interface TurnParts {
114
- origin: string
115
- root: string
116
- schemaHash: string
117
- /** only the threads awaiting an author reply (open, last entry not author) */
118
- awaitingThreads: Comment[]
119
- userContext: ContextItem[]
120
- autoContext: ContextItem[]
121
- documents: DocMeta[]
122
- firstTurn: boolean
123
- /** optional free-text instruction the user typed in the Context composer */
124
- message?: string
125
- /** current schema IR (null when deps aren't installed → static fallback) */
126
- ir: SchemaIR | null
127
- overlay?: SchemaOverlay
128
- }
129
-
130
- /** The per-turn message piped to the harness. */
131
- export function buildTurnPrompt(parts: TurnParts): string {
132
- const body = buildCopyMarkdown({
133
- origin: parts.origin,
134
- root: parts.root,
135
- schemaHash: parts.schemaHash,
136
- openComments: parts.awaitingThreads,
137
- userContext: parts.userContext,
138
- autoContext: parts.autoContext,
139
- documents: parts.documents,
140
- })
141
- const hasThreads = parts.awaitingThreads.length > 0
142
- const header = parts.firstTurn
143
- ? hasThreads
144
- ? '> New session. The thread pointers and context below are your orientation — implement the open threads and reply by id.'
145
- : '> New session. Follow the direct instruction below; use the context below and read schema/ when needed.'
146
- : hasThreads
147
- ? '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits); the threads below were added or updated since your last reply — implement and answer them.'
148
- : '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits). Follow the direct instruction below.'
149
- // Inject only precise thread-anchor pointers. The schema overview is not embedded;
150
- // the system prompt tells the agent to read/edit schema/ directly when needed.
151
- const anchors = resolveThreadAnchors(parts.awaitingThreads, parts.overlay)
152
- const msg = parts.message?.trim()
153
- const instruction = msg ? ['', '## Direct instruction', '', msg] : []
154
- return [header, ...instruction, '', anchors, '', body].filter((s) => s !== undefined).join('\n')
155
- }
156
-
157
- /** A bare "pick up where you left off" nudge for resuming an interrupted turn.
158
- * Deliberately empty of handoff data: the resumed session already holds the
159
- * threads, context and prior edits, so re-sending them would be pure noise — the
160
- * point of Resume is a seamless continue, not a fresh briefing. */
161
- export function buildResumePrompt(): string {
162
- return [
163
- '> Resuming the SAME session. Your previous turn was cut off when Domain Studio',
164
- '> restarted — nothing else has changed. Pick up exactly where you left off.',
165
- '',
166
- 'Continue and finish what you were doing, then make sure every open thread gets an',
167
- 'answer through the usual channel (the domain-studio MCP tools, or the final',
168
- 'machine-state ```json``` block). Keep going from where you stopped — do not restart.',
169
- ].join('\n')
170
- }
171
-
172
- /** The system prompt for a quick Ask side-question — capable, concise, ephemeral. */
173
- export function buildAskSystemPrompt(): string {
174
- return [
175
- 'You are answering a QUICK side question from inside Domain Studio about ONE element',
176
- 'of an Astrale domain. This is an ephemeral aside, shown in a small popover and then',
177
- 'discarded — it is NOT part of the main build conversation.',
178
- '- Answer directly and concisely (usually 1–4 sentences). Lead with the answer.',
179
- '- You have the same local tool surface and permission mode as the main agent. You may',
180
- ' inspect files, run commands, use web/search tools if available, and edit files when',
181
- ' the user explicitly asks for a change. Keep any edits tightly scoped and say what changed.',
182
- '- No preamble, no machine-state/JSON block, no thread protocol — just the answer.',
183
- ].join('\n')
184
- }
185
-
186
- export interface AskParts {
187
- anchorRef: string
188
- excerpt: string
189
- question: string
190
- ir: SchemaIR | null
191
- overlay?: SchemaOverlay
192
- }
193
-
194
- /** Compose the Ask turn: a friendly prefix + the injected target context + the question. */
195
- export function buildAskPrompt(parts: AskParts): string {
196
- const target = describeAnchor(parts.anchorRef, parts.ir, parts.overlay)
197
- const named = parts.excerpt && parts.excerpt !== parts.anchorRef ? ` (${parts.excerpt})` : ''
198
- const lines = [`Quick question about \`${parts.anchorRef}\`${named} in this domain.`]
199
- if (target) lines.push('', 'Target:', target)
200
- lines.push('', `Question: ${parts.question.trim()}`)
201
- return lines.join('\n')
202
- }