Skip to content

Commit 4d7a56c

Browse files
committed
feat: add progressive skill routing runtime
1 parent 604d3bd commit 4d7a56c

150 files changed

Lines changed: 13379 additions & 3402 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,18 @@ devcodex profile plan|init
141141
| 工作区 skill(W 优先) | `.devcodex/workspace/skills/<id>/SKILL.md` |
142142
| 全局 skill(hidden) | `~/.agents/devcodex/skills/<id>/`(菜单可能不显示,仍可加载) |
143143

144+
**未发布源码候选**:渐进式 Skill 路由会在每轮建立 W + managed G 动态快照,再按 catalog → commit → stage 分页加载正文;全程使用宿主按需启动的本地 stdio MCP 子进程,不监听端口,也不需要服务端。新增或修改 `.devcodex/workspace/skills/<id>/SKILL.md` 会让旧快照失效,并在下一轮重新发现。
145+
146+
Codex/Claude 等宿主自己的 `AGENTS.md`、`CLAUDE.md`、原生个人/项目 Skill 仍由宿主负责发现。DevCodex 不扫描、复制、合并、覆盖或删除这些用户资产;同名也不视为 DevCodex 所有权。Codex 项目指令使用 `AGENTS.md`(不是 `codex.md`),Claude 项目指令使用精确文件名 `CLAUDE.md`,Skill 入口使用精确文件名 `SKILL.md`。
147+
144148
---
145149

146150
## 宿主能力(诚实上限)
147151

148152
| 宿主 | 大致能力 |
149153
|------|----------|
150154
| Claude Code / Codex / Copilot CLI 等 | Hook 较完整时可硬拦危险命令、Stop 收口 |
151-
| **Grok** | **Partial**:UserPromptSubmit **不能**可靠注入;靠读 skill + 条件 Stop |
155+
| **Grok** | **Partial**:UserPromptSubmit **不能**可靠注入;`devcodex grok` 的 exact global-launcher + local-stdio 变体通过新鲜 S15 时可使用渐进式 Skill 路由,其他入口回退 legacy |
152156
| 仅 Instruction 的 surface | 语义约束,不保证硬拦 |
153157

154158
「adapter 已安装」≠「五宿主能力完全一致」。
@@ -245,7 +249,7 @@ Node:`^20.19 || >=22.12`(仅文档站)。
245249
## 用户可见交付与链接兼容
246250

247251
用户可见交付物应带可定位路径(工作区相对路径优先);宿主证据不足时用 portable 链接,不假装全宿主可点。
248-
MCP 侧常见入口含 `profile_load`;工作流技能按意图 **invoke**(按需读取,非整库预载)。
252+
MCP 侧先用 `profile_context_plan` 生成有界计划,再由 `profile_load` 完成选定正文;记忆侧先用 `memory_status` 定位,再按需查询。完成状态以绑定的 `ContextReadReceiptV2` 为准,V1 receipt 只作兼容读取。工作流技能按意图 **invoke**(按需读取,非整库预载)。
249253
用户侧文档 review 聚合见 skill `audit-user-manual`。
250254
规范侧:`PromptLongGateListDriftProbe`(V75)。
251255

‎changelogs/unreleased.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
> **当前**: v1.15.3 已归档到 `changelogs/releases/v1.15.3.md`;本文件仅保留历史锚点索引与镜像供 validate 探针消费。发版后新实现再写入下方「当前未发布」。
55
66
## 当前未发布实现候选
7+
- **工作区 Skill 意图结构化与渐进路由**:83 个 active Skill 增加 `SkillIntentV1`;运行时构建 W + managed G 动态快照,经单一 `skill_route` MCP Tool 执行 catalog/commit/load_stage/status、0/1 自由选择、依赖闭包、冲突与预算校验、分阶段正文加载、条件重规划、幂等重放、上下文截断恢复和有锁 GC。仅有新鲜能力证据的 exact `grok-cli-single/global-launcher-local-stdio` 可进入 unified;其他宿主 fail-closed 到 legacy。MCP 为宿主按需启动的本地 stdio 子进程,无端口、HTTP、远程服务或常驻 daemon。W 在每轮动态发现;用户原生 `AGENTS.md` / `CLAUDE.md` / `SKILL.md` 与个人 Skill 保持宿主所有权,同名不构成清理或覆盖权限。完整 full `86/86`、profile-deploy、package-release 与真实 tarball global-install 均通过。
8+
- **公共 README 校验迁移**:以严格 `PublicReadmeContractV1` 替代要求公共 README 罗列内部 Gate/Skill/控制面术语的历史断言;README/client/release/workflow/brand/turn/execution consumers 共用该边界,并用损坏核心安装路径的负向探针防止宽松绕过。
79
- **全局验证 skill `skill-load-verify`**:触发「验证技能 / skill load verify」;用户可见仅固定句 `SKILL-LOAD-VERIFY-OK`;Skill 规模 85→**86**(83 active + 3 gray)。
810
- **取消用户可见 SkillLoadReceipt 元行**:不再强制/注入 `【DevCodex 技能】…`;加载可见性靠宿主过程时间线 + `skill-load-verify`;Stop 仅 enforce 命中 skill 的 must-reply/body。
911
- **技能过程文案**:inject 引导过程侧写「正在加载 <id> 技能」,避免「命中 … 正在读取并按该技能执行」。
@@ -13,7 +15,7 @@
1315
- **README 用户向重写**:安装、验证 skill、语言诚实、宿主 Partial、仓库边界;去掉 Gate 百科式长清单。
1416
- **控制面 ImplementStartGate 防过程跳过**:`classifyImplementStartGate` 对控制面 mutation **禁止 unbound 放行**(`implement-start-without-task-binding`);绑定任务后除 04/05/复审清单外还须 **00/01 + 02 技术方案**(`implement-start-without-design`);lifecycle PreTool 无任务也 enforce。探针 `test-implement-start-gate.js`。需求:`VSCode全局MCP与五宿主同刷`(过程事故:承诺按流程却直接改码)。
1517
- **VS Code 全局 MCP 与五宿主同刷**:`global-adapters apply` / 全局安装在 Copilot host 事务中合并写入用户级 `Code/User/mcp.json`(及已存在的 Code - Insiders)的 `servers.devcodex-memory|profile`(stdio→copilot runtime mcp),保留既有 `inputs`/其它 server;测试与边界见 `global-host-target.resolveVscodeUserMcpPaths`。
16-
- **SkillsDeployModeV1(隐蔽运行时 + PR-1 防假绿)**:默认 `skillsDeployMode=hidden` 将 active skills 部署到 `~/.agents/devcodex/skills`(G_RUNTIME),停写/目录 prune `~/.agents/skills`、`~/.claude/skills`、`~/.copilot/skills` 中的包 managed id;`resolveGlobalSkillsRoot` 随 mode 切换(ENV `DEVCODEX_GLOBAL_SKILLS_ROOT` 仍最高优先);`global-adapters apply --mode=hidden|legacy` 与 npm lifecycle 透传;receipt 记录 mode。强化 `pr1EvidenceOk`:有 `02` 须独立实质 `03` 复审(长度+blocker=0+实质章节),拒绝 thin open-blocker / sessions-only;改写 stop-gate 负向探针。需求:`全局Skill与插件隐蔽运行时`。
18+
- **SkillsDeployModeV1(隐蔽运行时 + PR-1 防假绿)**:默认 `skillsDeployMode=hidden` 将 active skills 部署到 `~/.agents/devcodex/skills`(G_RUNTIME),不再写入、扫描、按 ID prune 或覆盖 `~/.agents/skills`、`~/.claude/skills`、`~/.copilot/skills` 等宿主原生根;仅有 committed receipt/marker/digest 完整 ownership proof 的旧受管路径可进入显式清理预览。`resolveGlobalSkillsRoot` 随 mode 切换(ENV `DEVCODEX_GLOBAL_SKILLS_ROOT` 仍最高优先);`global-adapters apply --mode=hidden|legacy` 与 npm lifecycle 透传;receipt 记录 mode。强化 `pr1EvidenceOk`:有 `02` 须独立实质 `03` 复审(长度+blocker=0+实质章节),拒绝 thin open-blocker / sessions-only;改写 stop-gate 负向探针。需求:`全局Skill与插件隐蔽运行时`。
1719
- **Runtime 白名单闭包防复发**:新增 `scripts/lib/runtime-dependency-closure.js` 作为统一 runtime require 闭包 Owner;`MCP_RUNTIME_DEPS` / `PROJECT_RUNTIME_SCRIPT_DEPS`(兼容 `CLAUDE_MCP_RUNTIME_SCRIPT_DEPS`)改为派生;Codex project hook 复制到 `.codex/scripts/lib`;`test:mcp-runtime-closure` 覆盖 allowlist-only layout/MCP smoke/负向/packlist;validation 节点 `mcp-runtime-closure` 接入 fast/full/profile-deploy/package-release。需求:`bugs/runtime白名单闭包防复发修复`。
1820
- **P0-1 工作区意图 + DEVCODEX.md + ImplementStartGate + e2e(Batch1)**:入口 `.devcodex/workspace/DEVCODEX.md`;`workspace-skill-intent` catalog/意图/lifecycle;控制面 mutation 前 04+05+复审清单门闩(`IMPLEMENT_START_WITHOUT_PROCESS`);全局 skill `workspace-skill-author`;`npm run test:workspace-skill-intent` / `test:workspace-skill-intent-e2e`(E2E-A~J+K)并入 `test:skill-resolve`。**无 e2e 不算 Batch1 完成**。需求:`开源发布前用户面与文档边界收口`。
1921
- **P0-1 Batch2**:`init`/`ensureRuntimeDirs` 写入 DEVCODEX.md 模板(不覆盖);CLI `devcodex skill intent <prompt> [--json]`;help 文案同步。
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
'use strict'
2+
3+
const crypto = require('crypto')
4+
const path = require('path')
5+
const { createDerivedStateStore } = require('./derived-state-store.cjs')
6+
const { stableDigest, validateContextReadPlan } = require('./context-read-contract.cjs')
7+
8+
const CONTEXT_PLAN_OBSERVATION_SCHEMA = 'ContextPlanObservationV1'
9+
const CONTEXT_PLAN_OBSERVATION_SLOT_COUNT = 128
10+
const CONTEXT_PLAN_OBSERVATION_MAX_BYTES = 256 * 1024
11+
const CONTEXT_PLAN_OBSERVATION_FUTURE_SKEW_MS = 5 * 60 * 1000
12+
const OBSERVATION_FIELDS = new Set([
13+
'schemaVersion',
14+
'contextEpochDigest',
15+
'contextEpoch',
16+
'project',
17+
'activeRoot',
18+
'observedAt',
19+
'planId',
20+
'planContentId',
21+
'planDigest',
22+
'plan'
23+
])
24+
25+
function portableRoot(value) {
26+
const text = String(value || '').trim()
27+
return text ? path.resolve(text).replace(/\\/g, '/') : ''
28+
}
29+
30+
function contextEpochDigest(contextEpoch) {
31+
return crypto.createHash('sha256').update(String(contextEpoch || '').trim()).digest('hex')
32+
}
33+
34+
function contextPlanObservationRelativePath(contextEpoch) {
35+
const digest = contextEpochDigest(contextEpoch)
36+
const slot = Number.parseInt(digest.slice(0, 8), 16) % CONTEXT_PLAN_OBSERVATION_SLOT_COUNT
37+
return path.join(
38+
'.runtime-state',
39+
'context-plan-observations',
40+
'v1',
41+
`slot-${String(slot).padStart(3, '0')}.json`
42+
)
43+
}
44+
45+
function buildContextPlanObservation({ activeRoot, project, contextEpoch, plan, nowMs = Date.now() }) {
46+
return {
47+
schemaVersion: CONTEXT_PLAN_OBSERVATION_SCHEMA,
48+
contextEpochDigest: contextEpochDigest(contextEpoch),
49+
contextEpoch: String(contextEpoch || '').trim(),
50+
project: String(project || '').trim(),
51+
activeRoot: portableRoot(activeRoot),
52+
observedAt: new Date(nowMs).toISOString(),
53+
planId: String(plan?.planId || '').trim(),
54+
planContentId: String(plan?.planContentId || '').trim(),
55+
planDigest: stableDigest(plan),
56+
plan
57+
}
58+
}
59+
60+
function validateContextPlanObservation(value, expected = {}) {
61+
const errors = []
62+
const observation = value && typeof value === 'object' && !Array.isArray(value) ? value : null
63+
if (!observation) return { valid: false, errors: ['observation must be an object'], plan: null }
64+
65+
const unknown = Object.keys(observation).filter(field => !OBSERVATION_FIELDS.has(field))
66+
if (unknown.length) errors.push(`unsupported observation fields: ${unknown.join(', ')}`)
67+
if (observation.schemaVersion !== CONTEXT_PLAN_OBSERVATION_SCHEMA) errors.push('invalid observation schema')
68+
69+
const epoch = String(observation.contextEpoch || '').trim()
70+
const project = String(observation.project || '').trim()
71+
const activeRoot = portableRoot(observation.activeRoot)
72+
if (!epoch || observation.contextEpochDigest !== contextEpochDigest(epoch)) errors.push('invalid context epoch identity')
73+
if (!project || !activeRoot) errors.push('observation target is incomplete')
74+
75+
if (expected.contextEpoch && epoch !== String(expected.contextEpoch).trim()) errors.push('context epoch mismatch')
76+
if (expected.project && project !== String(expected.project).trim()) errors.push('project mismatch')
77+
if (expected.activeRoot && activeRoot !== portableRoot(expected.activeRoot)) errors.push('active root mismatch')
78+
79+
const observedAtMs = Date.parse(observation.observedAt)
80+
const notBeforeMs = expected.notBefore ? Date.parse(expected.notBefore) : null
81+
const nowMs = Number.isFinite(expected.nowMs) ? expected.nowMs : Date.now()
82+
if (!Number.isFinite(observedAtMs)) errors.push('invalid observedAt')
83+
if (Number.isFinite(notBeforeMs) && observedAtMs < notBeforeMs) errors.push('observation predates the matching tool attempt')
84+
if (Number.isFinite(observedAtMs) && observedAtMs > nowMs + CONTEXT_PLAN_OBSERVATION_FUTURE_SKEW_MS) {
85+
errors.push('observation timestamp is too far in the future')
86+
}
87+
88+
const planValidation = validateContextReadPlan(observation.plan)
89+
if (!planValidation.valid) errors.push(planValidation.error.message)
90+
const plan = planValidation.valid ? observation.plan : null
91+
if (plan) {
92+
if (observation.planDigest !== stableDigest(plan)) errors.push('plan digest mismatch')
93+
if (observation.planId !== plan.planId || observation.planContentId !== plan.planContentId) {
94+
errors.push('plan identity mirror mismatch')
95+
}
96+
if (plan.identity.contextEpoch !== epoch ||
97+
plan.identity.project !== project ||
98+
portableRoot(plan.identity.activeRoot) !== activeRoot) {
99+
errors.push('plan target does not match the observation envelope')
100+
}
101+
}
102+
103+
return { valid: errors.length === 0, errors, plan }
104+
}
105+
106+
function observationStore(activeRoot, contextEpoch, maxWrites) {
107+
return createDerivedStateStore({
108+
root: activeRoot,
109+
relativePath: contextPlanObservationRelativePath(contextEpoch),
110+
maxBytes: CONTEXT_PLAN_OBSERVATION_MAX_BYTES,
111+
lockWaitMs: 2000,
112+
maxWrites
113+
})
114+
}
115+
116+
function persistContextPlanObservation(input) {
117+
const observation = buildContextPlanObservation(input)
118+
const validation = validateContextPlanObservation(observation, {
119+
activeRoot: input.activeRoot,
120+
project: input.project,
121+
contextEpoch: input.contextEpoch,
122+
nowMs: input.nowMs
123+
})
124+
if (!validation.valid) {
125+
return {
126+
status: 'invalid',
127+
errorCode: 'CONTEXT_PLAN_OBSERVATION_INVALID',
128+
errors: validation.errors
129+
}
130+
}
131+
132+
const store = observationStore(input.activeRoot, input.contextEpoch, 1)
133+
const write = store.write(observation)
134+
if (write.status !== 'persisted') return write
135+
136+
const read = store.read()
137+
const readValidation = read.status === 'fresh'
138+
? validateContextPlanObservation(read.value, {
139+
activeRoot: input.activeRoot,
140+
project: input.project,
141+
contextEpoch: input.contextEpoch,
142+
nowMs: input.nowMs
143+
})
144+
: { valid: false, errors: [`observation read-back status is ${read.status}`] }
145+
if (!readValidation.valid) {
146+
return {
147+
status: 'invalid',
148+
errorCode: 'CONTEXT_PLAN_OBSERVATION_READBACK_INVALID',
149+
errors: readValidation.errors,
150+
filePath: store.filePath
151+
}
152+
}
153+
return {
154+
status: 'persisted',
155+
filePath: store.filePath,
156+
bytes: write.bytes,
157+
planDigest: observation.planDigest
158+
}
159+
}
160+
161+
function readContextPlanObservation({ activeRoot, project, contextEpoch, notBefore, nowMs }) {
162+
const store = observationStore(activeRoot, contextEpoch, 0)
163+
const read = store.read()
164+
if (read.status !== 'fresh') return { ...read, plan: null }
165+
const validation = validateContextPlanObservation(read.value, {
166+
activeRoot,
167+
project,
168+
contextEpoch,
169+
notBefore,
170+
nowMs
171+
})
172+
if (!validation.valid) {
173+
return {
174+
status: 'invalid',
175+
errorCode: 'CONTEXT_PLAN_OBSERVATION_INVALID',
176+
errors: validation.errors,
177+
filePath: store.filePath,
178+
plan: null
179+
}
180+
}
181+
return {
182+
status: 'fresh',
183+
filePath: store.filePath,
184+
bytes: read.bytes,
185+
observation: read.value,
186+
plan: validation.plan
187+
}
188+
}
189+
190+
module.exports = {
191+
CONTEXT_PLAN_OBSERVATION_SCHEMA,
192+
CONTEXT_PLAN_OBSERVATION_SLOT_COUNT,
193+
CONTEXT_PLAN_OBSERVATION_MAX_BYTES,
194+
buildContextPlanObservation,
195+
contextEpochDigest,
196+
contextPlanObservationRelativePath,
197+
persistContextPlanObservation,
198+
readContextPlanObservation,
199+
validateContextPlanObservation
200+
}

‎hooks/_runtime/context-read-contract.cjs‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const PLAN_V1_FIELDS = new Set([
7575
])
7676
const PLAN_FIELDS = new Set([
7777
...PLAN_V1_FIELDS,
78-
'planContentId', 'identityInputs', 'executionOptimization', 'reusePolicy', 'stageTiming', 'cacheDecision'
78+
'planContentId', 'contextBinding', 'identityInputs', 'executionOptimization', 'reusePolicy', 'stageTiming', 'cacheDecision'
7979
])
8080
const SEED_FIELDS = new Set([
8181
'schemaVersion', 'contextEpoch', 'semantic', 'intent', 'targetHint', 'continuationHint',
@@ -423,6 +423,17 @@ function buildPlanIdentityInputs(plan) {
423423
}
424424
}
425425

426+
function buildContextReadBinding(plan) {
427+
return {
428+
schemaVersion: 'ContextReadBindingV1',
429+
contextEpoch: plan.identity.contextEpoch,
430+
planId: plan.planId,
431+
planContentId: plan.planContentId,
432+
activeRoot: plan.identity.activeRoot,
433+
project: plan.identity.project
434+
}
435+
}
436+
426437
function refreshPlannerResponseBytes(plan) {
427438
let observed = 0
428439
for (let attempt = 0; attempt < 4; attempt += 1) {
@@ -883,6 +894,17 @@ function buildContextReadPlan(input = {}, options = {}) {
883894
finalIntent,
884895
invocationNonce
885896
},
897+
contextBinding: null,
898+
exitCondition: blocked ? 'blocked' : (fullRead ? 'escalated-full' : 'relevant-complete'),
899+
profile: {
900+
selectedFiles: [...selectedFiles].sort(),
901+
configLocalRequested: input.configLocalRequested === true
902+
},
903+
memory: {
904+
requiredQueries: selectedSources.filter(source => source.kind === 'memory').map(source => source.selector).sort()
905+
},
906+
fullRead,
907+
fullReadReason: fullRead ? automaticFullReason : null,
886908
baselineContext: baseline,
887909
actionEnvelope: deriveActionEnvelope(finalIntent, changeTypes, seed.riskHint),
888910
changeTypes,
@@ -915,16 +937,6 @@ function buildContextReadPlan(input = {}, options = {}) {
915937
},
916938
escalationTriggers: uniqueSorted(CONTEXT_READ_CONTRACT.escalationTriggers),
917939
triggeredEscalations: [...triggerSet].sort(),
918-
exitCondition: blocked ? 'blocked' : (fullRead ? 'escalated-full' : 'relevant-complete'),
919-
profile: {
920-
selectedFiles: [...selectedFiles].sort(),
921-
configLocalRequested: input.configLocalRequested === true
922-
},
923-
memory: {
924-
requiredQueries: selectedSources.filter(source => source.kind === 'memory').map(source => source.selector).sort()
925-
},
926-
fullRead,
927-
fullReadReason: fullRead ? automaticFullReason : null,
928940
planningTelemetry: {
929941
bytes: planningMeasure.bytes,
930942
chars: planningMeasure.chars,
@@ -952,6 +964,7 @@ function buildContextReadPlan(input = {}, options = {}) {
952964
contextEpoch: seed.contextEpoch,
953965
invocationNonce
954966
}).slice(0, 24)}`
967+
plan.contextBinding = buildContextReadBinding(plan)
955968
refreshPlannerResponseBytes(plan)
956969
const validation = validateContextReadPlan(plan)
957970
return validation.valid ? plan : validation.error
@@ -1109,6 +1122,9 @@ function validateContextReadPlan(raw) {
11091122
invocationNonce: identity.invocationNonce
11101123
}).slice(0, 24)}`
11111124
if (raw.planId !== expectedPlanId) errors.push('planId invocation digest mismatch')
1125+
if (stableDigest(raw.contextBinding) !== stableDigest(buildContextReadBinding(raw))) {
1126+
errors.push('contextBinding is not derived from plan identity')
1127+
}
11121128
} catch (error) {
11131129
errors.push(`identityInputs validation failed: ${error.message}`)
11141130
}

0 commit comments

Comments
 (0)