Skip to content

Commit 6fa3569

Browse files
committed
fix: reduce binding mismatch, memory agent ambiguity, and ledger noise
Rebind skill_route trusted context from plan observation when lifecycle lags MCP replan. Prefer runtime-detected agent among multi-client memory roots and pin DEVCODEX_AGENT on managed MCP configs. Demote completed sessions from activeSessionIds, repair corrupted unreleased bullet, and refresh runtime contract digest.
1 parent 46274c4 commit 6fa3569

7 files changed

Lines changed: 165 additions & 28 deletions

File tree

‎changelogs/unreleased.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@
2323
- **P0-1 Batch2 历史实现(已由统一路由替代)**:`init`/`ensureRuntimeDirs` 写入 DEVCODEX.md 模板(不覆盖);旧 intent CLI 已退役。
2424
- **P0-1 ECR 计数同步**:新增 `workspace-skill-author` 后 Skill 规模 84→**85**(82 active + 3 gray);重生 `skills/portfolio.json`;同步 test-skill-portfolio / docs-surface-inventory / V92/V95 / README / website 规模锚点。
2525
- **联系邮箱统一为 `rockyshi1993@gmail.com`**:`package.json` author 与 `SECURITY.md` 漏洞报告邮箱一致(唯一可用邮箱)。
26-
- **npm 包名为未 scope 的 `devcodex`**:安装入口 `npm install -g devcodex` / `npm update -g devcodex`;GitHub 组织仓与 npm 模块身份分离;`package.json` name=`devcodex`;撤销 `devcodex` 误绑。
27-
pm install|update -g 对齐 devcodex; alidate-profile 识别 scoped 包名;修复正则字面量中 / 截断;managed deployment-manifest package 字段同步。
26+
- **npm 包名为未 scope 的 `devcodex`**:安装入口 `npm install -g devcodex` / `npm update -g devcodex`;GitHub 组织仓与 npm 模块身份分离;`package.json` name=`devcodex`;撤销 `devcodex` 误绑。文档与命令示例中 `npm install|update -g` 对齐 `devcodex`;`validate-profile` 识别 scoped 包名;修复正则字面量中 `/` 截断;managed deployment-manifest package 字段同步。
2827
- **目录与包身份统一为 devcodex(本地/公开)**:源码目录与 active-root 由 devcodex-v1 对齐为 devcodex;npm 包 devcodex、仓库 devcodex-labs/devcodex、npmjs public 发布配置与 probe/fixtures/Skill 示例同步;isSourceRepo 识别新包名;历史 releases 台账不回溯改写。
2928

3029
- **维护者站点×清单一致性(Fix-1 分母重冻)**:`docs-surface-inventory` npm scripts 下限 **≥113**、scripts/lib **≥102**;基线 `scripts/fixtures/docs-surface-baseline-20260727.json`;intro 能力证据表补 scripts/lib/validation/delivery-honesty 与五宿主 L0 摘要表。需求:`maintainer-site-docs-consistency`。

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"status": "PASS",
1414
"testedVersion": "codex-cli 0.145.0",
1515
"protocol": "MCP 2024-11-05",
16-
"runtimeContractDigest": "1c727701a1ae4f3a8699a932066994098402744b99ed81ba2ec32f1929bf5d6a",
16+
"runtimeContractDigest": "8e6fd2aa9f8dd4f865ff76b670f3f4a2f61b8ccd4c37546d84579c5d6efd8b41",
1717
"hostAdapterDigest": "6ba881f7fd7eba6297e1fcb8c55da09abbd5f47a42661fb2269f38d05e014139",
1818
"evidenceDigest": "ce8fb9c145934bb59f2ce1f5d253a21fb9ba33218a728d4b23b7a0c45fe2cc53",
1919
"evidenceRef": "E:/Worker/devcodex/.audit-state/content-root-s15-codex-production-20260730.json",

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

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,95 @@ function readJson (file, fsImpl = fs) {
134134
}
135135
}
136136

137+
function writeJsonAtomic (file, value, fsImpl = fs) {
138+
const dir = path.dirname(file)
139+
fsImpl.mkdirSync(dir, { recursive: true })
140+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
141+
fsImpl.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
142+
fsImpl.renameSync(tmp, file)
143+
}
144+
145+
/**
146+
* When MCP plan observation advanced but Hook PostToolUse never installed the
147+
* plan into lifecycle-state, skill_route would see MISMATCH/stale against the
148+
* caller's contextBinding. Rebind from the exact observation when identities match.
149+
*/
150+
function tryRebindLifecycleFromPlanObservation (binding, target, lifecycle, options = {}) {
151+
const fsImpl = options.fs || fs
152+
const acquisition = lifecycle?.contextAcquisition
153+
if (!acquisition || typeof acquisition !== 'object') return { rebound: false, lifecycle }
154+
155+
const plan = acquisition.plan
156+
const receipt = acquisition.receipt
157+
const identityMatches = !!(
158+
plan &&
159+
receipt &&
160+
binding.contextEpoch === acquisition.contextEpoch &&
161+
binding.contextEpoch === plan.identity?.contextEpoch &&
162+
binding.planId === plan.planId &&
163+
binding.planContentId === plan.planContentId &&
164+
path.resolve(binding.activeRoot) === path.resolve(target.activeRoot) &&
165+
binding.project === target.project &&
166+
receipt.contextEpoch === binding.contextEpoch &&
167+
receipt.planId === binding.planId &&
168+
receipt.planContentId === binding.planContentId
169+
)
170+
if (identityMatches && ACCEPTED_CONTEXT_RECEIPT_STATUSES.has(receipt.status)) {
171+
return { rebound: false, lifecycle }
172+
}
173+
174+
let readContextPlanObservation
175+
let createContextReadReceipt
176+
let validateContextReadPlan
177+
try {
178+
;({ readContextPlanObservation } = require('./context-plan-observation.cjs'))
179+
;({ createContextReadReceipt, validateContextReadPlan } = require('./context-read-contract.cjs'))
180+
} catch {
181+
return { rebound: false, lifecycle }
182+
}
183+
184+
const observed = readContextPlanObservation({
185+
activeRoot: target.activeRoot,
186+
project: target.project,
187+
contextEpoch: binding.contextEpoch
188+
})
189+
if (observed.status !== 'fresh' || !observed.plan) return { rebound: false, lifecycle }
190+
if (observed.plan.planId !== binding.planId || observed.plan.planContentId !== binding.planContentId) {
191+
return { rebound: false, lifecycle }
192+
}
193+
const validation = validateContextReadPlan(observed.plan)
194+
if (!validation.valid) return { rebound: false, lifecycle }
195+
196+
const priorReceipt = acquisition.receipt
197+
const nextReceipt = createContextReadReceipt(observed.plan, {
198+
verificationMode: 'structured-plan',
199+
planObserved: true,
200+
hostSessionId: acquisition.hostSessionId
201+
})
202+
if (priorReceipt && Number.isFinite(Number(priorReceipt.replanCount))) {
203+
nextReceipt.replanCount = Number(priorReceipt.replanCount)
204+
}
205+
206+
acquisition.plan = observed.plan
207+
acquisition.receipt = nextReceipt
208+
acquisition.contextEpoch = binding.contextEpoch
209+
acquisition.activeRoot = path.resolve(target.activeRoot).replace(/\\/g, '/')
210+
acquisition.project = target.project
211+
acquisition.targetResolved = true
212+
acquisition.fallbackActive = false
213+
acquisition.lastError = null
214+
acquisition.verificationMode = 'structured-plan'
215+
216+
lifecycle.contextAcquisition = acquisition
217+
const statePath = lifecycleStatePath(target)
218+
try {
219+
writeJsonAtomic(statePath, lifecycle, fsImpl)
220+
} catch {
221+
// Still use in-memory rebinding for this call even if persist fails.
222+
}
223+
return { rebound: true, lifecycle }
224+
}
225+
137226
function validateTrustedContextBinding (binding, target, options = {}) {
138227
if (options.trustedContext) return options.trustedContext
139228
if (!binding ||
@@ -152,7 +241,14 @@ function validateTrustedContextBinding (binding, target, options = {}) {
152241
throw error
153242
}
154243
const statePath = lifecycleStatePath(target)
155-
const lifecycle = readJson(statePath, options.fs || fs)
244+
let lifecycle = readJson(statePath, options.fs || fs)
245+
if (!lifecycle || typeof lifecycle !== 'object') lifecycle = {}
246+
if (!lifecycle.contextAcquisition || typeof lifecycle.contextAcquisition !== 'object') {
247+
lifecycle.contextAcquisition = {}
248+
}
249+
250+
const rebound = tryRebindLifecycleFromPlanObservation(binding, target, lifecycle, options)
251+
lifecycle = rebound.lifecycle
156252
const acquisition = lifecycle?.contextAcquisition
157253
const plan = acquisition?.plan
158254
const receipt = acquisition?.receipt
@@ -199,7 +295,8 @@ function validateTrustedContextBinding (binding, target, options = {}) {
199295
receiptId: receipt.receiptId,
200296
receiptStatus: receipt.status,
201297
hostSessionId: String(acquisition.hostSessionId || ''),
202-
statePath: portable(statePath)
298+
statePath: portable(statePath),
299+
reboundFromObservation: rebound.rebound === true
203300
}
204301
value.bindingDigest = sha256(value)
205302
return value

‎mcp/memory-server.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,12 @@ function resolveMemoryAgent(agent, activeRoot) {
881881
}
882882
if (candidates.length === 1) return candidates[0]
883883
if (candidates.length > 1) {
884+
// Prefer runtime-detected host when multi-client dirs exist (common in monorepos).
885+
// Only fail closed when inference is unknown or not among candidates.
886+
const inferred = normalizeAgent(DEFAULT_AGENT) || detectRuntimeAgent()
887+
if (inferred && inferred !== 'unknown-agent' && candidates.includes(inferred)) {
888+
return inferred
889+
}
884890
throw memoryQueryError(
885891
`memory agent is ambiguous; available clients: ${candidates.join(', ')}.`,
886892
'Pass the current actual host in agent.',

‎scripts/lib/global-host-config.js‎

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -240,45 +240,64 @@ function copilotHookDocument(runtimeFile) {
240240
}
241241
}
242242

243-
function buildMcpServers(runtimeRoot) {
243+
/**
244+
* Map global host id → memory/profile agent identity (VALID_AGENTS).
245+
* Claude host root is `.claude` but agent id is `claude-code`.
246+
*/
247+
function hostToRuntimeAgent (host) {
248+
const normalized = String(host || '').trim().toLowerCase()
249+
if (normalized === 'claude') return 'claude-code'
250+
if (normalized === 'copilot' || normalized === 'codex' || normalized === 'grok') return normalized
251+
// gemini / unknown: leave unset so detectRuntimeAgent can still apply
252+
return ''
253+
}
254+
255+
function buildMcpServers(runtimeRoot, options = {}) {
256+
const agent = hostToRuntimeAgent(options.agent || options.host)
257+
const env = agent ? { DEVCODEX_AGENT: agent } : undefined
258+
const base = {
259+
type: 'stdio',
260+
command: 'node'
261+
}
244262
return {
245263
'devcodex-memory': {
246-
type: 'stdio',
247-
command: 'node',
248-
args: [portable(path.join(runtimeRoot, 'mcp', 'memory-server.js')), '.']
264+
...base,
265+
args: [portable(path.join(runtimeRoot, 'mcp', 'memory-server.js')), '.'],
266+
...(env ? { env } : {})
249267
},
250268
'devcodex-profile': {
251-
type: 'stdio',
252-
command: 'node',
253-
args: [portable(path.join(runtimeRoot, 'mcp', 'profile-server.js')), '.']
269+
...base,
270+
args: [portable(path.join(runtimeRoot, 'mcp', 'profile-server.js')), '.'],
271+
...(env ? { env } : {})
254272
}
255273
}
256274
}
257275

258-
function buildCopilotMcpServers(runtimeRoot) {
276+
function buildCopilotMcpServers(runtimeRoot, options = {}) {
259277
return Object.fromEntries(
260-
Object.entries(buildMcpServers(runtimeRoot)).map(([name, server]) => [
278+
Object.entries(buildMcpServers(runtimeRoot, options)).map(([name, server]) => [
261279
name,
262280
{
263281
type: 'local',
264282
command: server.command,
265283
args: server.args,
266-
env: {},
284+
env: server.env && typeof server.env === 'object' ? { ...server.env } : {},
267285
tools: ['*']
268286
}
269287
])
270288
)
271289
}
272290

273291
/** VS Code user mcp.json "servers" entries (stdio). */
274-
function buildVscodeMcpServers (runtimeRoot) {
292+
function buildVscodeMcpServers (runtimeRoot, options = {}) {
275293
return Object.fromEntries(
276-
Object.entries(buildMcpServers(runtimeRoot)).map(([name, server]) => [
294+
Object.entries(buildMcpServers(runtimeRoot, { agent: options.agent || options.host || 'copilot' })).map(([name, server]) => [
277295
name,
278296
{
279297
type: 'stdio',
280298
command: server.command,
281-
args: server.args
299+
args: server.args,
300+
...(server.env ? { env: server.env } : {})
282301
}
283302
])
284303
)
@@ -287,7 +306,7 @@ function buildVscodeMcpServers (runtimeRoot) {
287306
/**
288307
* Merge DevCodex servers into VS Code User mcp.json without wiping inputs / other servers.
289308
*/
290-
function mergeVscodeUserMcpContent (existingText, runtimeRoot) {
309+
function mergeVscodeUserMcpContent (existingText, runtimeRoot, options = {}) {
291310
let doc = {}
292311
try {
293312
doc = parseJsonObject(existingText, 'VS Code user mcp.json')
@@ -297,7 +316,7 @@ function mergeVscodeUserMcpContent (existingText, runtimeRoot) {
297316
if (!doc.servers || typeof doc.servers !== 'object' || Array.isArray(doc.servers)) {
298317
doc.servers = {}
299318
}
300-
const managed = buildVscodeMcpServers(runtimeRoot)
319+
const managed = buildVscodeMcpServers(runtimeRoot, options)
301320
for (const [name, server] of Object.entries(managed)) {
302321
doc.servers[name] = server
303322
}
@@ -332,17 +351,21 @@ function codexMcpTableNames () {
332351
}
333352

334353
function codexTomlBlock(target) {
335-
const servers = buildMcpServers(target.runtimeRoot)
354+
const servers = buildMcpServers(target.runtimeRoot, { host: target.host || 'codex' })
355+
const agent = hostToRuntimeAgent(target.host || 'codex')
356+
const envLine = agent ? `env = { DEVCODEX_AGENT = ${quoteToml(agent)} }` : null
336357
const lines = [
337358
'# Managed by npm install/update -g devcodex.',
338359
'[mcp_servers.devcodex-memory]',
339360
'command = "node"',
340361
`args = [${servers['devcodex-memory'].args.map(quoteToml).join(', ')}]`,
362+
...(envLine ? [envLine] : []),
341363
'startup_timeout_sec = 30',
342364
'',
343365
'[mcp_servers.devcodex-profile]',
344366
'command = "node"',
345367
`args = [${servers['devcodex-profile'].args.map(quoteToml).join(', ')}]`,
368+
...(envLine ? [envLine] : []),
346369
'startup_timeout_sec = 30'
347370
]
348371
for (const [server, tools] of Object.entries(CODEX_MCP_APPROVED_TOOLS)) {
@@ -393,7 +416,7 @@ function addCopilotPlan(operations, target, packageRoot, fsImpl) {
393416
'json',
394417
`${JSON.stringify(hooks, null, 2)}\n`
395418
)
396-
const mcp = { mcpServers: buildCopilotMcpServers(target.runtimeRoot) }
419+
const mcp = { mcpServers: buildCopilotMcpServers(target.runtimeRoot, { host: 'copilot' }) }
397420
addFileOperation(
398421
operations,
399422
target.host,
@@ -416,7 +439,7 @@ function addCopilotPlan(operations, target, packageRoot, fsImpl) {
416439
if (seenVscode.has(key)) continue
417440
seenVscode.add(key)
418441
const existing = readText(vscodeMcpPath, fsImpl)
419-
const merged = mergeVscodeUserMcpContent(existing, target.runtimeRoot)
442+
const merged = mergeVscodeUserMcpContent(existing, target.runtimeRoot, { host: 'copilot' })
420443
addFileOperation(
421444
operations,
422445
target.host,
@@ -464,11 +487,11 @@ function addClaudePlan(operations, target, packageRoot, fsImpl) {
464487
target.files.mcp,
465488
mergeHostJsonContent(
466489
readText(target.files.mcp, fsImpl),
467-
{ mcpServers: buildMcpServers(target.runtimeRoot) },
490+
{ mcpServers: buildMcpServers(target.runtimeRoot, { host: 'claude' }) },
468491
'Claude user MCP'
469492
),
470493
'json',
471-
`${JSON.stringify({ mcpServers: buildMcpServers(target.runtimeRoot) }, null, 2)}\n`
494+
`${JSON.stringify({ mcpServers: buildMcpServers(target.runtimeRoot, { host: 'claude' }) }, null, 2)}\n`
472495
)
473496
}
474497

@@ -531,7 +554,7 @@ function addGeminiPlan(operations, target, packageRoot, fsImpl) {
531554
managedInstruction('', source, 'gemini')
532555
)
533556
const settings = transformedHookTemplate(packageRoot, target, path.join('gemini', 'settings.json'), 'gemini', fsImpl)
534-
settings.mcpServers = buildMcpServers(target.runtimeRoot)
557+
settings.mcpServers = buildMcpServers(target.runtimeRoot, { host: 'gemini' })
535558
addFileOperation(
536559
operations,
537560
target.host,
@@ -584,7 +607,7 @@ function addGrokPlan(operations, target, packageRoot, fsImpl) {
584607
operations,
585608
target.host,
586609
path.join(target.files.plugin, '.mcp.json'),
587-
`${JSON.stringify({ mcpServers: buildMcpServers(target.runtimeRoot) }, null, 2)}\n`,
610+
`${JSON.stringify({ mcpServers: buildMcpServers(target.runtimeRoot, { host: 'grok' }) }, null, 2)}\n`,
588611
'json'
589612
)
590613

@@ -1628,6 +1651,7 @@ module.exports = {
16281651
buildCopilotMcpServers,
16291652
buildMcpServers,
16301653
buildVscodeMcpServers,
1654+
hostToRuntimeAgent,
16311655
mergeVscodeUserMcpContent,
16321656
copilotHookDocument,
16331657
digestPlan,

‎scripts/lib/memory-index.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,17 @@ function summaryConflicts(rows) {
146146
}
147147

148148
function latestUniqueActiveSessionIds(rows) {
149+
// Prefer completed truth for the same day#sessionId so a later ✅ row
150+
// does not leave a stale 🔄 row forever in activeSessionIds.
151+
const completedKeys = new Set(
152+
rows
153+
.filter(row => row.state === 'completed' && row.sessionIdCanonical)
154+
.map(row => `${row.day}#${row.sessionId}`)
155+
)
149156
return [...new Set(rows.slice().reverse()
150157
.filter(row => row.state === 'active' && row.sessionIdCanonical)
151-
.map(row => `${row.day}#${row.sessionId}`))]
158+
.map(row => `${row.day}#${row.sessionId}`)
159+
.filter(key => !completedKeys.has(key)))]
152160
}
153161

154162
function buildSummaryPartitions(document, parsed) {

‎scripts/test-global-host-config.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,14 @@ for (const name of ['devcodex-memory', 'devcodex-profile']) {
330330
assert.strictEqual(copilotMcp.mcpServers[name].type, 'local')
331331
assert.deepStrictEqual(copilotMcp.mcpServers[name].tools, ['*'])
332332
assert.ok(copilotMcp.mcpServers[name].args.some(value => /mcp\/(?:memory|profile)-server\.js$/i.test(value)))
333+
assert.strictEqual(copilotMcp.mcpServers[name].env?.DEVCODEX_AGENT, 'copilot')
333334
}
334335

335336
const geminiTarget = targets.find(target => target.host === 'gemini')
336337
const geminiSettings = JSON.parse(fs.readFileSync(geminiTarget.files.settings, 'utf8'))
337338
for (const name of ['devcodex-memory', 'devcodex-profile']) {
338339
assert.strictEqual(geminiSettings.mcpServers[name].command, 'node')
340+
// gemini has no VALID_AGENTS mapping; do not inject DEVCODEX_AGENT
339341
assert.deepStrictEqual(
340342
Object.keys(geminiSettings.mcpServers[name]).sort(),
341343
['args', 'command', 'type']
@@ -357,6 +359,7 @@ assert.ok(!JSON.stringify(claudeSettings.hooks).includes('.claude/hooks/_runtime
357359
const codexConfig = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8')
358360
assert.ok(codexConfig.includes('model = "user-choice"'))
359361
assert.ok(codexConfig.includes('BEGIN DEVCODEX MANAGED: global-codex-mcp'))
362+
assert.ok(codexConfig.includes('DEVCODEX_AGENT') && codexConfig.includes('codex'))
360363
assert.ok(codexConfig.includes('[mcp_servers.user-keep]'))
361364
assert.ok(!codexConfig.includes('old-runtime.js'))
362365
assert.ok(!codexConfig.includes('BEGIN DEVCODEX-MCP-MANAGED'))

0 commit comments

Comments
 (0)