@astrale-os/cli 0.7.0-alpha.0 → 0.8.1-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.
- package/README.md +7 -3
- package/dist/astrale.js +128 -98
- package/package.json +11 -5
- package/src/commands/studio.ts +13 -0
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/server.ts +4 -37
- package/studio/client/dist/assets/elk-api-DVw5WDTH.js +1 -0
- package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
- package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
- package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
- package/studio/client/dist/assets/index-Dl709Ra1.js +8 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/studio/tsconfig.json +23 -0
- package/viewer/tsconfig.json +13 -0
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
- package/studio/client/dist/assets/index-huaFafBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- 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
|
-
*
|
|
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 '
|
|
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
|
-
|
|
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 (
|
|
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
|
-
}
|