Skip to content

Commit 972cb7b

Browse files
committed
fix: bind skill routing after project resolution
1 parent 99cd9fe commit 972cb7b

11 files changed

Lines changed: 234 additions & 40 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ devcodex profile plan|init
139139
| 工作区 skill(W 优先) | `.devcodex/workspace/skills/<id>/SKILL.md` |
140140
| 全局 skill(hidden) | `~/.agents/devcodex/skills/<id>/`(菜单可能不显示,仍可加载) |
141141

142-
**未发布源码候选**:渐进式 Skill 路由是五宿主唯一默认路由。它会在每轮建立 W + managed G 动态快照,再按 catalog → commit → stage 分页加载正文;全程使用宿主按需启动的本地 stdio MCP 子进程,不监听端口,也不需要服务端。新增或修改 `.devcodex/workspace/skills/<id>/SKILL.md` 会让旧快照失效,并在下一轮重新发现。宿主生产证据继续作为发布后的可观测性材料,不再控制源码路由模式,也不阻塞功能完成。
142+
**未发布源码候选**:渐进式 Skill 路由是五宿主唯一默认路由。它会在每轮建立 W + managed G 动态快照,再按 catalog → commit → stage 分页加载正文;全程使用宿主按需启动的本地 stdio MCP 子进程,不监听端口,也不需要服务端。多项目工作区尚未解析出目标时先等待 Profile plan 绑定真实项目,不会把工作区目录名当成项目创建运行态。新增或修改 `.devcodex/workspace/skills/<id>/SKILL.md` 会让旧快照失效,并在下一轮重新发现。宿主生产证据继续作为发布后的可观测性材料,不再控制源码路由模式,也不阻塞功能完成。
143143

144144
Codex/Claude 等宿主自己的 `AGENTS.md`、`CLAUDE.md`、原生个人/项目 Skill 仍由宿主负责发现。DevCodex 不扫描、复制、合并、覆盖或删除这些用户资产;同名也不视为 DevCodex 所有权。Codex 项目指令使用 `AGENTS.md`(不是 `codex.md`),Claude 项目指令使用精确文件名 `CLAUDE.md`,Skill 入口使用精确文件名 `SKILL.md`。
145145

‎changelogs/unreleased.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
> **当前**: v1.15.3 已归档到 `changelogs/releases/v1.15.3.md`;本文件仅保留历史锚点索引与镜像供 validate 探针消费。发版后新实现再写入下方「当前未发布」。
55
66
## 当前未发布实现候选
7-
- **工作区 Skill 意图结构化与渐进路由(B4 收口)**:83 个 active Skill 使用 `SkillIntentV1`;运行时构建 W + managed G 动态快照,经单一 `skill_route` MCP Tool 执行 catalog/commit/load_stage/status、0/1 自由选择、依赖闭包、冲突与预算校验、分阶段正文加载、条件重规划、幂等重放、会话隔离恢复和有锁 GC。统一路由现为 Claude/Codex/Copilot/Gemini/Grok 五宿主唯一源码默认,能力证据仅用于发布后观测,不再切换模式或阻塞完成;旧 `WorkspaceSkillAutoMatch` / `WorkspaceSkillIntent` 生命周期分支、CLI 入口和验证节点退出活跃闭包。修复跨会话 Stop 读取共享状态导致的 `test` 误触发,并把否定、截图、日志、报告、诊断性提及排除在显式调用外。MCP 为宿主按需启动的本地 stdio 子进程,无端口、HTTP、远程服务或常驻 daemon。W 在每轮动态发现;用户原生 `AGENTS.md` / `CLAUDE.md` / `SKILL.md` 与个人 Skill 保持宿主所有权,同名不构成清理或覆盖权限。
7+
- **工作区 Skill 意图结构化与渐进路由(B4 收口)**:83 个 active Skill 使用 `SkillIntentV1`;运行时构建 W + managed G 动态快照,经单一 `skill_route` MCP Tool 执行 catalog/commit/load_stage/status、0/1 自由选择、依赖闭包、冲突与预算校验、分阶段正文加载、条件重规划、幂等重放、会话隔离恢复和有锁 GC。统一路由现为 Claude/Codex/Copilot/Gemini/Grok 五宿主唯一源码默认,能力证据仅用于发布后观测,不再切换模式或阻塞完成;旧 `WorkspaceSkillAutoMatch` / `WorkspaceSkillIntent` 生命周期分支、CLI 入口和验证节点退出活跃闭包。修复跨会话 Stop 读取共享状态导致的 `test` 误触发,并把否定、截图、日志、报告、诊断性提及排除在显式调用外;多项目工作区目标未绑定时改为 `SkillRouteBootstrapPendingV1`,只接受 observed ContextRead plan 绑定现有项目,禁止用 workspace basename 创建合成 namespace。MCP 为宿主按需启动的本地 stdio 子进程,无端口、HTTP、远程服务或常驻 daemon。W 在每轮动态发现;用户原生 `AGENTS.md` / `CLAUDE.md` / `SKILL.md` 与个人 Skill 保持宿主所有权,同名不构成清理或覆盖权限。
88
- **公共 README 校验迁移**:以严格 `PublicReadmeContractV1` 替代要求公共 README 罗列内部 Gate/Skill/控制面术语的历史断言;README/client/release/workflow/brand/turn/execution consumers 共用该边界,并用损坏核心安装路径的负向探针防止宽松绕过。
99
- **全局验证 skill `skill-load-verify`**:触发「验证技能 / skill load verify」;用户可见仅固定句 `SKILL-LOAD-VERIFY-OK`;Skill 规模 85→**86**(83 active + 3 gray)。
1010
- **取消用户可见 SkillLoadReceipt 元行**:不再强制/注入 `【DevCodex 技能】…`;加载可见性靠宿主过程时间线 + `skill-load-verify`;Stop 仅 enforce 命中 skill 的 must-reply/body。

‎hooks/_runtime/host-skill-route-capabilities.v1.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
"status": "PASS",
1414
"testedVersion": "codex-cli 0.145.0",
1515
"protocol": "MCP 2024-11-05",
16-
"runtimeContractDigest": "4c225c1110a7a24d483c80870aca8b58c7a1d0f8864bf1b9d3a5937277e73065",
17-
"hostAdapterDigest": "10710bef81d425e6df9c09a5c059c301aff85dfb701599337698ed105fca6172",
18-
"evidenceDigest": "77a911f991b53769cdd4fb527292c74c9e7076694106e51534847abafc4e3a9f",
19-
"evidenceRef": "E:/Worker/.devcodex/devcodex/.audit-state/skill-route-s15-codex-20260729-stale-stop-fix-production-r3.json",
16+
"runtimeContractDigest": "d1a1841d89cefd5498d9abfbc7d4f8559010d4a219ebbb6697086ea94a959529",
17+
"hostAdapterDigest": "04d6bdd414535a5710befc9dddf06b7e538e5ff1d95fa9a45651e4ee43b4b938",
18+
"evidenceDigest": "66b59d1f1d4617ca53b478824f0a257e6743f59f38b09b5398afb547ccc33d35",
19+
"evidenceRef": "E:/Worker/.devcodex/devcodex/.audit-state/skill-route-s15-codex-20260729-project-rebind-production.json",
2020
"entrySurface": "codex exec --ephemeral",
2121
"bootstrapDelivery": "user-global hooks.json UserPromptSubmit",
2222
"defaultEligible": true

‎hooks/_runtime/lifecycle-bootstrap-state.cjs‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ function buildLifecycleBootstrapStateUtils(ctx) {
5252
getStatePaths,
5353
getActiveScope,
5454
getActiveNamespaceRoot,
55+
listWorkspaceProjects,
5556
getBootstrapAgent,
5657
getWorkspaceNamespaceRoot,
5758
readProfileMode,
@@ -733,7 +734,12 @@ function buildLifecycleBootstrapStateUtils(ctx) {
733734
return { allowed: false, suspicious: true, reason: 'context tool requires an exact server/tool identity' }
734735
}
735736
if (identity.canonical) {
736-
if (!targetMatches(args, state)) {
737+
const requestedProject = String(args.project || '').trim()
738+
const pendingPlanTarget = identity.tool === 'profile_context_plan' &&
739+
!acquisition.targetResolved &&
740+
requestedProject &&
741+
listWorkspaceProjects().includes(requestedProject)
742+
if (!targetMatches(args, state) && !pendingPlanTarget) {
737743
return { allowed: false, suspicious: true, reason: 'context tool target does not match the active project' }
738744
}
739745
if (identity.tool === 'skill_route') {
@@ -766,7 +772,9 @@ function buildLifecycleBootstrapStateUtils(ctx) {
766772
if (identity.tool === 'profile_context_plan') {
767773
const epoch = String(args.contextEpoch || '').trim()
768774
const planKey = stableDigest({ contextEpoch: acquisition.contextEpoch, canonical: identity.canonical, args })
769-
if (!acquisition.targetResolved || !epoch || epoch !== acquisition.contextEpoch) {
775+
if ((!acquisition.targetResolved && !pendingPlanTarget) ||
776+
!epoch ||
777+
epoch !== acquisition.contextEpoch) {
770778
return { allowed: false, suspicious: true, reason: 'profile plan must use the current bound contextEpoch' }
771779
}
772780
if (acquisition.failedPlanKeys.includes(planKey)) {
@@ -782,6 +790,10 @@ function buildLifecycleBootstrapStateUtils(ctx) {
782790
args,
783791
argsDigest: stableDigest(args),
784792
planKey,
793+
targetProject: pendingPlanTarget ? requestedProject : acquisition.project,
794+
targetActiveRoot: pendingPlanTarget
795+
? normalizePath(getActiveNamespaceRoot(state, requestedProject, 'project'))
796+
: acquisition.activeRoot,
785797
sourceIds: []
786798
}
787799
}
@@ -936,8 +948,8 @@ function buildLifecycleBootstrapStateUtils(ctx) {
936948
attemptId: `pre-${crypto.randomUUID()}`,
937949
toolCallId: getToolCallId(payload),
938950
contextEpoch: acquisition.contextEpoch,
939-
activeRoot: acquisition.activeRoot,
940-
project: acquisition.project,
951+
activeRoot: classified.targetActiveRoot || acquisition.activeRoot,
952+
project: classified.targetProject || acquisition.project,
941953
canonical: classified.canonical,
942954
server: classified.server,
943955
tool: classified.tool,
@@ -1301,8 +1313,8 @@ function buildLifecycleBootstrapStateUtils(ctx) {
13011313
: null
13021314
if (!plan && extracted.outcome?.transportSuccess && typeof extracted.outcome.payload === 'string' && !inlineBody) {
13031315
const recovered = readContextPlanObservation({
1304-
activeRoot: acquisition.activeRoot,
1305-
project: acquisition.project,
1316+
activeRoot: attempt.activeRoot,
1317+
project: attempt.project,
13061318
contextEpoch: acquisition.contextEpoch,
13071319
notBefore: attempt.startedAt
13081320
})
@@ -1325,8 +1337,8 @@ function buildLifecycleBootstrapStateUtils(ctx) {
13251337
}
13261338
}
13271339
const identityMatches = !!plan && plan.identity.contextEpoch === acquisition.contextEpoch &&
1328-
normalizePath(plan.identity.activeRoot) === acquisition.activeRoot &&
1329-
plan.identity.project === acquisition.project &&
1340+
normalizePath(plan.identity.activeRoot) === normalizePath(attempt.activeRoot) &&
1341+
plan.identity.project === attempt.project &&
13301342
String(attempt.args.contextEpoch || '') === acquisition.contextEpoch
13311343
if (!identityMatches) {
13321344
if (!acquisition.failedPlanKeys.includes(attempt.planKey)) acquisition.failedPlanKeys.push(attempt.planKey)
@@ -1390,6 +1402,9 @@ function buildLifecycleBootstrapStateUtils(ctx) {
13901402
}
13911403
acquisition.plan = plan
13921404
acquisition.receipt = receipt
1405+
acquisition.activeRoot = normalizePath(plan.identity.activeRoot)
1406+
acquisition.project = plan.identity.project
1407+
acquisition.targetResolved = true
13931408
acquisition.stageTiming = {
13941409
...plan.stageTiming,
13951410
hostDeliveredBytes: extracted.outcome?.telemetry?.bytes ?? null
@@ -1404,6 +1419,7 @@ function buildLifecycleBootstrapStateUtils(ctx) {
14041419
/** Correlate an observable PostToolUse result and advance only independently proven sources. */
14051420
function recordContextPostToolUse(state, payload) {
14061421
const acquisition = syncContextProjection(state)
1422+
const targetWasResolved = acquisition.targetResolved === true
14071423
const identity = canonicalContextTool(payload)
14081424
if (!identity.canonical && !isBootstrapReadTool(payload, state)) {
14091425
return { observed: false, ignored: true }
@@ -1440,7 +1456,20 @@ function buildLifecycleBootstrapStateUtils(ctx) {
14401456
acquisition.lastError = contextError('CONTEXT_PLAN_INVALID', 'PostToolUse input does not match the correlated PreToolUse attempt.')
14411457
}
14421458
} else if (attempt.kind === 'plan') {
1443-
installObservedPlan(acquisition, attempt, payload)
1459+
const installed = installObservedPlan(acquisition, attempt, payload)
1460+
if (installed && !targetWasResolved) {
1461+
state.activeProject = acquisition.project
1462+
state.activeScope = 'project'
1463+
state.activeProjectSource = 'context-plan'
1464+
state.mode = readProfileMode(state, acquisition.project)
1465+
state.stickyProject = {
1466+
project: acquisition.project,
1467+
source: 'context-plan',
1468+
sessionKey: acquisition.hostSessionId,
1469+
updatedAt: new Date().toISOString(),
1470+
updatedAtMs: Date.now()
1471+
}
1472+
}
14441473
} else if (attempt.kind === 'profile-load' && acquisition.plan && acquisition.receipt) {
14451474
const parsed = profileSourceResults(acquisition, attempt, outcome)
14461475
if (parsed.drift) {
@@ -1468,7 +1497,14 @@ function buildLifecycleBootstrapStateUtils(ctx) {
14681497
}].slice(-40)
14691498
state.contextAcquisition = acquisition
14701499
syncContextProjection(state)
1471-
return { observed: true, attempt, outcome }
1500+
return {
1501+
observed: true,
1502+
attempt,
1503+
outcome,
1504+
targetRebound: !targetWasResolved && acquisition.targetResolved === true,
1505+
project: acquisition.project,
1506+
activeRoot: acquisition.activeRoot
1507+
}
14721508
}
14731509

14741510
function updateBootstrapState(state, payload) {

‎hooks/_runtime/lifecycle.cjs‎

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ const {
488488
getStatePaths,
489489
getActiveScope,
490490
getActiveNamespaceRoot,
491+
listWorkspaceProjects,
491492
getBootstrapAgent,
492493
getWorkspaceNamespaceRoot,
493494
readProfileMode,
@@ -1390,25 +1391,52 @@ async function main() {
13901391
const {
13911392
getLifecycleHostAdapterDigest
13921393
} = require('./host-adapter-identity.cjs')
1393-
const route = bootstrapSkillRouteForTurn({
1394-
project: state.contextAcquisition?.project,
1395-
contextEpoch: state.contextAcquisition?.contextEpoch,
1396-
prompt,
1397-
host: platform,
1398-
cwd: CONTEXT_ROOT
1399-
}, {
1400-
inputRoot: CONTEXT_ROOT,
1401-
env: process.env,
1402-
hostAdapterDigest: getLifecycleHostAdapterDigest(platform)
1403-
})
1404-
progressiveSkillRouteMode = route.modeReceipt?.effective || 'unified'
1405-
progressiveSkillRouteMsg = route.injectionText || ''
1406-
state.progressiveSkillRoute = {
1407-
schemaVersion: 'LifecycleSkillRouteStateV1',
1408-
modeReceipt: route.modeReceipt,
1409-
bootstrap: route.bootstrap,
1410-
active: route.active === true,
1411-
errorCode: null
1394+
if (!state.contextAcquisition?.targetResolved) {
1395+
const { parseExplicitSkillId } = require('./skill-route-state.cjs')
1396+
const requestedSkillId = parseExplicitSkillId(prompt)
1397+
const pending = {
1398+
schemaVersion: 'SkillRouteBootstrapPendingV1',
1399+
contextEpoch: state.contextAcquisition?.contextEpoch,
1400+
targetResolved: false,
1401+
explicitStatus: requestedSkillId ? 'pending-target' : 'none',
1402+
explicitSkillId: requestedSkillId
1403+
}
1404+
progressiveSkillRouteMsg = [
1405+
'### DevCodex · SkillRouteBootstrapPendingV1',
1406+
JSON.stringify(pending),
1407+
'',
1408+
'Resolve one real project with `profile_context_plan` using this contextEpoch. After the observed plan binds the active-root, the lifecycle will inject the project-bound SkillRouteBootstrapV1.',
1409+
'Do not call `skill_route` with the workspace directory name and do not create a synthetic project namespace.'
1410+
].join('\n')
1411+
state.progressiveSkillRoute = {
1412+
schemaVersion: 'LifecycleSkillRouteStateV1',
1413+
modeReceipt: null,
1414+
bootstrap: null,
1415+
pending,
1416+
active: false,
1417+
errorCode: null
1418+
}
1419+
} else {
1420+
const route = bootstrapSkillRouteForTurn({
1421+
project: state.contextAcquisition?.project,
1422+
contextEpoch: state.contextAcquisition?.contextEpoch,
1423+
prompt,
1424+
host: platform,
1425+
cwd: CONTEXT_ROOT
1426+
}, {
1427+
inputRoot: CONTEXT_ROOT,
1428+
env: process.env,
1429+
hostAdapterDigest: getLifecycleHostAdapterDigest(platform)
1430+
})
1431+
progressiveSkillRouteMode = route.modeReceipt?.effective || 'unified'
1432+
progressiveSkillRouteMsg = route.injectionText || ''
1433+
state.progressiveSkillRoute = {
1434+
schemaVersion: 'LifecycleSkillRouteStateV1',
1435+
modeReceipt: route.modeReceipt,
1436+
bootstrap: route.bootstrap,
1437+
active: route.active === true,
1438+
errorCode: null
1439+
}
14121440
}
14131441
} catch (error) {
14141442
state.progressiveSkillRoute = {
@@ -1698,7 +1726,7 @@ async function main() {
16981726

16991727
// ── PostToolUse ────────────────────────────────────────────────────────────
17001728
if (eventName === 'PostToolUse') {
1701-
recordContextPostToolUse(state, payload)
1729+
const contextPost = recordContextPostToolUse(state, payload)
17021730
markContextPostMutationStale(state, payload, platform)
17031731
observeGovernanceLedgerWrite(state, payload, {
17041732
activeRoot: getActiveNamespaceRoot(state),
@@ -1709,6 +1737,46 @@ async function main() {
17091737
markProductMutationOrder(state, payload, platform)
17101738
updateArtifactTouches(state, payload, platform)
17111739
state.turnLiveness = completeToolLease(state.turnLiveness, payload)
1740+
if (contextPost?.targetRebound) {
1741+
try {
1742+
const { bootstrapSkillRouteForTurn } = require('./skill-route-tool.cjs')
1743+
const {
1744+
getLifecycleHostAdapterDigest
1745+
} = require('./host-adapter-identity.cjs')
1746+
const route = bootstrapSkillRouteForTurn({
1747+
project: state.contextAcquisition.project,
1748+
contextEpoch: state.contextAcquisition.contextEpoch,
1749+
explicitSkillId: state.progressiveSkillRoute?.pending?.explicitSkillId || null,
1750+
host: platform,
1751+
cwd: CONTEXT_ROOT
1752+
}, {
1753+
inputRoot: CONTEXT_ROOT,
1754+
env: process.env,
1755+
hostAdapterDigest: getLifecycleHostAdapterDigest(platform)
1756+
})
1757+
state.progressiveSkillRoute = {
1758+
schemaVersion: 'LifecycleSkillRouteStateV1',
1759+
modeReceipt: route.modeReceipt,
1760+
bootstrap: route.bootstrap,
1761+
active: route.active === true,
1762+
errorCode: null
1763+
}
1764+
saveState(state)
1765+
writeStdout(contextMessageOutput(
1766+
'PostToolUse',
1767+
route.injectionText || ''
1768+
))
1769+
return
1770+
} catch (error) {
1771+
state.progressiveSkillRoute = {
1772+
schemaVersion: 'LifecycleSkillRouteStateV1',
1773+
modeReceipt: null,
1774+
bootstrap: null,
1775+
active: false,
1776+
errorCode: String(error.code || error.message || 'SKILL_ROUTE_BOOTSTRAP_FAILED')
1777+
}
1778+
}
1779+
}
17121780
saveState(state)
17131781
writeStdout(noopOutput())
17141782
return

‎hooks/_runtime/skill-route-state.cjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,9 @@ function bootstrapSkillRoute (input, options = {}) {
554554
turnBinding,
555555
contextEpoch
556556
})
557-
const explicitSkillId = parseExplicitSkillId(input.prompt)
557+
const explicitSkillId = Object.prototype.hasOwnProperty.call(input, 'explicitSkillId')
558+
? (String(input.explicitSkillId || '').trim() || null)
559+
: parseExplicitSkillId(input.prompt)
558560
const explicitEntry = explicitSkillId
559561
? index.entries.find(entry => entry.skillId === explicitSkillId && entry.lifecycle === 'green')
560562
: null

‎hooks/_runtime/skill-route-tool.cjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,9 @@ function bootstrapSkillRouteForTurn (input, options = {}) {
12541254
activeRoot: target.activeRoot,
12551255
contextEpoch: input.contextEpoch,
12561256
prompt: input.prompt,
1257+
...(Object.prototype.hasOwnProperty.call(input, 'explicitSkillId')
1258+
? { explicitSkillId: input.explicitSkillId }
1259+
: {}),
12571260
mode: modeReceipt.effective,
12581261
modeReceipt,
12591262
runtimeContractDigest: modeReceipt.runtimeContractDigest,

‎scripts/fixtures/skill-route-closure-trace.v1.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"S10": ["C06"],
3535
"S11": ["P02"],
3636
"S12": ["C07", "P10", "M05"],
37-
"S13": ["M02", "M05", "M08a"],
37+
"S13": ["M02", "M05", "M08a", "M08b"],
3838
"S14": ["M01", "M03"],
3939
"S15": ["M05"],
4040
"S16": ["C01", "C03"],
@@ -91,6 +91,7 @@
9191
"M07": {"owner": "README.md", "anchor": "global-adapters apply"},
9292
"M08": {"owner": "scripts/test-skill-route-state.js", "anchor": "MODE_CAPABILITY_STALE"},
9393
"M08a": {"owner": "scripts/test-skill-route-lifecycle.js", "anchor": "MODE_CAPABILITY_STALE"},
94+
"M08b": {"owner": "scripts/lib/test-hooks-runtime-bootstrap-layout.js", "anchor": "pending-project-rebind"},
9495
"M09": {"owner": "scripts/test-global-install-smoke.js", "anchor": "host-native .agents/skills root"},
9596
"M10": {"owner": "scripts/test-skill-route-state.js", "anchor": "Acceptance T01a / T07 / M10"}
9697
}

0 commit comments

Comments
 (0)