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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/BACKLOG.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,10 @@

## [Unreleased]

### Added
- **项目里的一切定制只住 `.alpha` 一个目录**(REQ-060,需下个签名版本生效)——在会话里创建的技能 / Agent / 命令 / 连接器 / 插件全部落到项目的 `.alpha/` 下,项目里**不再出现 `.opencode` 目录**;没用过扩展的项目连 `.alpha` 都不会出现。模型经新的注册工具写入(字段校验、原子写、自动生效),不再手改配置文件;写完当前回复结束后自动重载,下一条消息即可用,无需重启。配套:全局安装 Agent 也不再在 `~/.opencode` 留任何痕迹(「`.alpha` 是你的,`.opencode` 是引擎的」自此全兑现)。
- **打开自带可执行扩展的项目会先征得你同意**(REQ-060 信任门,需下个签名版本生效)——项目若自带连接器(MCP)或插件(均为可在本机运行的程序代码),首次打开该项目会话时弹窗列明具体条目,你可以选择「允许加载」(需勾选知情确认)或「仅文本扩展」;技能 / Agent / 命令等文本类扩展不受影响。决定按项目记录一次不再重复询问,同意后无需重启即刻生效;拒绝后该项目的可执行扩展保持不加载。项目里放置未打包的 TypeScript 插件会被如实拒载并在日志说明原因(桌面运行时无法执行,需先打包成自包含 JS)。

### Fixed
- **会话页输入框按钮"点了没反应"根治**(REQ-056)——三个叠加原因一并修复:①接管容器层级低于对话滚动区,按钮**看得见点不着**(点击被透明滚动层吃掉);②窄窗口/审查面板打开时底部按钮行溢出输入框外,推理强度/发送被挤到不可点(现模型名截断省略、按钮行自动换行);③会话页丢失 alpha 焦点样式作用域,点击后残留上游橙色粗焦点圈(现样式随组件走,两页一致)。
- **推理强度不再有"选择模型后可用"的死状态**(REQ-056)——登录且代理可用时自动选中默认模型 **Claude Sonnet 4.6**(高级档;不覆盖你的手动选择、不写死到本地),推理强度点开即「默认/低/中/高」;模型不支持档位时点开如实说明原因,任何状态点击都有反馈。
Expand Down
79 changes: 79 additions & 0 deletions docs/audits/2026-07-08-s30-req060-realmachine/verify.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# S30 真机批 — REQ-060 验收 1-6 + REQ-059 T3b + 信任门 UI(2026-07-08)

> ship:mac 装机(alpha @ feat/s30-req060-finish,commit 3dea94b6),CDP 9222 + AppleScript
> System Events(原生对话框点按)。fixture 四项目 `~/req060-t5-{a,b,c,d}`,验证后已删。

## 结论:全部 PASS(12 断言 + 2 条 loud 日志)

## 一、项目级通道 + alpha_register + 生 TS 拒收(项目 A,已 consent)

fixture:`.alpha/alpha.jsonc`(agent `t5-agent` / command `t5-cmd` / `skills.paths: ["./.alpha/skills"]`
相对路径)+ `.alpha/plugins/{raw-trap.ts, t5-ok.js}` + `.alpha/skills/t5-skill/`。

| 断言 | 结果 |
|---|---|
| /config 见 agent `t5-agent` + command `t5-cmd`(条目通道) | ✅ |
| `skills.paths` 相对条目按项目根解析为绝对(`/Users/tide/req060-t5-a/.alpha/skills`)—— 项目可移动 | ✅ |
| tool ids 含 `alpha_register`(T2 工具注册) | ✅ |
| **生 TS 拒收 loud**(验收4):`raw-trap.ts` NOT loaded + 日志指路「Bundle to self-contained ESM .js」 | ✅ |
| 同目录 `t5-ok.js` 正常 fan-out(`t5_plugin_tool` 在 tool ids)—— .ts 拒收不误伤 | ✅ |

```
[@alpha-code/ext] project plugin raw-trap.ts is raw TypeScript — NOT loaded (desktop runtime can't run raw TS, ADR-006). Bundle it to a self-contained ESM .js first.
[@alpha-code/ext] project plugin loaded (fan-out): /Users/tide/req060-t5-a/.alpha/plugins/t5-ok.js
```

```json
{"cfgStatus":200,"agentVisible":true,"commandVisible":true,"skillsPathResolvedAbs":true,
"registerToolPresent":true,"tsTrapNotLoaded":true,"jsPluginLoaded":true}
```

## 二、信任门 UI 弹窗(验收2,项目 B=拒绝 / 项目 D=同意;原生 sheet 实点)

CDP 调 `window.api.ext.trustCheck(dir)` → 主窗弹原生 sheet(buttons=[允许加载, 仅文本扩展(不加载)],
checkbox=「我了解这会在本机运行该项目提供的代码」)→ AppleScript 实点。

| 断言 | 结果 |
|---|---|
| **拒绝路径**(B):点「仅文本扩展」→ prefs.json 落 `{granted:false, version:1}` | ✅ |
| 拒绝后重查:`{prompted:false, granted:false}` ×2(不重复弹) | ✅ |
| **同意路径**(D):勾知情 checkbox + 点「允许加载」→ `{prompted:true, granted:true}`;prefs 落 granted | ✅ |
| 同意后重查:`{prompted:false, granted:true}`(不重复弹) | ✅ |
| granted 项目 dispose 后 mcp 条目引擎可见(`t5grantmcp`) | ✅ |
| denied 项目 mcp 保持 gated(`t5gatedmcp` 不可见)+ 相邻隔离(B 看不到 D 的 mcp) | ✅ |

```json
{"grantedProjectMcpVisible":true,"deniedProjectMcpGated":true,"isolation":true}
```

## 三、REQ-059 T3b:全局 agent 零桥 + 条目化 + 净除

`window.api.ext.installBuiltinAgent("agents/code-reviewer.md", "code-reviewer")`(writeAgent 新管线):

| 断言 | 结果 |
|---|---|
| md 落 `~/.alpha/agents/code-reviewer.md`,files 仅此一项(**无桥文件**) | ✅ |
| **`~/.opencode` 不存在**(零 `.opencode` 不变量,装全局 agent 不再复活它) | ✅ |
| `~/.alpha/alpha.jsonc` 落 `agent.code-reviewer` 条目(agentMdToEntry:permission 嵌套/中文 prompt 正确转换) | ✅ |
| dispose 后引擎 /config 见到该 agent(prompt 完整,G1 通道) | ✅ |
| 卸载(`ext.uninstall`)净除:md 删 + alpha.jsonc 条目删 | ✅ |

## 四、零目录新增(验收3,项目 C)

空项目 C 经引擎建实例 + /config 查询后,目录仍为空(无 `.alpha`、无 `.opencode`)。✅

## 覆盖对照(REQ-060 验收标准)

| 验收 | 覆盖 |
|---|---|
| 1 创建产物全在 `.alpha`、免重启可用 | 通道全链已证(条目→/config→dispose);**模型经会话实调 alpha_register 未演**(需真 LLM 会话,机制 = 工具注册✅+纯逻辑 33 测) |
| 2 信任门同意/拒绝双路径 | ✅ 全真机(原生 sheet 实点) |
| 3 零目录新增 | ✅ |
| 4 生 TS 拒收 loud | ✅ |
| 5 存量共存 | 用户自建 `.opencode` 零触碰 = reconcile 既有纪律(REQ-059 批已证);agents 桥迁移逻辑单测覆盖,本机无存量桥验证物 |
| 6 REQ-036 创建→发现→免重启复测 | 通道级已证(同 1);会话级实测归下一次日常使用 |
| 7 零改上游 + alpha-check 绿 | ✅(north-star guard + 549 测) |

## 残留物

fixture 四项目已删;`~/.alpha/alpha.jsonc` 的 code-reviewer 条目已卸载净除;app 已退出。
10 changes: 10 additions & 0 deletions docs/requirements/REQ-036-creation-via-skills.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,3 +48,13 @@ created: 2026-07-05

- 决策依据与机制证据:本轮分析(2026-07-05 会话,三份勘探 + Claude Code 对标);
- [[REQ-033]](开放安装面:导入通道并存)、[[REQ-037]](上游治理层,同为「上游能力面」孪生档)、[[B6]](ext 接缝,alpha_reload 落点)、ADR-014 v3(dispose 免重启)、ADR-019(`.alpha` 落点与桥)、ADR-015 Tier-2(能力扩展走 harness 接缝,不写提示词)。

## 修订(2026-07-08,S30/REQ-060 —— 创建流落点改造:零 `.opencode` + alpha_register)

REQ-060(项目级扩展物 `.alpha`-only)+ REQ-059 T3b(agents 全局桥退役)使本档**目标3 与验收5 的落点表述作废**,agent-creator 已改稿(同批 PR):

1. **项目 agent(默认)**:不再写 `<proj>/.opencode/agent/*.md` 文件 —— 改调 `alpha_register` ext 工具(type=agent,entry 经字段白名单校验后原子写 `<proj>/.alpha/alpha.jsonc` 条目,自动排程 reload)。项目内零 `.opencode`、零文件手写。
2. **全局 agent**:`~/.opencode` 桥已退役(T3b);会话内不再直写全局 —— 稿子指引经**定制中心导入**(writeAgent 管线:`~/.alpha/agents/*.md` + alpha.jsonc 条目,受账本管理)。「全局落点走 `~/.alpha` 桥并在 `~/.opencode` 可见」(验收5 后半)不再成立,替代验收 = 条目进 `~/.alpha/alpha.jsonc` 且引擎 /config 可见。
3. **skill**:项目级落 `<proj>/.alpha/skills/` + `alpha_register`(type=skill,注册 skills.paths 相对路径);全局 `~/.alpha/skills/` 原生可见(REQ-059 T3)。
4. **command / plugin**:新增会话内指导(command 经 alpha_register;plugin 直落 `.alpha/plugins/*.js` + 信任门告知)。
5. 「创建 → 发现 → 免重启」核心验收(2/3/4)语义不变,在新落点由 S30 真机批复测。
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ id: REQ-060
title: 项目级扩展物 `.alpha`-only —— 五类(skill/command/agent/mcp/plugin)生成与安装全落 `<项目>/.alpha`,项目不产生 `.opencode`
type: feature
priority: P1
status: ready
status: shipped
repo: A
created: 2026-07-07
source: 用户拍板(2026-07-07):「项目中生成的 skill command agent mcp 和 plugin 都应该落到 .alpha」「不希望每个项目出现 .alpha .opencode 两个目录」
Expand Down
53 changes: 53 additions & 0 deletions docs/sprints/2026-07-08-s30-req060-finish/sprint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
# S30 — REQ-060 项目级 `.alpha`-only 收尾 + REQ-059 T3b(2026-07-08)

> 契约:ADR-018 §5。WIP=1(S29 已收口)。分支 `feat/s30-req060-finish` → PR → alpha。

## 目标

REQ-060 剩余全清(信任门 UI / T2 / T3 / T5)+ REQ-059 T3b(agents 桥退役),使「项目唯一目录 = `.alpha`」链路端到端可用并真机 verified;全局层 agents 落点与条目化同步收敛。

## 抽取 IDs

| ID | 条目 | 状态(入 sprint 时) |
|---|---|---|
| REQ-060 | 项目级扩展物 `.alpha`-only(剩:信任门 UI / T2 / T3 / T5) | ready |
| REQ-059 | T3b:agents/commands 全局桥退役 + 条目化 | shipped(残 T3b) |

## Task 表

| # | 任务 | 状态 |
|---|---|---|
| S30-1 | 信任门 consent UI:检测 gated → 原生弹窗(B16 模式)→ prefs.json → dispose;拒绝路径记 denied 不重复弹 | ✅ |
| S30-2 | `alpha_register` ext 工具:SAFE_NAME/字段白名单 → 原子写项目 alpha.jsonc → alpha_reload 一条龙;生 TS plugin 拒收 loud | ✅ |
| S30-3 | 创建流改造:agent-creator 落 `<proj>/.alpha/agents` + skill-creator 引导段 + command 指导;REQ-036 修订 | ✅ |
| S30-4 | REQ-059 T3b:writeAgent 去桥(全局 `~/.alpha/agents` + alpha.jsonc 条目;项目 target 同构);removeFsInstall 净除;reconcile 清存量链 | ✅ |
| S30-5 | 真机批(REQ-060 验收 1-6):创建→发现→免重启;信任门双路径;隔离断言;生 TS 拒收;零目录新增 | ✅ |
| S30-6 | 收口:BACKLOG/CHANGELOG/需求档/sprint 回写 + PR merge | ✅ |

## Gates

- 零改上游(north-star guard);`alpha-check` 三关绿。
- 信任门文案与 B16/ADR-021 口径对齐(告知「加载可执行物」的含义)。
- 真机批证据落 `docs/audits/2026-07-08-s30-req060-realmachine/verify.md`。

## 结果

六 task 全完成,一日收口。真机批 12 断言全 PASS(CDP + AppleScript 原生 sheet 实点),证据
[audits/2026-07-08-s30-req060-realmachine](../../audits/2026-07-08-s30-req060-realmachine/verify.md)。

- REQ-060 → **shipped**(核心链路真机 verified):信任门 UI(同意/拒绝双路径 + 不重复弹 + granted
自动 dispose)、alpha_register(项目级注册一条龙)、创建流改稿(零 `.opencode`)、生 TS 拒收 loud、
零目录新增全过。**残(会话级)**:模型经真 LLM 会话实调 alpha_register 未演(机制已证)。
- REQ-059 T3b → **shipped + 真机 verified**:writeAgent 去桥条目化(md 真源 + agentMdToEntry
fail-closed 转换 + alpha.jsonc 条目),装全局 agent 零 `~/.opencode`,卸载净除;reconcile 存量桥
迁移(单测覆盖,本机无存量验证物)。
- 场中发现:S30-5 启动时发现无 CDP 的旧实例抢占单实例锁导致 9222 拒连(带 flag 的新实例让位退出)
—— 真机验证前须 pgrep 确认无残留实例,已记入验证方法。
- 单测:ext 24→33,ui-mac 549(全绿);alpha-check 三关绿。

## 回写清单

- [x] BACKLOG:REQ-060 → shipped(核心真机 verified);REQ-059 T3b 残项关闭
- [x] CHANGELOG [Unreleased]:用户可见变化(项目级扩展 `.alpha`-only + 信任门弹窗)
- [x] 需求档 frontmatter:REQ-060 status=shipped;REQ-036 修订节
- [x] 本 sprint.md task 勾选 + 结果
9 changes: 8 additions & 1 deletion packages/ext/src/plugin-fanout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,7 +63,14 @@ export async function loadProjectPlugins(
const hooks: Record<string, unknown>[] = []
let files: string[]
try {
files = deps.readdirSync(dir).filter((f) => f.endsWith(".js"))
const all = deps.readdirSync(dir)
// ADR-006 生 TS 雷拒收 loud:桌面运行时(Electron-Node)加载生 TS 必崩,只认自包含 ESM .js。
for (const f of all)
if (f.endsWith(".ts"))
deps.error?.(
`[@alpha-code/ext] project plugin ${f} is raw TypeScript — NOT loaded (desktop runtime can't run raw TS, ADR-006). Bundle it to a self-contained ESM .js first.`,
)
files = all.filter((f) => f.endsWith(".js"))
} catch {
return []
}
Expand Down
66 changes: 65 additions & 1 deletion packages/ext/src/plugin.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { pathToFileURL } from "node:url"
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
import { isGlobalAlphaDir, mergeProjectConfig } from "./project-config"
import { loadProjectPlugins, mergeHooks } from "./plugin-fanout"
import { applyRegister, type RegisterType } from "./register"

/**
* alpha-code backend isolation extension.
Expand DownExpand Up@@ -53,6 +54,7 @@ export const AlphaExt: Plugin = async (input) => {
const trustExecutable = readProjectExtensionsConsent(input.directory)
const { added, gatedExecutable } = mergeProjectConfig(cfg as Record<string, unknown>, readFileSync(f, "utf8"), {
trustExecutable,
directory: input.directory,
})
if (gatedExecutable.length)
console.log(
Expand DownExpand Up@@ -83,6 +85,68 @@ export const AlphaExt: Plugin = async (input) => {
}
},
}),
alpha_register: tool({
description:
"Register a project-scoped extension entry into <project>/.alpha/alpha.jsonc (the ONLY alpha directory in a project — never create .opencode). " +
"Use type=agent|command with an entry object (agent: {description,prompt,mode,...}; command: {template,description,...}); " +
"type=mcp with the connector config (loading executable connectors additionally requires the user's per-project consent dialog); " +
"type=skill takes no entry — it registers the ./.alpha/skills path; write the skill itself to .alpha/skills/<name>/SKILL.md. " +
"Plugins are NOT registered here: drop a self-contained ESM .js into .alpha/plugins/ (raw TypeScript is rejected). " +
"The change is validated, written atomically, and auto-reloaded after this reply finishes (available from the NEXT message).",
args: {
type: tool.schema.enum(["mcp", "agent", "command", "skill"]).describe("Extension kind to register"),
name: tool.schema.string().describe("Entry name (letters/digits/._- , max 64 chars); ignored for type=skill").default(""),
entry: tool.schema
.string()
.describe("The entry as a JSON object string, e.g. {\"description\":\"...\",\"prompt\":\"...\"}; empty for type=skill")
.default(""),
},
async execute(args, ctx) {
if (isGlobalAlphaDir(ctx.directory, globalAlphaRoot))
return {
title: "alpha_register",
output: "refused: this session runs in the home directory — project-scoped registration needs a project. Global installs go through the Extension Hub.",
metadata: { ok: false },
}
let entry: Record<string, unknown> | undefined
if (args.entry.trim()) {
try {
const parsed: unknown = JSON.parse(args.entry)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object")
entry = parsed as Record<string, unknown>
} catch {
return { title: "alpha_register", output: "refused: entry is not a valid JSON object string", metadata: { ok: false } }
}
}
const alphaDir = join(ctx.directory, ".alpha")
const file = join(alphaDir, "alpha.jsonc")
const current = existsSync(file) ? readFileSync(file, "utf8") : null
const r = applyRegister(current, args.type as RegisterType, args.name, entry)
if (!r.ok) return { title: "alpha_register", output: `refused: ${r.reason}`, metadata: { ok: false } }
try {
mkdirSync(alphaDir, { recursive: true })
const tmp = file + ".tmp"
writeFileSync(tmp, r.next)
renameSync(tmp, file)
} catch (error) {
return {
title: "alpha_register",
output: `write failed: ${error instanceof Error ? error.message : String(error)}`,
metadata: { ok: false },
}
}
pendingReloads.set(ctx.sessionID ?? "", { reason: `alpha_register ${args.type} ${args.name}`.trim(), at: Date.now() })
const consentNote =
args.type === "mcp" && !readProjectExtensionsConsent(ctx.directory)
? "\nnote: this project has not yet been granted permission to load executable extensions — the connector stays gated until the user confirms the trust dialog (it appears when the project session is opened)."
: ""
return {
title: "alpha_register",
output: `${r.summary}\nreload scheduled — available from the NEXT message.${consentNote}`,
metadata: { ok: true, type: args.type, name: args.name, directory: ctx.directory },
}
},
}),
alpha_echo: tool({
description:
"Echo back the provided text. Proof that an alpha-code plugin-registered tool is available with zero opencode source edits.",
Expand Down
14 changes: 9 additions & 5 deletions packages/ext/src/project-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,11 +23,12 @@ export type MergeResult = {
gatedExecutable: string[]
}

/** 信任门(REQ-060 §3):mcp = 可执行连接器,只在 trustExecutable 时加载;agent/command/skills = 文本注入,恒加载。 */
/** 信任门(REQ-060 §3):mcp = 可执行连接器,只在 trustExecutable 时加载;agent/command/skills = 文本注入,恒加载。
* opts.directory:项目根 —— skills.paths 的相对条目("./…")以此解析为绝对(项目可移动,alpha.jsonc 存相对)。 */
export function mergeProjectConfig(
cfg: Record<string, unknown>,
projectJsoncText: string,
opts: { trustExecutable?: boolean } = {},
opts: { trustExecutable?: boolean; directory?: string } = {},
): MergeResult {
let proj: unknown
try {
Expand All@@ -54,10 +55,13 @@ export function mergeProjectConfig(
if (isObj(proj[key]) && mergeNamed(cfg, proj[key] as Record<string, unknown>, key)) added.push(`${key}.*`)
}

// skills.paths 并集(object schema;去重,existing 在前)
const projPaths = isObj(proj.skills) && Array.isArray((proj.skills as Record<string, unknown>).paths)
// skills.paths 并集(object schema;去重,existing 在前);相对条目按项目根解析为绝对
const rawPaths = isObj(proj.skills) && Array.isArray((proj.skills as Record<string, unknown>).paths)
? ((proj.skills as Record<string, unknown>).paths as unknown[])
: []
const projPaths = rawPaths.map((p) =>
typeof p === "string" && p.startsWith("./") && opts.directory ? resolve(opts.directory, p) : p,
)
if (projPaths.length > 0) {
const cur = isObj(cfg.skills) && Array.isArray((cfg.skills as Record<string, unknown>).paths)
? ((cfg.skills as Record<string, unknown>).paths as unknown[])
Expand DownExpand Up@@ -91,7 +95,7 @@ function mergeNamed(cfg: Record<string, unknown>, src: Record<string, unknown>,
}

/** 极简 jsonc → json:去 // 行注释与 /* *​/ 块注释 + 尾逗号。够用于 alpha 自写的项目 alpha.jsonc。 */
function stripJsonc(text: string): string {
export function stripJsonc(text: string): string {
let out = ""
let inStr = false
let strCh = ""
Expand Down
Loading
Loading