Skip to content

Commit 53ab3ff

Browse files
committed
fix: allow explicit profile directories
1 parent 639ae70 commit 53ab3ff

7 files changed

Lines changed: 116 additions & 13 deletions

File tree

‎changelogs/unreleased.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
## 当前未发布实现候选
77

8+
- **显式 `init --profile` 目录直通**:手动指定的精确 workspace namespace 只校验安全边界、存在性与目录类型,不再要求 `.git`、`package.json` 等自动发现标记;空目录或无标记目录初始化后以物理目录加 canonical Profile 作为稳定绑定证据。单叶别名、自动发现、歧义、reserved、traversal、workspace escape 与 runtime-only 拒绝边界保持不变。
89
- **v1.19.3 归档候选 — 确认持久化与正式任务 owner P0 修复**:TaskRecoveryStoreV5 在无 formal task、无 operational lease 时仍以有界、摘要绑定和 TTL 校验的恢复记录保留 actual instruction、work-item、route、plan 与 project lease;状态超限时仅保留 identity-only 且明确撤销 authority,避免假恢复。生命周期 semantic no-op 仍刷新 live projection,确认后的读取/工具事件不再清空 ingress。`memory_cp_confirm` 生成的安全 Markdown artifactPath 投影现与 task owner reader 共用同一任务根、路径防穿越、稳定文件身份和 SHA-256 复核契约。新增 `npm run test:confirmation-persistence` 作为 30 秒内 P0 专项快测。完整说明见 [`changelogs/releases/v1.19.3.md`](./releases/v1.19.3.md)。
910
- **v1.19.2 归档候选 — 宿主权限归属与多呈现面文件打开修复**:`PreToolUse/PermissionRequest` 在 lifecycle、输出 builder 与 Codex/Claude/Gemini/Copilot/Grok/Cursor adapter 三层统一为 advisory-only,剥离遗留 `allow/deny/ask/block/continue:false`,DevCodex 继续记录风险与 typed workflow-invalid,但不再形成任何操作权限判断。新增 `HostLinkCapabilityDecisionV2`,把 `hostSurface` 与 `presentationSurface` 分开,为 Codex Desktop、VS Code、Zed、WebStorm、Codex CLI、Claude/unknown 选择经证据绑定的 native action、终端命令或绝对路径 copy fallback;持久化记忆链接继续保留 `LinkCapabilityDecisionV1`。完整说明见 [`changelogs/releases/v1.19.2.md`](./releases/v1.19.2.md)。
1011
- **正式任务跨根续接与 Skill 精确读取(PI-306~PI-308 / PF-373~PF-375 / GR-096~GR-098)**:TaskIdentity 首次 root digest 仅作 provenance,Admission/Lifecycle/Stop/MCP takeover 共用 portable binding decision;共享决定先验证不可变 V2 identity/digest,损坏身份失败关闭。新根重新取得 live authority,旧 root 热槽和 BudgetCard/owner/lease 不迁移且不删除。宿主 Skill 隐私规则只放行精确 `SKILL.md` 文件,skills 根目录 list/glob/recurse 仍阻断。跨根 admission/Stop/MCP、tampered identity、真实 D 盘 session remap、Codex system Skill 与危险命令分类定向回归均 PASS。

‎hooks/_runtime/lifecycle-project-target.cjs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,16 @@ function buildLifecycleProjectTargetUtils({
195195
return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved
196196
}
197197

198-
function readPhysicalMarkerIdentity(projectRoot) {
199-
const markerName = (PROJECT_ROOT_MARKERS || []).find(name => fs.existsSync(path.join(projectRoot, name)))
200-
if (!markerName) return null
201-
const markerPath = path.join(projectRoot, markerName)
198+
function readPhysicalMarkerIdentity(projectRoot, runtimeRoot) {
199+
let markerName = (PROJECT_ROOT_MARKERS || []).find(name => fs.existsSync(path.join(projectRoot, name)))
200+
let markerPath = markerName ? path.join(projectRoot, markerName) : ''
201+
if (!markerName) {
202+
markerName = 'canonical-profile'
203+
markerPath = path.join(runtimeRoot, 'profile')
204+
}
202205
let stat
203206
try { stat = fs.statSync(markerPath) } catch { return null }
207+
if (markerName === 'canonical-profile' && !stat.isDirectory()) return null
204208
return {
205209
markerName,
206210
markerPath: normalizedIdentityPath(markerPath),
@@ -242,7 +246,7 @@ function buildLifecycleProjectTargetUtils({
242246
runtimeRoot: path.join(physicalRoot, '.devcodex')
243247
}
244248
}
245-
const physicalMarker = readPhysicalMarkerIdentity(resolved.projectRoot)
249+
const physicalMarker = readPhysicalMarkerIdentity(resolved.projectRoot, resolved.runtimeRoot)
246250
if (!physicalMarker) return null
247251
const layoutIdentity = currentLayoutIdentity()
248252
const physicalRoot = path.resolve(resolved.projectRoot)

‎hooks/_runtime/workspace-layout.cjs‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,21 @@ function hasProjectRootMarker(dir) {
225225
return PROJECT_ROOT_MARKERS.some(marker => fs.existsSync(path.join(dir, marker)))
226226
}
227227

228+
function isDirectoryPath(targetPath) {
229+
try { return fs.statSync(targetPath).isDirectory() } catch { return false }
230+
}
231+
232+
function hasProvisionedProjectProfile(workspaceRoot, namespaceValue) {
233+
return isDirectoryPath(path.join(namespaceRootPath(workspaceRoot, namespaceValue), 'profile'))
234+
}
235+
236+
function hasWorkspaceProjectEvidence(workspaceRoot, namespaceValue, projectRoot) {
237+
return hasProjectRootMarker(projectRoot) || hasProvisionedProjectProfile(workspaceRoot, namespaceValue)
238+
}
239+
228240
/** Discover physical projects only; runtime history is a separate concern. */
229-
function collectActiveWorkspaceProjectNamespaces(workspaceRoot, { maxDepth = 4 } = {}) {
241+
function collectActiveWorkspaceProjectNamespaces(workspaceRoot, { maxDepth = 4, runtimeMaxDepth = 8 } = {}) {
242+
const root = path.resolve(workspaceRoot)
230243
const namespaces = new Set()
231244

232245
function scanWorkspaceDirs(root, relativeSegments = [], depth = maxDepth) {
@@ -256,7 +269,16 @@ function collectActiveWorkspaceProjectNamespaces(workspaceRoot, { maxDepth = 4 }
256269
}
257270
}
258271

259-
scanWorkspaceDirs(workspaceRoot)
272+
scanWorkspaceDirs(root)
273+
// A manually provisioned directory may intentionally have no conventional
274+
// project marker. Rehydrate only canonical Profile namespaces whose physical
275+
// directory still exists; do not broaden automatic discovery to every folder.
276+
for (const namespace of collectWorkspaceRuntimeNamespaces(root, { maxDepth: runtimeMaxDepth })) {
277+
const physical = resolvePhysicalNamespace(root, namespace)
278+
if (physical && hasProvisionedProjectProfile(root, physical.namespace)) {
279+
namespaces.add(physical.namespace)
280+
}
281+
}
260282
return [...namespaces].sort((left, right) => left.localeCompare(right))
261283
}
262284

@@ -322,14 +344,27 @@ function resolveWorkspaceProjectTarget(workspaceRoot, requestedTarget, options =
322344
const layout = { enabled: true, workspaceRoot: root }
323345
const requested = normalizeProjectNamespace(requestedTarget, { layout, allowEmpty: false })
324346
const physical = resolvePhysicalNamespace(root, requested)
325-
if (physical && hasProjectRootMarker(physical.projectRoot)) {
347+
const allowExistingDirectory = options.allowExistingDirectory === true
348+
if (physical && (
349+
allowExistingDirectory ||
350+
hasWorkspaceProjectEvidence(root, physical.namespace, physical.projectRoot)
351+
)) {
326352
return {
327353
namespace: physical.namespace,
328354
projectRoot: physical.projectRoot,
329355
runtimeRoot: namespaceRootPath(root, physical.namespace),
330356
candidates: [physical.namespace]
331357
}
332358
}
359+
if (allowExistingDirectory) {
360+
const requestedPath = path.join(root, ...splitNamespace(requested))
361+
if (fs.existsSync(requestedPath)) {
362+
const error = new Error(`project target is not a supported workspace directory: ${requested}`)
363+
error.code = 'PROFILE_TARGET_DIRECTORY_MISSING'
364+
error.candidates = []
365+
throw error
366+
}
367+
}
333368

334369
const candidates = collectActiveWorkspaceProjectNamespaces(root, options)
335370
const folded = value => String(value || '').toLocaleLowerCase('en-US')
@@ -361,8 +396,8 @@ function resolveWorkspaceProjectTarget(workspaceRoot, requestedTarget, options =
361396
error.candidates = matches
362397
throw error
363398
}
364-
if (!hasProjectRootMarker(projectRoot)) {
365-
const error = new Error(`project target has no project marker: ${namespace}`)
399+
if (!hasWorkspaceProjectEvidence(root, namespace, projectRoot)) {
400+
const error = new Error(`project target has no project marker or canonical Profile: ${namespace}`)
366401
error.code = 'PROFILE_TARGET_NOT_FOUND'
367402
error.candidates = candidates
368403
throw error

‎scripts/lib/cli-workspace-init-command.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ function buildWorkspaceInitCommand(ctx) {
3838
try {
3939
const existingLayout = typeof findLayoutInfo === 'function' ? findLayoutInfo(cwd) : null
4040
const workspaceRoot = existingLayout?.enabled ? existingLayout.workspaceRoot : cwd
41-
profileTarget = resolveWorkspaceProjectTarget(workspaceRoot, parsed.profileTarget)
41+
profileTarget = resolveWorkspaceProjectTarget(workspaceRoot, parsed.profileTarget, {
42+
allowExistingDirectory: true
43+
})
4244
} catch (error) {
4345
return initArgumentFailure(
4446
refresh ? 'update' : 'init',

‎scripts/lib/test-hooks-runtime-bootstrap-layout.js‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,34 @@ function runHooksRuntimeBootstrapLayoutScenarios(context) {
15651565
assert.strictEqual(missingProfileBinding.status, 'profile-missing')
15661566
assert.strictEqual(missingProfileBinding.error.code, 'PROFILE_MISSING')
15671567

1568+
const profileBackedRoot = path.join(TEMP_ROOT, 'blank')
1569+
fs.mkdirSync(profileBackedRoot, { recursive: true })
1570+
fs.mkdirSync(path.join(TEMP_ROOT, '.devcodex', 'blank', 'profile'), { recursive: true })
1571+
fs.writeFileSync(
1572+
path.join(TEMP_ROOT, '.devcodex', 'blank', 'profile', 'config.json'),
1573+
JSON.stringify({ mode: 'dev', agent: TEST_AGENT })
1574+
)
1575+
const profileBackedBinding = resolveHostWorkspaceBinding({
1576+
cwd: profileBackedRoot,
1577+
layout: bindingLayout,
1578+
requireProfile: true,
1579+
allowUniqueProject: false
1580+
})
1581+
assert.strictEqual(profileBackedBinding.status, 'resolved')
1582+
assert.strictEqual(profileBackedBinding.projectNamespace, 'blank')
1583+
assert.strictEqual(profileBackedBinding.source, 'bridge-cwd')
1584+
1585+
const profileBackedLifecycle = run({
1586+
hookEventName: 'UserPromptSubmit',
1587+
session_id: 'profile-backed-empty-session',
1588+
prompt: '检查 blank 项目'
1589+
})
1590+
assert.strictEqual(profileBackedLifecycle.continue, true)
1591+
const profileBackedState = JSON.parse(fs.readFileSync(getWorkspaceLayoutStateFile(), 'utf8'))
1592+
assert.strictEqual(profileBackedState.activeProject, 'blank')
1593+
assert.strictEqual(profileBackedState.stickyProject.physicalMarker.markerName, 'canonical-profile')
1594+
assert.strictEqual(profileBackedState.stickyProject.physicalMarker.kind, 'directory')
1595+
15681596
fs.mkdirSync(path.join(TEMP_ROOT, 'apps', 'app-a'), { recursive: true })
15691597
fs.mkdirSync(path.join(TEMP_ROOT, 'services', 'app-a'), { recursive: true })
15701598
fs.writeFileSync(path.join(TEMP_ROOT, 'apps', 'app-a', 'package.json'), '{}')

‎scripts/test-cli-behavior.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const {
2121
const {
2222
commitTaskRecoveryState
2323
} = require('../hooks/_runtime/task-recovery-store-v5.cjs')
24+
const {
25+
collectActiveWorkspaceProjectNamespaces,
26+
resolveWorkspaceProjectTarget
27+
} = require('../hooks/_runtime/workspace-layout.cjs')
2428

2529
const ROOT = path.resolve(__dirname, '..')
2630
const CLI = path.join(ROOT, 'index.js')
@@ -596,6 +600,10 @@ function testInitBootstrapsWorkspaceProfileAndOneNamedProject() {
596600
const ambiguous = JSON.parse(runCliFailure(['init', '--profile', 'api', '--json'], ambiguousRoot))
597601
assert.strictEqual(ambiguous.errorCode, 'PROFILE_TARGET_AMBIGUOUS')
598602
assert.ok(!fs.existsSync(path.join(ambiguousRoot, '.devcodex')), 'ambiguous project target must not write runtime or Profile files')
603+
fs.mkdirSync(path.join(ambiguousRoot, 'api'), { recursive: true })
604+
const exact = JSON.parse(runCli(['init', '--profile', 'api', '--json'], ambiguousRoot))
605+
assert.strictEqual(exact.payload.projectProfile.namespace, 'api', 'an exact existing directory must win over leaf aliases')
606+
assert.deepStrictEqual(fs.readdirSync(path.join(ambiguousRoot, 'api')), [], 'init must not write inside the selected empty directory')
599607
fs.rmSync(ambiguousRoot, { recursive: true, force: true })
600608
}
601609

@@ -604,6 +612,8 @@ function testExplicitProfileTargetsAndDryRunStayPhysicalAndZeroWrite() {
604612
writeFile(dryRunRoot, 'docs/package.json', '{ "name": "docs" }\n')
605613
writeFile(dryRunRoot, 'clients/acme/api/package.json', '{ "name": "deep-api" }\n')
606614
writeFile(dryRunRoot, 'dist/fake/package.json', '{ "name": "derived-fake" }\n')
615+
fs.mkdirSync(path.join(dryRunRoot, 'empty'), { recursive: true })
616+
writeFile(dryRunRoot, 'notes/context.txt', 'manual target without a project marker\n')
607617
const before = walk(dryRunRoot).map(file => path.relative(dryRunRoot, file)).sort()
608618
const dry = JSON.parse(runCli(['init', '--profile', 'docs', '--dry-run', '--json'], dryRunRoot))
609619
const after = walk(dryRunRoot).map(file => path.relative(dryRunRoot, file)).sort()
@@ -614,6 +624,18 @@ function testExplicitProfileTargetsAndDryRunStayPhysicalAndZeroWrite() {
614624
path.resolve(item.dest).startsWith(path.join(dryRunRoot, '.devcodex', 'docs', 'profile') + path.sep)
615625
), 'targeted dry-run actions must use the future workspace namespace path')
616626

627+
const empty = JSON.parse(runCli(['init', '--profile', 'empty', '--json'], dryRunRoot))
628+
assert.strictEqual(empty.payload.projectProfile.namespace, 'empty')
629+
assert.deepStrictEqual(fs.readdirSync(path.join(dryRunRoot, 'empty')), [], 'empty project directory must remain untouched')
630+
assert.ok(fs.existsSync(path.join(dryRunRoot, '.devcodex', 'empty', 'profile', 'README.md')))
631+
assert.ok(collectActiveWorkspaceProjectNamespaces(dryRunRoot).includes('empty'), 'canonical Profile must keep an empty physical project discoverable')
632+
assert.strictEqual(resolveWorkspaceProjectTarget(dryRunRoot, 'empty').namespace, 'empty')
633+
634+
const unmarked = JSON.parse(runCli(['init', '--profile', 'notes', '--json'], dryRunRoot))
635+
assert.strictEqual(unmarked.payload.projectProfile.namespace, 'notes')
636+
assert.strictEqual(fs.readFileSync(path.join(dryRunRoot, 'notes', 'context.txt'), 'utf8'), 'manual target without a project marker\n')
637+
assert.ok(!fs.existsSync(path.join(dryRunRoot, 'notes', '.devcodex')), 'unmarked project directory must not receive legacy runtime files')
638+
617639
const deep = JSON.parse(runCli(['init', '--profile', 'clients/acme/api', '--json'], dryRunRoot))
618640
assert.strictEqual(deep.payload.projectProfile.namespace, 'clients/acme/api')
619641
assert.ok(fs.existsSync(path.join(dryRunRoot, '.devcodex', 'clients', 'acme', 'api', 'profile', 'README.md')))
@@ -633,8 +655,19 @@ function testExplicitProfileTargetsAndDryRunStayPhysicalAndZeroWrite() {
633655
assert.strictEqual(derived.errorCode, 'PROFILE_TARGET_NOT_FOUND')
634656
assert.deepStrictEqual(derived.details.candidates, [])
635657
assert.ok(!fs.existsSync(path.join(derivedRoot, '.devcodex')))
658+
const exactDerived = JSON.parse(runCli(['init', '--profile', 'dist/fake', '--json'], derivedRoot))
659+
assert.strictEqual(exactDerived.payload.projectProfile.namespace, 'dist/fake')
660+
assert.ok(fs.existsSync(path.join(derivedRoot, '.devcodex', 'dist', 'fake', 'profile', 'README.md')))
661+
assert.ok(collectActiveWorkspaceProjectNamespaces(derivedRoot).includes('dist/fake'), 'explicit derived namespace must remain discoverable after provisioning')
636662
fs.rmSync(derivedRoot, { recursive: true, force: true })
637663

664+
const nonDirectoryRoot = createTempRoot('devcodex-cli-non-directory-target-')
665+
writeFile(nonDirectoryRoot, 'not-a-directory', 'file target\n')
666+
const nonDirectory = JSON.parse(runCliFailure(['init', '--profile', 'not-a-directory', '--json'], nonDirectoryRoot))
667+
assert.strictEqual(nonDirectory.errorCode, 'PROFILE_TARGET_DIRECTORY_MISSING')
668+
assert.ok(!fs.existsSync(path.join(nonDirectoryRoot, '.devcodex')), 'a file target must fail without runtime writes')
669+
fs.rmSync(nonDirectoryRoot, { recursive: true, force: true })
670+
638671
const runtimeOnlyRoot = createTempRoot('devcodex-cli-runtime-only-target-')
639672
writeFile(runtimeOnlyRoot, '.devcodex/history/profile/README.md', '# historical runtime only\n')
640673
const runtimeOnly = JSON.parse(runCliFailure(['init', '--profile', 'history', '--json'], runtimeOnlyRoot))

‎scripts/test-migrate-layout.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ function main() {
6868
assert.ok(fs.existsSync(path.join(TEMP_ROOT, '.devcodex', 'workspace', '.memory', 'SUMMARY.md')))
6969
assert.ok(!fs.existsSync(path.join(TEMP_ROOT, 'chat', '.devcodex')))
7070
assert.ok(!fs.existsSync(path.join(TEMP_ROOT, 'admin', '.devcodex')))
71-
assert.strictEqual(inferProjectFromCwd(path.join(TEMP_ROOT, 'chat')), '')
72-
assert.strictEqual(resolveActiveRuntimeRoot(path.join(TEMP_ROOT, 'chat')), path.join(TEMP_ROOT, '.devcodex', 'workspace'))
71+
assert.strictEqual(inferProjectFromCwd(path.join(TEMP_ROOT, 'chat')), 'chat')
72+
assert.strictEqual(resolveActiveRuntimeRoot(path.join(TEMP_ROOT, 'chat')), path.join(TEMP_ROOT, '.devcodex', 'chat'))
7373
assert.strictEqual(resolveActiveRuntimeRoot(TEMP_ROOT), path.join(TEMP_ROOT, '.devcodex', 'workspace'))
7474
assert.strictEqual(resolveGitignoreRoot(path.join(TEMP_ROOT, 'chat')), TEMP_ROOT)
7575

0 commit comments

Comments
 (0)