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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to @rpamis/comet will be documented in this file.

## What's Changed [0.4.0-beta.16] - 2026-08-05

### Fixed

- **Classic/OpenSpec coexistence**: Classic now uses only the configured artifact root, so a standalone OpenSpec project can keep the other root at the same time; explicit root migration still refuses to overwrite a non-empty destination.
- **Codex Native Hook parsing**: Raw `apply_patch` input now attributes Add, Update, Delete, and standard `+++ b/...` file headers so Native phase protection is applied consistently.

### Security

- **Dependency security updates**: Updated PostCSS, Undici, and brace-expansion to patched releases to address reported dependency vulnerabilities.

## What's Changed [0.4.0-beta.15] - 2026-08-05

### Added
Expand Down
11 changes: 8 additions & 3 deletions app/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,16 @@ async function checkClassicLayout(projectPath: string): Promise<CheckResult> {
};
}
if (inspection.dualRoots) {
const configuredRoot = path
.relative(projectPath, inspection.paths.openSpecRoot)
.replaceAll('\\', '/');
const alternateRoot = path
.relative(projectPath, inspection.alternateRoot)
.replaceAll('\\', '/');
return {
check: 'Classic artifact layout',
status: 'fail',
message:
'both openspec/ and docs/openspec/ exist; Classic writes are blocked until the conflict is resolved',
status: 'pass',
message: `${inspection.paths.artifactLayout}: configured ${configuredRoot}/ present; standalone OpenSpec root ${alternateRoot}/ also present and ignored by Comet`,
};
}
const configuredRoot = path
Expand Down
2 changes: 1 addition & 1 deletion assets/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.4.0-beta.15",
"version": "0.4.0-beta.16",
"skills": [
"comet/SKILL.md",
"comet-classic/SKILL.md",
Expand Down
86 changes: 43 additions & 43 deletions assets/skills/comet-native/scripts/comet-native-hook-guard.mjs

Large diffs are not rendered by default.

178 changes: 89 additions & 89 deletions assets/skills/comet-native/scripts/comet-native-runtime.mjs

Large diffs are not rendered by default.

130 changes: 65 additions & 65 deletions assets/skills/comet/scripts/comet-archive.mjs

Large diffs are not rendered by default.

190 changes: 95 additions & 95 deletions assets/skills/comet/scripts/comet-guard.mjs

Large diffs are not rendered by default.

134 changes: 67 additions & 67 deletions assets/skills/comet/scripts/comet-handoff.mjs

Large diffs are not rendered by default.

116 changes: 58 additions & 58 deletions assets/skills/comet/scripts/comet-hook-guard.mjs

Large diffs are not rendered by default.

108 changes: 54 additions & 54 deletions assets/skills/comet/scripts/comet-hook-router.mjs

Large diffs are not rendered by default.

120 changes: 60 additions & 60 deletions assets/skills/comet/scripts/comet-resume-probe.mjs

Large diffs are not rendered by default.

232 changes: 116 additions & 116 deletions assets/skills/comet/scripts/comet-runtime.mjs

Large diffs are not rendered by default.

138 changes: 69 additions & 69 deletions assets/skills/comet/scripts/comet-state.mjs

Large diffs are not rendered by default.

130 changes: 65 additions & 65 deletions assets/skills/comet/scripts/comet-yaml-validate.mjs

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions docs/comet/specs/unified-product-catalogue/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ docs:

布局由 `.comet/config.yaml` 的 `classic.artifact_layout` 唯一指定。Classic 的 OpenSpec root、changes、archive、specs 以及 Superpowers specs、plans、reports 都必须由同一个布局 resolver 推导。

Runtime 不扫描另一布局来寻找 fallback,不根据哪个目录先出现决定所有权。配置指定布局与磁盘事实冲突、或 legacy/docs 两个 OpenSpec 根同时存在时,所有写操作失败关闭;只读 status / doctor 报告两个根及修复命令。配置缺失或无效时,只读消费者报告布局不可用,不猜测 legacy 路径继续扫描。
Runtime 不扫描另一布局来寻找 fallback,不根据哪个目录先出现决定所有权。配置指定的布局是 Comet 的唯一写入和读取边界;另一个 OpenSpec 根可以由用户独立运行官方 OpenSpec CLI,二者同时存在不构成 Classic 冲突。配置缺失或无效时,只读消费者报告布局不可用,不猜测 legacy 路径继续扫描。

跨 workflow 的项目配置类型、字段规范化和相对路径边界属于 `domains/workflow-contract/`。Classic、Native、Entry、Factory、Dashboard 与 Skill 安装可以消费该契约,但不得分别以正则、字符串拼接或重复 YAML 解析重新定义它。

Expand All @@ -43,7 +43,7 @@ comet classic openspec -- <openspec-args...>

该命令发现项目根、读取并验证 Classic layout,将进程 cwd 设为解析后的 OpenSpec root,然后执行配置的 OpenSpec CLI。legacy 的 cwd 是项目根,docs 的 cwd 是 `<project>/docs`。

Adapter 原样转发 stdout、stderr 和退出码;命令不存在、配置无效、双根冲突或 root 不健康时返回明确错误,不切换另一布局重试。
Adapter 原样转发 stdout、stderr 和退出码;命令不存在、配置无效或配置的 root 不健康时返回明确错误,不切换另一布局重试。另一个 OpenSpec 根不会被 Comet adapter 扫描或使用

Comet-owned Skill、Rule、reference、runtime 与用户可复制的命令必须使用该 adapter。用户也可以在解析后的 OpenSpec root 直接运行官方 CLI。项目内 docs 布局不创建、注册或依赖 OpenSpec store ID。

Expand All @@ -56,12 +56,13 @@ OpenSpec CLI / 平台工具资产安装与项目 artifact root 初始化是两
- CLI 安装和平台 Skill / command 文件继续落在项目对应的平台目录。
- artifact root 初始化只在解析后的 OpenSpec root 创建官方 `openspec/` 结构,不把平台工具目录嵌套到 `docs/`。
- docs 布局可以通过 `openspec init <project>/docs --tools none` 或等价的官方无工具初始化完成 root 创建。
- 当项目尚无 Comet 配置、但已有用户管理的根级 `openspec/` 时,启用 Comet 的 docs 布局可以创建 `docs/openspec/`,不得移动、清理或扫描原有根级 OpenSpec 产物。
- 工具资产需要 OpenSpec 生成时,在隔离 staging project 生成并通过现有 platform adapter 合并;不得在真实项目根留下临时 `openspec/`。
- update 分别更新平台工具资产和配置的 artifact root,不以项目根存在 `openspec/` 为前提。

## 路径消费者

以下能力必须使用布局 resolver,并在写入前执行双根/配置可写断言
以下能力必须使用布局 resolver,并在写入前执行配置 root 的可写断言

- Classic state、validate、guard、handoff、archive、resume probe、current selection 与 Hook Guard。
- Entry status、Ambient Resume 与 Hook Router 的 Classic change 枚举。
Expand Down Expand Up @@ -119,7 +120,7 @@ Doctor 检查:

- layout 枚举、配置与启用 workflows 的一致性;
- OpenSpec root 健康状态;
- legacy/docs 双根冲突
- 配置 root 与物理目录的存在性、类型和安全边界;另一个 OpenSpec 根仅作为独立工具目录保留,不参与 Classic 状态判断
- 未完成迁移 journal 及其完整身份;
- platform tool 资产与 artifact root 是否错误耦合。

Expand Down
75 changes: 68 additions & 7 deletions domains/comet-classic/classic-evidence.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { promises as fs } from 'fs';
import path from 'path';
import type { ClassicStateProjection } from './classic-state.js';
import { assertClassicLayoutReadable, discoverClassicProject } from './classic-layout.js';
import {
assertClassicLayoutReadable,
classicLayoutPaths,
classicProjectRelative,
discoverClassicProject,
type ClassicLayoutPaths,
} from './classic-layout.js';
import { readLegacyArchivedHandoffFallback } from './classic-archive-pointer.js';
import {
inspectProtectedProjectPath,
Expand All @@ -27,10 +33,43 @@ async function linkedFileEvidence(
projectRoot: string,
code: string,
relativePath: string | null,
layout?: ClassicLayoutPaths,
layoutError?: unknown,
): Promise<ClassicEvidence> {
if (!relativePath) return { code, satisfied: false };
const source = relativePath.replaceAll('\\', '/');
if (layoutError) {
return {
code,
satisfied: false,
source,
detail: `Classic layout is unsafe or unavailable: ${
layoutError instanceof Error ? layoutError.message : String(layoutError)
}`,
};
}
try {
if (layout) {
const alternateLayout = layout.artifactLayout === 'legacy' ? 'docs' : 'legacy';
const alternateRoot = classicProjectRelative(
projectRoot,
classicLayoutPaths(projectRoot, alternateLayout).openSpecRoot,
);
if (source === alternateRoot || source.startsWith(`${alternateRoot}/`)) {
const alternate = await inspectProtectedProjectPath(projectRoot, source, {
label: `${code} artifact`,
expected: 'file',
});
if (alternate.exists) {
return {
code,
satisfied: false,
source,
detail: 'standalone OpenSpec root is not a Comet artifact root',
};
}
}
}
const satisfied = await protectedProjectFileExists(projectRoot, source, {
label: `${code} artifact`,
});
Expand All @@ -56,9 +95,10 @@ async function archivedHandoffEvidence(
projectRoot: string,
changeDir: string,
relativePath: string | null,
layout?: ClassicLayoutPaths,
): Promise<ClassicEvidence> {
try {
await assertClassicLayoutReadable(projectRoot);
layout ??= await assertClassicLayoutReadable(projectRoot);
} catch (error) {
return {
code: 'design.handoff',
Expand All @@ -69,7 +109,7 @@ async function archivedHandoffEvidence(
}`,
};
}
const evidence = await linkedFileEvidence(projectRoot, 'design.handoff', relativePath);
const evidence = await linkedFileEvidence(projectRoot, 'design.handoff', relativePath, layout);
if (!relativePath || evidence.satisfied || evidence.detail) return evidence;
try {
const mapped = await readLegacyArchivedHandoffFallback(
Expand Down Expand Up @@ -197,17 +237,38 @@ export async function collectClassicEvidence(
const checkpoint = projection.run
? path.resolve(changeDir, projection.run.checkpointRef)
: path.join(changeDir, '.comet', 'checkpoint.json');
let layout: ClassicLayoutPaths | undefined;
let layoutError: unknown;
try {
layout = await assertClassicLayoutReadable(projectRoot);
} catch (error) {
layoutError = error;
// The individual evidence item keeps reporting the precise unavailable
// layout error; pointers are only scoped when a valid layout is known.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const evidence = await Promise.all([
directFileEvidence(projectRoot, 'openspec.proposal', proposal),
directFileEvidence(projectRoot, 'openspec.design', design),
directFileEvidence(projectRoot, 'openspec.tasks', tasks),
deltaSpecEvidence(projectRoot, changeDir),
linkedFileEvidence(projectRoot, 'design.document', classic?.designDoc ?? null),
linkedFileEvidence(projectRoot, 'build.plan', classic?.plan ?? null),
linkedFileEvidence(
projectRoot,
'design.document',
classic?.designDoc ?? null,
layout,
layoutError,
),
linkedFileEvidence(projectRoot, 'build.plan', classic?.plan ?? null, layout, layoutError),
taskEvidence(projectRoot, tasks),
linkedFileEvidence(projectRoot, 'verification.report', classic?.verificationReport ?? null),
archivedHandoffEvidence(projectRoot, changeDir, classic?.handoffContext ?? null),
linkedFileEvidence(
projectRoot,
'verification.report',
classic?.verificationReport ?? null,
layout,
layoutError,
),
archivedHandoffEvidence(projectRoot, changeDir, classic?.handoffContext ?? null, layout),
directFileEvidence(projectRoot, 'run.checkpoint', checkpoint),
]);

Expand Down
24 changes: 13 additions & 11 deletions domains/comet-classic/classic-layout-initialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,9 +999,10 @@ export async function repairClassicLayoutInitialization(
}

/**
* Validate the only two safe OpenSpec initialization states:
* an existing configured Classic layout that is already writable, or a truly
* fresh project where neither managed OpenSpec root exists.
* Validate OpenSpec initialization against the explicitly selected Classic
* root. A standalone OpenSpec root may already exist at the alternate
* location; initialization owns only the selected root and leaves the
* alternate root untouched.
*/
export async function assertClassicLayoutInitializationSafe(
projectRoot: string,
Expand Down Expand Up @@ -1085,11 +1086,6 @@ export async function assertClassicLayoutInitializationSafe(
`Classic init ownership is ${ownership.stage}; recover it with comet doctor before continuing`,
);
}
if (alternateRoot.exists) {
throw new Error(
'Classic layout conflict: the alternate OpenSpec root appeared during initialization',
);
}
const originalConfigStillMatches = workflowProjectConfigIdentityEquals(
configIdentity,
ownership.configIdentity,
Expand Down Expand Up @@ -1150,9 +1146,7 @@ export async function assertClassicLayoutInitializationSafe(
`Configured Classic OpenSpec root is missing for ${desiredLayout} layout while the alternate root exists`,
);
}
const configured = await assertClassicLayoutWritable(root, desiredLayout, {
allowAlternateRoot: true,
});
const configured = await assertClassicLayoutWritable(root, desiredLayout);
return {
...configured,
initializationPermit: permitsDesiredRoot(permit, root, desiredLayout)
Expand All @@ -1170,6 +1164,14 @@ export async function assertClassicLayoutInitializationSafe(
: initializationPermit(root, desiredLayout, configIdentity),
};
}
if (!desiredRoot.exists && alternateRoot.exists) {
return {
...desired,
initializationPermit: permitsDesiredRoot(permit, root, desiredLayout)
? permit
: initializationPermit(root, desiredLayout, configIdentity),
};
}
throw new Error(
'Cannot initialize Classic layout without .comet/config.yaml when openspec/ or docs/openspec/ already exists',
);
Expand Down
66 changes: 19 additions & 47 deletions domains/comet-classic/classic-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@ export interface ClassicLayoutInspection {
dualRoots: boolean;
}

interface ClassicLayoutAccessOptions {
allowAlternateRoot?: boolean;
}

export class ClassicLayoutUnavailableError extends Error {
readonly code = 'classic-layout-unavailable';

Expand All @@ -52,23 +48,6 @@ export class ClassicLayoutUnavailableError extends Error {
}
}

export class ClassicLayoutConflictError extends Error {
readonly code = 'classic-layout-conflict';

constructor(
readonly configuredRoot: string,
readonly alternateRoot: string,
projectRoot: string,
) {
const configured = classicProjectRelative(projectRoot, configuredRoot);
const alternate = classicProjectRelative(projectRoot, alternateRoot);
super(
`Classic layout conflict: both ${configured}/ and ${alternate}/ exist; run comet doctor and resolve the conflict before writing`,
);
this.name = 'ClassicLayoutConflictError';
}
}

const PROJECT_CONFIG_RELATIVE_PATH = '.comet/config.yaml';

function isMissingPath(error: unknown): boolean {
Expand Down Expand Up @@ -184,6 +163,16 @@ export async function resolveClassicLayout(
);
}

async function inspectUntrustedAlternateRoot(alternateRoot: string): Promise<boolean> {
try {
await fs.lstat(alternateRoot);
return true;
} catch (error) {
if (isMissingPath(error)) return false;
throw error;
}
}

export async function inspectClassicLayout(
projectRoot: string,
artifactLayout?: ClassicArtifactLayout,
Expand All @@ -192,7 +181,7 @@ export async function inspectClassicLayout(
const alternateLayout: ClassicArtifactLayout =
paths.artifactLayout === 'legacy' ? 'docs' : 'legacy';
const alternateRoot = classicLayoutPaths(projectRoot, alternateLayout).openSpecRoot;
const [configuredRoot, alternate] = await Promise.all([
const [configuredRoot, alternateRootExists] = await Promise.all([
inspectProtectedProjectPath(
paths.projectRoot,
classicProjectRelative(paths.projectRoot, paths.openSpecRoot),
Expand All @@ -201,17 +190,9 @@ export async function inspectClassicLayout(
expected: 'directory',
},
),
inspectProtectedProjectPath(
paths.projectRoot,
classicProjectRelative(paths.projectRoot, alternateRoot),
{
label: 'Alternate Classic OpenSpec root',
expected: 'directory',
},
),
inspectUntrustedAlternateRoot(alternateRoot),
]);
const configuredRootExists = configuredRoot.exists;
const alternateRootExists = alternate.exists;
return {
paths,
configuredRootExists,
Expand All @@ -221,16 +202,12 @@ export async function inspectClassicLayout(
};
}

async function assertClassicManagedRootsPhysical(
paths: ClassicLayoutPaths,
alternateRoot: string,
): Promise<void> {
async function assertClassicManagedRootsPhysical(paths: ClassicLayoutPaths): Promise<void> {
const managedRoots = [
paths.openSpecRoot,
paths.changesDir,
paths.archiveDir,
paths.specsDir,
alternateRoot,
paths.superpowersRoot,
paths.superpowersSpecsDir,
paths.superpowersPlansDir,
Expand All @@ -248,17 +225,13 @@ async function assertClassicManagedRootsPhysical(
export async function assertClassicLayoutReadable(
projectRoot: string,
artifactLayout?: ClassicArtifactLayout,
options: ClassicLayoutAccessOptions = {},
): Promise<ClassicLayoutPaths> {
const inspection = await inspectClassicLayout(projectRoot, artifactLayout);
await assertClassicManagedRootsPhysical(inspection.paths, inspection.alternateRoot);
if (inspection.dualRoots && !options.allowAlternateRoot) {
throw new ClassicLayoutConflictError(
inspection.paths.openSpecRoot,
inspection.alternateRoot,
inspection.paths.projectRoot,
);
}
await assertClassicManagedRootsPhysical(inspection.paths);
// `artifact_layout` is the ownership boundary. The alternate OpenSpec root
// may belong to a standalone OpenSpec workflow and must not block Comet from
// reading or writing its configured root. Explicit root migration remains
// responsible for handling a non-empty destination safely.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!inspection.configuredRootExists) {
const configured = classicProjectRelative(
inspection.paths.projectRoot,
Expand All @@ -280,15 +253,14 @@ export async function assertClassicLayoutReadable(
export async function assertClassicLayoutWritable(
projectRoot: string,
artifactLayout?: ClassicArtifactLayout,
options: ClassicLayoutAccessOptions = {},
): Promise<ClassicLayoutPaths> {
const pendingMove = path.join(path.resolve(projectRoot), '.comet', 'classic-root-move.json');
if (await fileExists(pendingMove)) {
throw new Error(
'Classic root move transaction is incomplete; inspect it with comet doctor and recover it explicitly before writing',
);
}
const paths = await assertClassicLayoutReadable(projectRoot, artifactLayout, options);
const paths = await assertClassicLayoutReadable(projectRoot, artifactLayout);
if (!(await fileExists(paths.openSpecRoot))) {
throw new Error(
`Configured Classic OpenSpec root is missing: ${classicProjectRelative(
Expand Down
Loading
Loading