diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed265e7..fd4f0c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.5] - 2026-07-14 + +### Changed + +- **Skill trigger and decision authoring**: Built-in phase Skills and Creator-generated internal Node Skills now declare explicit entry/runtime boundaries, while Creator templates classify automatic handling, stop conditions, and manual handoffs before emitting user pauses. This prevents ordinary tasks, guard failures, capability gaps, and single-option recovery paths from invoking internal phases or prompting unnecessarily. +- **Comet workflow checkpoints**: Clear requests now skip redundant pre-artifact naming confirmation, Build preflights executable capabilities and combines adjacent configuration choices into one decision, and manual handoffs return control without asking again. Full workflows also initialize recoverable state before artifact generation, persist large-PRD batch manifests, and keep resumability independent of unwritten conversation state. +- **Verification repair loops and archive ownership**: Verification now automatically returns the first three actionable failures to Build, persists the consecutive failure count across resumes, pauses only for real tradeoffs or retry-limit decisions, and keeps CRITICAL and IMPORTANT findings non-waivable. Verification records evidence without finishing the branch, while archive commits only attributed paths before branch handling so the final branch or PR includes merged specs and archive metadata. +- **Preset execution semantics**: Hotfix and tweak workflows now record truthful current-workspace isolation, retain regression testing in direct mode, avoid escalating on task count alone, and discard lightweight execution settings when upgraded to the full workflow. + +### Fixed + +- **OpenSpec workflow compatibility**: Comet now requires OpenSpec 1.5 or newer, reports incompatible installations in setup and Doctor, drives `/comet-open` from `applyRequires` and the live schema, validates repository-local paths and concrete outputs, and resumes persisted split batches without recreating completed changes. +- **Codex hook configuration**: Project and global Codex installs now write phase guard hooks to the supported `.codex/hooks.json` location and safely migrate Comet-managed entries from the previously generated `settings.local.json` without changing user-defined hooks or settings ([#199](https://github.com/rpamis/comet/issues/199)). +- **Standard Superpowers artifacts**: Classic write hooks now accept first-time design, plan, and verification artifacts in their standard workflow directories without requiring Comet-specific filename suffixes, while selected-change, phase, and occupied-slot checks still prevent ambiguous or duplicate writes. +- **Skill lifecycle integrity**: Comet now preserves malformed user Hook configuration, reports Skill, Rule, and Hook failures consistently across init, update, Doctor, and uninstall, and avoids registering partial installations as complete. + ## What's Changed [0.4.0-beta.4] - 2026-07-11 ### Added diff --git a/app/commands/doctor.ts b/app/commands/doctor.ts index de6a769c..d5af9acd 100644 --- a/app/commands/doctor.ts +++ b/app/commands/doctor.ts @@ -1,8 +1,12 @@ import path from 'path'; import os from 'os'; -import { execSync } from 'child_process'; import { fileExists, readDir } from '../../platform/fs/file-system.js'; -import { isCommandAvailable } from '../../domains/integrations/openspec.js'; +import { + getOpenSpecVersion, + isCommandAvailable, + isOpenSpecVersionCompatible, + MINIMUM_OPENSPEC_VERSION, +} from '../../domains/integrations/openspec.js'; import { hasCodegraphProjectIndex, resolveCodegraphCommand, @@ -12,8 +16,16 @@ import { getAssetsDir, getManagedSkillPaths, } from '../../domains/skill/platform-install.js'; -import { PLATFORMS, getPlatformSkillsDirs } from '../../platform/install/platforms.js'; -import { hasPlatformDetectionPath } from '../../platform/install/detect.js'; +import { + getPlatformRuleDestinations, + inspectCometHooksForPlatform, +} from '../../domains/skill/platform-inspect.js'; +import { + PLATFORMS, + getPlatformSkillsDirs, + type Platform, +} from '../../platform/install/platforms.js'; +import { resolveCanonicalSkillRootOwners } from '../../platform/install/skill-root-owner.js'; import type { InstallScope } from '../../platform/install/types.js'; import { inspectClassicChange } from '../../domains/comet-classic/classic-diagnostics.js'; import { getCurrentVersion } from '../../platform/version/version.js'; @@ -51,14 +63,15 @@ async function checkOpenSpecCli(): Promise { message: 'not installed — install with: npm install -g @fission-ai/openspec@latest', }; } - try { - const version = execSync('openspec --version', { stdio: 'pipe', timeout: 10_000 }) - .toString() - .trim(); - return { check: 'openspec CLI', status: 'pass', message: `installed (${version})` }; - } catch { - return { check: 'openspec CLI', status: 'pass', message: 'installed' }; + const version = getOpenSpecVersion(); + if (!version || !isOpenSpecVersionCompatible(version)) { + return { + check: 'openspec CLI', + status: 'warn', + message: `installed (${version || 'version unknown'}), but Comet requires >= ${MINIMUM_OPENSPEC_VERSION} — run: npm install -g @fission-ai/openspec@latest`, + }; } + return { check: 'openspec CLI', status: 'pass', message: `installed (${version})` }; } function checkEnvironment(projectPath: string, context: DoctorContext): CheckResult { @@ -167,6 +180,65 @@ function getScopeBases( return bases; } +async function checkPlatformComponents( + baseDir: string, + platform: (typeof PLATFORMS)[number], + scope: InstallScope, +): Promise { + const results: CheckResult[] = []; + const ruleDestinations = await getPlatformRuleDestinations(baseDir, platform, scope); + if (ruleDestinations.length > 0) { + let present = 0; + const inspectionErrors: string[] = []; + for (const destination of ruleDestinations) { + try { + if (await fileExists(destination)) present++; + } catch (error) { + inspectionErrors.push(`${destination}: ${(error as Error).message}`); + } + } + results.push({ + check: `rules: ${platform.name} (${scope})`, + status: + inspectionErrors.length === 0 && present === ruleDestinations.length ? 'pass' : 'warn', + message: + inspectionErrors.length > 0 + ? `unable to inspect managed Rule (${inspectionErrors.join('; ')}) — run: comet update --scope ${scope}` + : present === ruleDestinations.length + ? `complete (${present} files)` + : `partial (${present}/${ruleDestinations.length} files) — run: comet update --scope ${scope}`, + }); + } + + if (platform.supportsHooks && platform.hookFormat) { + const inspection = await inspectCometHooksForPlatform(baseDir, platform, scope); + results.push({ + check: `hooks: ${platform.name} (${scope})`, + status: inspection.present ? 'pass' : 'warn', + message: inspection.present + ? 'managed Hook present' + : `${inspection.error ?? 'managed Hook missing'} — run: comet update --scope ${scope}`, + }); + } + + return results; +} + +async function getPlatformsForSkillInspection( + baseDir: string, + scope: InstallScope, + doctorScope: DoctorScope, +): Promise> { + return ( + await resolveCanonicalSkillRootOwners(baseDir, scope, { + respectDetectionPaths: doctorScope === 'auto', + }) + ).map(({ platform, hasOwnershipEvidence, sharedCanonicalRoot }) => ({ + platform, + inspectComponents: !sharedCanonicalRoot || hasOwnershipEvidence, + })); +} + async function checkSkillCompleteness( projectPath: string, scope: DoctorScope, @@ -183,8 +255,8 @@ async function checkSkillCompleteness( global: { hasInstall: false, hasComplete: false }, }; for (const base of getScopeBases(projectPath, scope, context)) { - for (const platform of PLATFORMS) { - if (scope === 'auto' && !(await hasPlatformDetectionPath(base.baseDir, platform))) continue; + const platforms = await getPlatformsForSkillInspection(base.baseDir, base.scope, scope); + for (const { platform, inspectComponents } of platforms) { const skillsDirs = getPlatformSkillsDirs(platform, base.scope); const canonicalSkillsDir = skillsDirs[0]; let detectedSkillsDir: string | undefined; @@ -232,6 +304,9 @@ async function checkSkillCompleteness( message: `partial (${present.length}/${total} files; missing ${missing.length}) — run: comet update --scope ${base.scope}`, }, ); + if (inspectComponents) { + results.push(...(await checkPlatformComponents(base.baseDir, platform, base.scope))); + } } } diff --git a/app/commands/init.ts b/app/commands/init.ts index b8f2628b..2c38b90e 100644 --- a/app/commands/init.ts +++ b/app/commands/init.ts @@ -46,7 +46,7 @@ type InitOptions = { }; type InstallStatus = 'installed' | 'skipped' | 'failed'; -type ComponentAction = 'overwrite' | 'skip' | 'install'; +type ComponentAction = 'overwrite' | 'skip' | 'install' | 'reuse'; type BulkOverwriteChoice = 'overwrite-all' | 'skip-all' | 'choose'; interface PlatformResult { @@ -186,6 +186,11 @@ function resolveAction( return 'install'; } +function resolveCometAction(hasExisting: boolean, options: InitOptions): ComponentAction { + if (hasExisting && options.yes && !options.overwrite && !options.skipExisting) return 'reuse'; + return resolveAction(hasExisting, options); +} + type NpmDepId = 'openspec' | 'superpowers' | 'codegraph'; interface NpmDepState { @@ -385,7 +390,7 @@ export async function initCommand(targetPath: string, options: InitOptions = {}) let osAction = resolveAction(hasOS, options); let spAction = resolveAction(hasSP, options); - let cmAction = resolveAction(hasCM, options); + let cmAction = resolveCometAction(hasCM, options); if (!options.yes) { const existingComponents = [ @@ -488,6 +493,8 @@ export async function initCommand(targetPath: string, options: InitOptions = {}) : `${scope === 'global' ? '~/' : ''}${platformSkillsDir}/skills/`; let cmStatus: InstallStatus = 'skipped'; + let cometComponentInstalled = false; + let skillFailed = false; if (cmAction !== 'skip') { const { copied, failed } = await copyCometSkillsForPlatform( baseDir, @@ -497,38 +504,71 @@ export async function initCommand(targetPath: string, options: InitOptions = {}) scope, installMode, ); + skillFailed = failed > 0; cmStatus = failed > 0 ? 'failed' : copied > 0 ? 'installed' : 'skipped'; - log( - ` Comet -> ${platform.name}: ${cmStatus} (${copied} files${ - failed > 0 ? `, ${failed} failed` : '' - }) -> ${skillsPath}`, - ); + cometComponentInstalled = copied > 0; + if (cmAction === 'reuse' && copied === 0 && failed === 0) { + log(` Comet -> ${platform.name}: reused (${t(lang, 'alreadyExists')})`); + } else { + log( + ` Comet -> ${platform.name}: ${cmStatus} (${copied} files${ + failed > 0 ? `, ${failed} failed` : '' + }) -> ${skillsPath}`, + ); + } } else { log(` Comet -> ${platform.name}: skipped (${t(lang, 'alreadyExists')})`); } - if (cmAction !== 'skip') { - const { copied: ruleCopied } = await copyCometRulesForPlatform( - baseDir, - platform, - cmAction === 'overwrite', - language.id, - scope, - ); - if (ruleCopied > 0) { - log(` Comet rules -> ${platform.name}: ${ruleCopied} ${t(lang, 'rulesInstalled')}`); + if (cmAction !== 'skip' && !skillFailed) { + try { + const { copied: ruleCopied, failed: ruleFailed } = await copyCometRulesForPlatform( + baseDir, + platform, + cmAction === 'overwrite', + language.id, + scope, + ); + cometComponentInstalled ||= ruleCopied > 0; + if (ruleCopied > 0) { + log(` Comet rules -> ${platform.name}: ${ruleCopied} ${t(lang, 'rulesInstalled')}`); + } + if (ruleFailed > 0) { + cmStatus = 'failed'; + log(` Comet rules -> ${platform.name}: ${t(lang, 'rulesFailed')} (${ruleFailed})`); + } + } catch (err) { + cmStatus = 'failed'; + log( + ` Comet rules -> ${platform.name}: ${t(lang, 'rulesFailed')} (${(err as Error).message})`, + ); } } - if (cmAction !== 'skip' && platform.supportsHooks) { - const { installed, reason } = await installCometHooksForPlatform(baseDir, platform, scope); - if (installed) { - log(` Comet hooks -> ${platform.name}: ${t(lang, 'hooksInstalled')}`); - } else if (reason) { - log(` Comet hooks -> ${platform.name}: ${t(lang, 'hooksSkipped')} (${reason})`); + if (cmAction !== 'skip' && !skillFailed) { + try { + const { status, reason } = await installCometHooksForPlatform(baseDir, platform, scope); + cometComponentInstalled ||= status === 'installed'; + if (status === 'installed') { + log(` Comet hooks -> ${platform.name}: ${t(lang, 'hooksInstalled')}`); + } else if (status === 'failed') { + cmStatus = 'failed'; + log(` Comet hooks -> ${platform.name}: ${t(lang, 'hooksFailed')} (${reason})`); + } else if (reason && platform.supportsHooks) { + log(` Comet hooks -> ${platform.name}: ${t(lang, 'hooksSkipped')} (${reason})`); + } + } catch (err) { + cmStatus = 'failed'; + log( + ` Comet hooks -> ${platform.name}: ${t(lang, 'hooksFailed')} (${(err as Error).message})`, + ); } } + if (cmAction !== 'skip' && cmStatus !== 'failed') { + cmStatus = cometComponentInstalled ? 'installed' : 'skipped'; + } + results.push({ platform, openspec: osToolIds.includes(platform.openspecToolId) ? osGlobalStatus : 'skipped', @@ -562,10 +602,24 @@ export async function initCommand(targetPath: string, options: InitOptions = {}) if (scope === 'project') { await createWorkingDirs(projectPath, language.artifactLanguage); const projectTargets = await detectInstalledCometTargets(projectPath, { scopes: ['project'] }); - if (projectTargets.length > 0) { + const successfulCometPlatforms = new Set( + results + .filter( + (result) => + result.comet !== 'failed' && + plans.some( + (plan) => plan.platform.id === result.platform.id && plan.cmAction !== 'skip', + ), + ) + .map((result) => result.platform.id), + ); + const completeProjectTargets = projectTargets.filter((target) => + successfulCometPlatforms.has(target.platform.id), + ); + if (completeProjectTargets.length > 0) { await upsertProjectInstallation( projectPath, - projectTargets.map((target) => ({ + completeProjectTargets.map((target) => ({ platform: target.platform.id, language: target.language, })), diff --git a/app/commands/uninstall.ts b/app/commands/uninstall.ts index f28c5d7e..0edfde0e 100644 --- a/app/commands/uninstall.ts +++ b/app/commands/uninstall.ts @@ -2,7 +2,7 @@ import path from 'path'; import { checkbox, select } from '@inquirer/prompts'; import { getBaseDir, type InstallScope } from '../../platform/install/detect.js'; -import { getPlatformSkillsDir } from '../../platform/install/platforms.js'; +import { PLATFORMS, getPlatformSkillsDir } from '../../platform/install/platforms.js'; import { removeCometSkillsForPlatform, removeCometRulesForPlatform, @@ -10,11 +10,13 @@ import { removeWorkingDirs, removeCometProjectInstructions, } from '../../domains/skill/uninstall.js'; -import { detectInstalledCometTargets } from './update.js'; +import { detectInstalledCometTargets, type InstalledCometTarget } from './update.js'; import { listProjectRegistryEntries, + findProjectRegistryEntry, removeProjectInstallation, upsertProjectInstallation, + type ProjectRegistryTarget, } from '../../platform/install/project-registry.js'; import { assertProjectScopeOptions, resolveProjectScopeMode } from './project-scope-selection.js'; @@ -24,6 +26,8 @@ interface UninstallOptions { force?: boolean; allProjects?: boolean; currentProject?: boolean; + recoverProjectCleanup?: boolean; + recoveryTargets?: ProjectRegistryTarget[]; } interface TargetUninstallResult { @@ -41,6 +45,7 @@ interface TargetUninstallResult { interface SingleProjectUninstallResult { projectPath: string; + projectScopeProcessed: boolean; targets: TargetUninstallResult[]; workingDirsRemoved: number; projectInstructionsRemoved: number; @@ -53,6 +58,28 @@ interface SingleProjectUninstallResult { }; } +function mergeCleanupTargets( + detectedTargets: InstalledCometTarget[], + recoveryTargets: ProjectRegistryTarget[], + recoverProjectCleanup: boolean, +): InstalledCometTarget[] { + const targets = [...detectedTargets]; + if (!recoverProjectCleanup) return targets; + + const seen = new Set(targets.map((target) => `${target.scope}:${target.platform.id}`)); + for (const recoveryTarget of recoveryTargets) { + const platform = PLATFORMS.find((candidate) => candidate.id === recoveryTarget.platform); + if (!platform) continue; + + const key = `project:${platform.id}`; + if (seen.has(key)) continue; + seen.add(key); + targets.push({ scope: 'project', platform, language: recoveryTarget.language }); + } + + return targets; +} + function currentProjectJson(result: SingleProjectUninstallResult | null): { targets: Array<{ scope: InstallScope; @@ -99,24 +126,33 @@ async function uninstallSingleProject( options: UninstallOptions = {}, log: (message: string) => void, ): Promise { - const targets = await detectInstalledCometTargets(projectPath, { + const detectedTargets = await detectInstalledCometTargets(projectPath, { scopes: options.scope ? [options.scope] : undefined, respectDetectionPaths: options.scope === undefined, }); + const targets = mergeCleanupTargets( + detectedTargets, + options.recoveryTargets ?? [], + options.recoverProjectCleanup === true, + ); - if (targets.length === 0) { + if (targets.length === 0 && !options.recoverProjectCleanup) { return null; } const scopeLabel = (scope: InstallScope) => scope === 'global' ? 'global' : `project (${projectPath})`; - log(' Found Comet installations on the following targets:\n'); - for (const target of targets) { - const skillsDir = getPlatformSkillsDir(target.platform, target.scope); - const prefix = target.scope === 'global' ? '~/' : ''; - log(` ${target.platform.name} (${scopeLabel(target.scope)})`); - log(` Path: ${prefix}${skillsDir}/skills/`); + if (targets.length > 0) { + log(' Found Comet installations on the following targets:\n'); + for (const target of targets) { + const skillsDir = getPlatformSkillsDir(target.platform, target.scope); + const prefix = target.scope === 'global' ? '~/' : ''; + log(` ${target.platform.name} (${scopeLabel(target.scope)})`); + log(` Path: ${prefix}${skillsDir}/skills/`); + } + } else { + log(' Found an indexed project with follow-on cleanup still pending.\n'); } let selectedTargets = targets; @@ -162,20 +198,9 @@ async function uninstallSingleProject( for (const target of selectedTargets) { const baseDir = getBaseDir(target.scope, projectPath); - const skillsResult = await removeCometSkillsForPlatform(baseDir, target.platform, target.scope); - totalSkills += skillsResult.removed; - totalFailures += skillsResult.failed; - - const rulesResult = - skillsResult.failed === 0 - ? await removeCometRulesForPlatform(baseDir, target.platform, target.scope) - : { removed: 0, failed: 0 }; - totalRules += rulesResult.removed; - totalFailures += rulesResult.failed; - let hooksRemoved = 0; let hooksFailed = 0; - if (skillsResult.failed === 0 && target.platform.supportsHooks) { + if (target.platform.supportsHooks) { const hooksResult = await removeCometHooksForPlatform(baseDir, target.platform, target.scope); hooksRemoved = hooksResult.removed; hooksFailed = hooksResult.failed; @@ -183,6 +208,17 @@ async function uninstallSingleProject( totalFailures += hooksResult.failed; } + const rulesResult = await removeCometRulesForPlatform(baseDir, target.platform, target.scope); + totalRules += rulesResult.removed; + totalFailures += rulesResult.failed; + + const skillsResult = + hooksFailed === 0 && rulesResult.failed === 0 + ? await removeCometSkillsForPlatform(baseDir, target.platform, target.scope) + : { removed: 0, failed: 0 }; + totalSkills += skillsResult.removed; + totalFailures += skillsResult.failed; + log( ` ${target.platform.name} (${target.scope}): ${skillsResult.removed} skills, ${rulesResult.removed} rules, ${hooksRemoved} hooks removed`, ); @@ -207,7 +243,8 @@ async function uninstallSingleProject( } let workingDirsRemoved = 0; - const hasProjectScope = selectedTargets.some((t) => t.scope === 'project'); + const hasProjectScope = + options.recoverProjectCleanup === true || selectedTargets.some((t) => t.scope === 'project'); if (hasProjectScope && totalFailures === 0) { const removeResult = await removeCometProjectInstructions(projectPath); projectInstructionsRemoved = removeResult.removed; @@ -219,13 +256,18 @@ async function uninstallSingleProject( if (hasProjectScope && totalFailures === 0) { const dirsResult = await removeWorkingDirs(projectPath); workingDirsRemoved = dirsResult.removed; + totalFailures += dirsResult.failed; if (workingDirsRemoved > 0) { log(` Working directories: ${workingDirsRemoved} removed`); } + if (dirsResult.failed > 0) { + log(` Working directories: cleanup failed (${dirsResult.failed})`); + } } return { projectPath, + projectScopeProcessed: hasProjectScope, targets: results, workingDirsRemoved, projectInstructionsRemoved, @@ -242,7 +284,7 @@ async function uninstallSingleProject( async function refreshRegistryAfterProjectUninstall( result: SingleProjectUninstallResult | null, ): Promise { - if (!result?.targets.some((target) => target.scope === 'project')) return; + if (!result?.projectScopeProcessed) return; if (result.summary.totalFailures > 0) return; const remaining = await detectInstalledCometTargets(result.projectPath, { scopes: ['project'] }); @@ -265,23 +307,17 @@ async function uninstallAllIndexedProjects( const registryProjects = await listProjectRegistryEntries({ strict: true }); const results = []; const runnableProjects = []; - let staleRemoved = 0; + const staleRemoved = 0; - for (const project of registryProjects) { - const projectPath = project.path; + for (const registryProject of registryProjects) { + const projectPath = registryProject.path; try { const targets = await detectInstalledCometTargets(projectPath, { scopes: ['project'] }); if (targets.length === 0) { - if (await removeProjectInstallation(projectPath)) staleRemoved++; - results.push({ - projectPath, - status: 'skipped', - reason: 'no project-scope Comet install detected', - targets: [], - }); + runnableProjects.push({ projectPath, targets, registryProject }); continue; } - runnableProjects.push({ projectPath, targets }); + runnableProjects.push({ projectPath, targets, registryProject }); } catch (error) { results.push({ projectPath, @@ -314,11 +350,19 @@ async function uninstallAllIndexedProjects( } for (const project of runnableProjects) { - const { projectPath, targets } = project; + const { projectPath, targets, registryProject } = project; try { const result = await uninstallSingleProject( projectPath, - { ...options, scope: 'project', allProjects: false, currentProject: true, force: true }, + { + ...options, + scope: 'project', + allProjects: false, + currentProject: true, + force: true, + recoverProjectCleanup: true, + recoveryTargets: registryProject.lastTargets, + }, log, ); @@ -401,7 +445,16 @@ export async function uninstallCommand( return; } - const result = await uninstallSingleProject(projectPath, options, log); + const registeredProject = await findProjectRegistryEntry(projectPath, registryProjects); + const result = await uninstallSingleProject( + projectPath, + { + ...options, + recoverProjectCleanup: Boolean(registeredProject) && options.scope !== 'global', + recoveryTargets: registeredProject?.lastTargets, + }, + log, + ); if (!result) { if (options.json) { diff --git a/app/commands/update.ts b/app/commands/update.ts index f1953ad3..3cad86e0 100644 --- a/app/commands/update.ts +++ b/app/commands/update.ts @@ -17,12 +17,11 @@ import { removeLegacyCometSkillsForPlatform } from '../../domains/skill/uninstal import { installCometProjectInstructions } from '../../domains/skill/project-instructions.js'; import { LANGUAGES } from '../../domains/skill/languages.js'; import { - PLATFORMS, getPlatformSkillsDir, getPlatformSkillsDirs, type Platform, } from '../../platform/install/platforms.js'; -import { hasPlatformDetectionPath } from '../../platform/install/detect.js'; +import { resolveCanonicalSkillRootOwners } from '../../platform/install/skill-root-owner.js'; import { listProjectRegistryEntries, removeProjectInstallation, @@ -87,6 +86,7 @@ interface SingleProjectUpdateResult { }; skills: { totalCopied: number; + totalFailed: number; cleanupFailed: number; installMode?: InstallMode; targets: Array<{ @@ -97,16 +97,52 @@ interface SingleProjectUpdateResult { source: string; copied: number; skipped: number; + failed: number; + reason?: string; cleanupFailed: number; command: string; }>; }; - rules: { totalCopied: number }; - hooks: { totalInstalled: number }; + rules: { + totalCopied: number; + totalFailed: number; + targets: Array<{ + scope: InstallScope; + platform: string; + platformName: string; + copied: number; + skipped: number; + failed: number; + status: 'copied' | 'skipped' | 'failed'; + reason?: string; + }>; + }; + hooks: { + totalInstalled: number; + totalFailed: number; + targets: Array<{ + scope: InstallScope; + platform: string; + platformName: string; + failed: number; + status: 'installed' | 'skipped' | 'failed'; + reason?: string; + }>; + }; projectInstructions: { updated: number }; codegraph: CodegraphStatus; } +interface ComponentFailureDetail { + scope: InstallScope; + platform: string; + platformName: string; + component: 'Skill' | 'Rule' | 'Hook'; + status: 'failed'; + failed: number; + reason: string; +} + interface AllProjectsUpdateResult { projectPath: string; status: 'updated' | 'skipped' | 'failed'; @@ -117,6 +153,7 @@ interface AllProjectsUpdateResult { platformName: string; language: SkillLanguage; }>; + failures?: ComponentFailureDetail[]; summary?: { skillsCopied: number; rulesCopied: number; @@ -238,13 +275,10 @@ async function detectInstalledCometTargets( for (const scope of scopes) { const baseDir = getScopedBaseDir(scope, projectPath, options.globalBaseDir); - for (const platform of PLATFORMS) { - if ( - options.respectDetectionPaths !== false && - !(await hasPlatformDetectionPath(baseDir, platform)) - ) { - continue; - } + const owners = await resolveCanonicalSkillRootOwners(baseDir, scope, { + respectDetectionPaths: options.respectDetectionPaths, + }); + for (const { platform } of owners) { if (!(await hasLocalCometSkills(baseDir, platform, scope))) continue; targets.push({ @@ -376,9 +410,11 @@ async function promptCodegraphInstall(lang: string): Promise { function currentProjectJson(result: SingleProjectUpdateResult): Record { return { + status: hasComponentFailures(result) ? 'incomplete' : 'complete', npm: result.npm, skills: { totalCopied: result.skills.totalCopied, + totalFailed: result.skills.totalFailed, cleanupFailed: result.skills.cleanupFailed, installMode: result.skills.installMode, targets: result.skills.targets, @@ -390,6 +426,79 @@ function currentProjectJson(result: SingleProjectUpdateResult): Record 0 || + result.skills.cleanupFailed > 0 || + result.rules.totalFailed > 0 || + result.hooks.totalFailed > 0 + ); +} + +function componentFailureReason(result: SingleProjectUpdateResult): string { + const reasons: string[] = []; + if (result.skills.totalFailed > 0) { + reasons.push(`Skill update failed (${result.skills.totalFailed})`); + } + if (result.rules.totalFailed > 0) { + reasons.push(`Rule update failed (${result.rules.totalFailed})`); + } + if (result.hooks.totalFailed > 0) { + reasons.push(`Hook update failed (${result.hooks.totalFailed})`); + } + if (result.skills.cleanupFailed > 0) { + reasons.push(`legacy Skill cleanup failed (${result.skills.cleanupFailed})`); + } + return reasons.join('; '); +} + +function collectComponentFailures(result: SingleProjectUpdateResult): ComponentFailureDetail[] { + const skillFailures = result.skills.targets.flatMap((target): ComponentFailureDetail[] => { + const failed = target.failed + target.cleanupFailed; + if (failed === 0 || !target.reason) return []; + return [ + { + scope: target.scope, + platform: target.platform, + platformName: target.platformName, + component: 'Skill', + status: 'failed', + failed, + reason: target.reason, + }, + ]; + }); + const ruleFailures = result.rules.targets.flatMap((target): ComponentFailureDetail[] => { + if (target.failed === 0 || !target.reason) return []; + return [ + { + scope: target.scope, + platform: target.platform, + platformName: target.platformName, + component: 'Rule', + status: 'failed', + failed: target.failed, + reason: target.reason, + }, + ]; + }); + const hookFailures = result.hooks.targets.flatMap((target): ComponentFailureDetail[] => { + if (target.failed === 0 || !target.reason) return []; + return [ + { + scope: target.scope, + platform: target.platform, + platformName: target.platformName, + component: 'Hook', + status: 'failed', + failed: target.failed, + reason: target.reason, + }, + ]; + }); + return [...skillFailures, ...ruleFailures, ...hookFailures]; +} + function summarizeTargets(targets: InstalledCometTarget[]): AllProjectsUpdateResult['targets'] { return targets.map((target) => ({ scope: target.scope, @@ -481,9 +590,9 @@ async function updateSingleProject( command: options.skipNpm || skipRepeatedGlobalNpm ? null : formatNpmUpdateCommand(packageScope), }, - skills: { totalCopied: 0, cleanupFailed: 0, targets: [] }, - rules: { totalCopied: 0 }, - hooks: { totalInstalled: 0 }, + skills: { totalCopied: 0, totalFailed: 0, cleanupFailed: 0, targets: [] }, + rules: { totalCopied: 0, totalFailed: 0, targets: [] }, + hooks: { totalInstalled: 0, totalFailed: 0, targets: [] }, projectInstructions: { updated: 0 }, codegraph: 'skipped', }; @@ -506,11 +615,16 @@ async function updateSingleProject( ); let totalCopied = 0; + let totalFailed = 0; let totalCleanupFailed = 0; let totalRulesCopied = 0; + let totalRulesFailed = 0; let totalHooksInstalled = 0; + let totalHooksFailed = 0; let projectInstructionsUpdated = 0; const targetResults: SingleProjectUpdateResult['skills']['targets'] = []; + const ruleTargetResults: SingleProjectUpdateResult['rules']['targets'] = []; + const hookTargetResults: SingleProjectUpdateResult['hooks']['targets'] = []; for (const target of targets) { const baseDir = getBaseDir(target.scope, projectPath); const languageId = resolveTargetLanguage(options.language, target.language); @@ -529,6 +643,7 @@ async function updateSingleProject( : { removed: 0, failed: 0 }; totalCleanupFailed += cleanupResult.failed; totalCopied += copied; + totalFailed += failed; targetResults.push({ scope: target.scope, platform: target.platform.id, @@ -537,6 +652,13 @@ async function updateSingleProject( source: languageSkillsDir, copied, skipped, + failed, + reason: + failed > 0 + ? `${failed} Skill file(s) failed to install` + : cleanupResult.failed > 0 + ? `legacy Skill cleanup failed (${cleanupResult.failed})` + : undefined, cleanupFailed: cleanupResult.failed, command: formatSkillUpdateCommand( target.scope, @@ -554,42 +676,115 @@ async function updateSingleProject( ); } + if (failed > 0) { + const dependencyReason = 'skipped because Skill installation failed'; + ruleTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + copied: 0, + skipped: 0, + failed: 0, + status: 'skipped', + reason: dependencyReason, + }); + hookTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + failed: 0, + status: 'skipped', + reason: dependencyReason, + }); + continue; + } + try { - const { copied: ruleCopied } = await copyCometRulesForPlatform( + const ruleResult = await copyCometRulesForPlatform( baseDir, target.platform, true, languageId, target.scope, ); - totalRulesCopied += ruleCopied; - if (ruleCopied > 0) { - log(` Comet rules -> ${target.platform.name}: ${ruleCopied} ${t(lang, 'rulesUpdated')}`); + totalRulesCopied += ruleResult.copied; + totalRulesFailed += ruleResult.failed; + const ruleStatus = + ruleResult.failed > 0 ? 'failed' : ruleResult.copied > 0 ? 'copied' : 'skipped'; + const ruleReason = + ruleResult.failed > 0 + ? `${ruleResult.failed} Rule file(s) failed to install` + : !target.platform.rulesDir || !target.platform.rulesFormat + ? 'platform does not support rules' + : undefined; + ruleTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + ...ruleResult, + status: ruleStatus, + reason: ruleReason, + }); + if (ruleResult.copied > 0) { + log( + ` Comet rules -> ${target.platform.name}: ${ruleResult.copied} ${t(lang, 'rulesUpdated')}`, + ); + } + if (ruleResult.failed > 0) { + log(` Comet rules -> ${target.platform.name}: ${t(lang, 'rulesFailed')} (${ruleReason})`); } } catch (err) { - log( - ` Comet rules -> ${target.platform.name}: ${t(lang, 'rulesFailed')} (${(err as Error).message})`, - ); + totalRulesFailed++; + const reason = (err as Error).message; + ruleTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + copied: 0, + skipped: 0, + failed: 1, + status: 'failed', + reason, + }); + log(` Comet rules -> ${target.platform.name}: ${t(lang, 'rulesFailed')} (${reason})`); } - if (target.platform.supportsHooks) { - try { - const { installed, reason } = await installCometHooksForPlatform( - baseDir, - target.platform, - target.scope, - ); - if (installed) { - totalHooksInstalled++; - log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksUpdated')}`); - } else if (reason) { - log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksSkipped')} (${reason})`); - } - } catch (err) { - log( - ` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksFailed')} (${(err as Error).message})`, - ); + try { + const { status, reason } = await installCometHooksForPlatform( + baseDir, + target.platform, + target.scope, + ); + const hookFailed = status === 'failed' ? 1 : 0; + totalHooksFailed += hookFailed; + hookTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + failed: hookFailed, + status, + reason, + }); + if (status === 'installed') { + totalHooksInstalled++; + log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksUpdated')}`); + } else if (status === 'failed') { + log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksFailed')} (${reason})`); + } else if (reason && target.platform.supportsHooks) { + log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksSkipped')} (${reason})`); } + } catch (err) { + totalHooksFailed++; + const reason = (err as Error).message; + hookTargetResults.push({ + scope: target.scope, + platform: target.platform.id, + platformName: target.platform.name, + failed: 1, + status: 'failed', + reason, + }); + log(` Comet hooks -> ${target.platform.name}: ${t(lang, 'hooksFailed')} (${reason})`); } } @@ -659,12 +854,21 @@ async function updateSingleProject( }, skills: { totalCopied, + totalFailed, cleanupFailed: totalCleanupFailed, installMode, targets: targetResults, }, - rules: { totalCopied: totalRulesCopied }, - hooks: { totalInstalled: totalHooksInstalled }, + rules: { + totalCopied: totalRulesCopied, + totalFailed: totalRulesFailed, + targets: ruleTargetResults, + }, + hooks: { + totalInstalled: totalHooksInstalled, + totalFailed: totalHooksFailed, + targets: hookTargetResults, + }, projectInstructions: { updated: projectInstructionsUpdated }, codegraph: codegraphStatus, }; @@ -690,11 +894,25 @@ function logSingleProjectSummary( if (result.skills.cleanupFailed > 0) { log(` Skill cleanup failures: ${result.skills.cleanupFailed} (update incomplete)`); } + if (result.skills.totalFailed > 0) { + log(` Skill failures: ${result.skills.totalFailed} (update incomplete)`); + } + if (result.rules.totalFailed > 0) { + log(` Rule failures: ${result.rules.totalFailed} (update incomplete)`); + } + if (result.hooks.totalFailed > 0) { + log(` Hook failures: ${result.hooks.totalFailed} (update incomplete)`); + } + for (const failure of collectComponentFailures(result)) { + log( + ` ${failure.platformName} (${failure.scope}) ${failure.component}: ${failure.status} (${failure.failed}) - ${failure.reason}`, + ); + } log(` ${t(lang, 'summaryCodegraph')} ${result.codegraph}`); log(` ${t(lang, 'summaryScope')} ${scopes}`); log(` ${t(lang, 'summaryLanguage')} ${languages}`); - if (result.skills.cleanupFailed > 0) { - log(`\n Update incomplete. Canonical Skills were installed, but legacy cleanup failed.\n`); + if (hasComponentFailures(result)) { + log(`\n Update incomplete. ${componentFailureReason(result)}.\n`); } else { log(`\n ${t(lang, 'updateComplete')}\n`); } @@ -789,12 +1007,13 @@ async function updateAllIndexedProjects( continue; } - if (result.skills.cleanupFailed > 0) { + if (hasComponentFailures(result)) { results.push({ projectPath, status: 'failed', - reason: `legacy Skill cleanup failed (${result.skills.cleanupFailed})`, + reason: componentFailureReason(result), targets: summarizeUpdatedTargets(result.skills.targets), + failures: collectComponentFailures(result), }); continue; } @@ -880,7 +1099,7 @@ export async function updateCommand( return; } - if (result.skills.cleanupFailed === 0) { + if (!hasComponentFailures(result)) { await upsertUpdatedProjectTargets(projectPath, result); } diff --git a/assets/manifest.json b/assets/manifest.json index bb1881ec..6c897e66 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "skills": [ "comet/SKILL.md", "comet/reference/auto-transition.md", diff --git a/assets/skills-zh/comet-any/SKILL.md b/assets/skills-zh/comet-any/SKILL.md index d02a4a9d..323faede 100644 --- a/assets/skills-zh/comet-any/SKILL.md +++ b/assets/skills-zh/comet-any/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-any -description: "Use when 用户想定制 Comet 五阶段 Skill、创建 workflow Skill、整理已有 Skill,或用 Workflow Node / Skill Binding / Output Schema 编排 Skill。" +description: "仅在用户明确调用 /comet-any,或明确要求定制 Comet 五阶段流程、创建或升级由 Comet Creator 管理的 workflow Skill 时使用;不要用于一般 Skill 的编写、整理或评审。" --- # Comet Any - Skill Creator diff --git a/assets/skills-zh/comet-any/reference/authored-zone-example.md b/assets/skills-zh/comet-any/reference/authored-zone-example.md index 3a673121..0bd453b0 100644 --- a/assets/skills-zh/comet-any/reference/authored-zone-example.md +++ b/assets/skills-zh/comet-any/reference/authored-zone-example.md @@ -62,21 +62,26 @@ Decision Core 应建模 Auto 区不处理的三件事:(1) **语义化当前节 - 若状态显示某 Node 已完成但预期 artifact 缺失,视为未完成并重新进入。 - 若用户在某个 Node 中途恢复但话题变了,确认是继续当前 Node 还是开始新的。 -### 决策点(必须暂停) +### 决策分类与决策点 + +先分类再行动:有两个或以上会改变范围、行为、风险接受或不可逆结果的合法选项才是用户决策;唯一安全下一步直接执行;缺少依赖、状态损坏或无法继续的 guard 失败是停止条件;`NEXT: manual` 只是交还控制权。只有第一类必须暂停。 | 情况 | 处理 | |------|------| -| 首次调用,无 workflow 状态 | 初始化状态,在开始第一个 Node 前与用户确认主题/范围 | -| 用户输入在两个 Node 间有歧义 | 询问用户指哪个 Node;不要猜测 | +| 首次调用且主题/范围明确 | 自动初始化状态并进入第一个 Node;不得为了确认已知信息而停顿 | +| 主题、范围或目标 Node 存在两个以上互斥且合法的解释 | 合并为一个问题,让用户选择;不要猜测 | | Node 需要用户确认输出才能推进 | 记录 evidence 后停下;等待明确确认 | -| Node guard 失败且原因不明 | 展示 guard 输出,询问用户如何继续 | +| WARNING/偏差接受或不可逆发布存在真实取舍 | 只展示当前可执行选项,并记录用户选择 | + +Node guard 失败时先自动读取证据并执行唯一安全修复;若缺少依赖或状态损坏导致无法继续,报告停止条件和恢复要求。只有恢复方式存在多个会改变范围或风险的合法选项时,才升级为上表中的用户决策。 ### Red Flags | Agent 想法 | 实际风险 | |-----------|---------| -| "用户提到了主题,所以研究隐式确认" | 提到 ≠ 确认。在第一个 Node 边界暂停并确认范围。 | +| "首次调用都应该再确认一次" | 清晰输入不需要重复批准;只有互斥解释仍会改变范围时才询问。 | | "脚本返回 NEXT: auto,应该立即加载下一个 Skill" | `NEXT: auto` 表示 Node 完成,不是跳过确认。检查下一个 Node 是否有决策点。 | +| "guard 失败,所以让用户决定怎么办" | 先自动诊断并执行唯一安全修复;无合法动作时报告停止条件,不要发明选项。 | | "看起来和上次一样的话题,从上次断点继续" | 始终重新读取状态。对话记忆在上下文压缩后不可靠。 | | "Exit Check 通过了,所以工作够好了" | Exit Check 是机械的。你的职责是判断检查之外的质量——稀疏笔记、浅层分析、缺失视角,脚本抓不到。 | ``` diff --git a/assets/skills-zh/comet-any/reference/subagents/pause-points-author.md b/assets/skills-zh/comet-any/reference/subagents/pause-points-author.md index f03e9d23..f4fb3062 100644 --- a/assets/skills-zh/comet-any/reference/subagents/pause-points-author.md +++ b/assets/skills-zh/comet-any/reference/subagents/pause-points-author.md @@ -4,8 +4,7 @@ ## 职责 -设计用户在 Skill 中必须停顿选择的位置,以及跨设备断点恢复方式。停顿点必须是明确的用户选择, -不能被默认推荐、历史偏好或自动推进绕过。 +设计用户在 Skill 中真正必须选择的位置,以及跨设备断点恢复方式。必须先区分四类情况:用户决策、自动处理、停止条件和手动衔接。只有存在两个或以上会改变范围、行为、风险接受或不可逆结果的合法选项时,才创建用户停顿点;单一安全动作直接执行,缺少依赖或状态损坏只报告停止条件,手动衔接只交还控制权。真正的用户决策不能被默认推荐、历史偏好或自动推进绕过。 必须覆盖: @@ -16,11 +15,10 @@ 读取主会话提供的通用输入,尤其关注: -- Skill Creator 方案确认页中的 `confirm-generate`、`revise-proposal`、`cancel`。 -- eval 工作量选择 `skip / quick / full eval`,以及当前 draft hash 的 eval evidence 缺失或过期时的阻塞恢复。 -- 安装前人工批准。 -- unresolved、ambiguous、capability gap、executable disclosure 等阻塞点。 -- runner 恢复状态和跨设备恢复入口。 +- Skill Creator 方案确认页中的 `confirm-generate`、`revise-proposal`、`cancel`:这是会改变生成结果的用户决策。 +- eval 工作量选择 `skip / quick / full eval` 和安装前人工批准:只有确实存在多个合法选项时才是用户决策。 +- 当前 draft hash 的 eval evidence 缺失或过期、unresolved、ambiguous、capability gap、executable disclosure:逐项判断是可自动修复、无路可走的停止条件,还是确有多个恢复选项的用户决策;不得统一写成停顿点。 +- runner 恢复状态和跨设备恢复入口:复用仍有效的持久化选择,不得在恢复时重复询问。 使用文件交接:主会话提供路径,不粘贴大段全文。不要继承主会话历史;只使用本 brief、通用输入、 workflow protocol 和已有草稿。 @@ -35,21 +33,23 @@ model: <必须显式指定 model> prompt: 你是停顿点作者 subagent。 先读取本 brief、通用输入路径、workflow protocol 路径、Skill 草稿路径和报告文件路径。 - 开始前先提出问题:如果用户选择项、阻塞恢复或跨设备状态不清楚,先返回 NEEDS_CONTEXT。 - 不要猜测或自行补全缺失停顿点。 + 开始前先把候选节点分类为用户决策、自动处理、停止条件或手动衔接。如果分类所需事实缺失,先返回 NEEDS_CONTEXT。 + 不要猜测缺失选择,也不要把自动修复、guard 失败、能力缺口、单一合法动作或手动衔接伪装成用户停顿点。 只产出 decision-points 和 recovery 草稿,不写 Bundle state,不执行候选脚本。 把完整停顿点草稿写入报告文件路径,并只返回 15 行以内状态摘要。 ``` ## 输出要求 -返回停顿点草稿,说明: +返回草稿时先列分类表,再说明真正的用户停顿点: +- 每个候选节点属于用户决策、自动处理、停止条件还是手动衔接,以及判定依据。 - 每个停顿点的触发条件。 - 用户可选择项。 - 每个选择会进入哪个阶段。 - 停顿点证据写入哪里。 -- 恢复时如何显示当前阶段、阻塞原因、建议下一步和可选项。 +- 自动处理如何直接推进;停止条件如何报告恢复条件而不发明选项。 +- 恢复时如何显示当前阶段、阻塞原因、建议下一步和真实可选项,并复用已持久化选择。 停顿点必须适配当前 workflow protocol,不得只列 Comet 原始停顿点。 @@ -58,8 +58,10 @@ prompt: 返回前逐项检查: - 每个用户停顿点都有触发条件、选项、下一阶段和证据位置。 +- 每个停顿点至少有两个当前可执行的合法选项;相邻且可同时回答的选择已合并,单一合法值不会制造停顿。 +- guard 失败、确定性重试、状态对账、能力缺口和 `NEXT: manual` 已按实际情况归入自动处理、停止条件或手动衔接,而不是默认询问用户。 - 默认推荐、历史偏好和自动推进都不能绕过必须停顿点。 -- 恢复摘要能显示当前阶段、阻塞原因、建议下一步和可选项。 +- 恢复摘要能显示当前阶段、阻塞原因、建议下一步和真实可选项,且不会重复询问仍有效的选择。 - 跨设备恢复不依赖当前会话记忆。 - 停顿点适配当前组合 Skill,不只是照列 Comet 原始停顿点。 diff --git a/assets/skills-zh/comet-any/reference/subagents/skill-reviewer.md b/assets/skills-zh/comet-any/reference/subagents/skill-reviewer.md index 96008510..c9ffec7f 100644 --- a/assets/skills-zh/comet-any/reference/subagents/skill-reviewer.md +++ b/assets/skills-zh/comet-any/reference/subagents/skill-reviewer.md @@ -61,6 +61,8 @@ prompt: - 阶段推进没有通过脚本输出 `NEXT:` 和 `SKILL:` 表达。 - workflow protocol 声明的 `requiredSkillCalls` 没有在对应Node Skill 中明确要求加载,或 subagent 槽位没有要求子代理任务提示加载该 Skill。 - 用户停顿点缺失,或停顿点可被默认值绕过。 +- 把确定性修复、guard 失败、状态对账、能力缺口、单一合法动作或 `NEXT: manual` 默认写成用户停顿点;或把可同时回答的相邻选择拆成连续确认。 +- entry Skill 的 frontmatter description 没有说明它是托管 workflow 的入口/恢复路由,或 internal Node Skill 的 description 允许普通任务直接触发而未限定为显式调用或 entry/runtime 路由。 - 中文 Skill 混入英文流程句。 - 嵌套 Skill 调用使用 provider 前缀。 - 用户可见 `SKILL.md` 泄漏生成审计章节、source hash 或内部 metadata。 diff --git a/assets/skills-zh/comet-any/reference/subagents/workflow-entry-author.md b/assets/skills-zh/comet-any/reference/subagents/workflow-entry-author.md index f4851479..54ad065d 100644 --- a/assets/skills-zh/comet-any/reference/subagents/workflow-entry-author.md +++ b/assets/skills-zh/comet-any/reference/subagents/workflow-entry-author.md @@ -20,7 +20,7 @@ - **语义化当前节点检测** — 如何判断用户在哪个 Node,而非只跑脚本。建模 comet 的 Step 0(从用户消息检测意图,检查 Node 顺序,处理"属于前序/后序 Node"的冲突)+ Step 1(读状态,文件优先于过期状态)。 - **Resume 与 drift 规则** — 上下文恢复时怎么办(从头重新检测,永远不信任对话历史),状态说 DONE 但 artifact 缺失时怎么办,用户在 Node 中途换话题时怎么办。 -- **决策点** — 必须暂停等用户确认的情况的显式表格(首次调用确认范围、Node 歧义、用户确认、guard 失败)。 +- **决策分类与决策点** — 先区分用户决策、自动处理、停止条件和手动衔接,再只为真正需要用户选择的情况建表。清晰的首次调用、可确定修复的 guard 失败、单一合法下一步和 `NEXT: manual` 都不得制造确认点。 - **Red flags** — "agent 想法 → 实际风险"模式,抓自欺(如"用户提到了主题所以研究已确认" → 提到 ≠ 确认)。 没有这四个子节的 Decision Core 是 stub,不是 Decision Core。entry 是每次调用最先读取的文件——它决定了 Skill 感觉"智能"还是"机械"。 @@ -48,10 +48,10 @@ model: <必须显式指定 model> prompt: 你是 workflow entry 作者 subagent。 先读取本 brief、通用输入路径、脚本契约路径、workflow protocol 路径和报告文件路径。 - 开始前先提出问题:如果启动路由、恢复路径、当前阶段判定或用户停顿点不清楚,先返回 NEEDS_CONTEXT。 + 开始前先分类用户决策、自动处理、停止条件和手动衔接;如果启动路由、恢复路径、当前阶段判定或真正的用户选择不清楚,先返回 NEEDS_CONTEXT。 不要猜测或自行补全缺失流程。 只写 entry SKILL.md 草稿,不写 internal Node Skill,不写 Bundle state,不执行候选脚本。 - Decision Core 必须包含四个子节:### 自动节点检测(Step 0 意图检测 + Step 1 状态读取 + Resume 规则)、### 决策点(显式暂停表格)、### Red Flags(agent 想法 → 实际风险表格)。没有这些子节的 Decision Core 是 stub。 + Decision Core 必须包含四个子节:### 自动节点检测(Step 0 意图检测 + Step 1 状态读取 + Resume 规则)、### 决策分类与决策点(只列真正的用户选择)、### Red Flags(agent 想法 → 实际风险表格)。不得把 guard 失败、确定性修复、单一合法动作或手动衔接列为用户决策。没有这些子节的 Decision Core 是 stub。 把完整 entry 草稿写入报告文件路径,并只返回 15 行以内状态摘要。 ``` @@ -64,7 +64,7 @@ entry 草稿必须体现: - 只有脚本输出 `NEXT: auto` 和 `SKILL: ` 后,才加载这一个 Node Skill。 - 阶段路线只能作为参考表,不能使用“立即执行”或“必须加载”这类执行指令。 - 对 `/comet` 定制,entry 必须列出必调槽位 Skill,但只能作为阶段内义务说明,不能变成 entry 立即执行清单。 -- 用户停顿点、恢复路径和参考文件清楚可见。 +- 用户决策、自动处理、停止条件、手动衔接、恢复路径和参考文件清楚可见;只有至少两个真实合法选项时才停顿,相邻选择应合并。 - 对 `/comet` 定制,说明保留 open / design / build / verify / archive 主路径和阶段守卫。 禁止: @@ -83,6 +83,7 @@ entry 草稿必须体现: - entry 没有立即加载Node Skill 的清单。 - 阶段路线是参考,不是执行步骤。 - 自动推进引用脚本输出的 `NEXT:` 和 `SKILL:`。 +- 清晰首次调用直接初始化;guard 失败先自动诊断或报告停止条件;`NEXT: manual` 只交还控制权;以上情况均不伪造用户决策。 - 中文用户可见文案没有混入英文流程句。 ## 必须返回的 claim diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 3c28df29..1f0a0f31 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-archive -description: "Use when Comet change 验证已通过,需要用户确认归档、合并 delta spec,或恢复 archive 阶段。" +description: "仅在用户明确调用 /comet-archive,或由 Comet 根 Skill/runtime 路由到 archive 阶段时使用;确认归档、合并 delta spec 并完成分支收尾。" --- # Comet 阶段 5:归档(Archive) @@ -8,7 +8,7 @@ description: "Use when Comet change 验证已通过,需要用户确认归档 ## 前置条件 - 验证已通过(阶段 4 完成) -- 分支已处理 +- 归档提交和分支处理尚未完成(`branch_status: pending`) - `openspec/changes//.comet.yaml` 中 `verify_result: pass` ## 步骤 @@ -35,7 +35,7 @@ comet state check archive 确认前必须向用户展示简短摘要: - change 名称 - 验证报告路径和结论 -- 分支处理状态 +- 当前分支/工作区和未提交改动归因摘要 - 本次归档将执行的不可逆动作:按 OpenSpec delta 语义合并主 spec、标注 design doc / plan、移动 change 到 archive 目录 用户确认问题必须以单选题形式呈现,包含以下选项: @@ -70,11 +70,6 @@ comet archive "" 如脚本返回非零退出码,报告错误并停止。 如脚本返回零退出码,归档完成。 -归档成功后清理当前执行上下文;该命令幂等: - -```bash -comet state clear-selection -``` 脚本摘要中的 `X/Y steps succeeded` 以真实执行步骤计数,不会因 delta spec 同步或文档标注重复累计。 脚本会调用 OpenSpec 归档能力按 `ADDED/MODIFIED/REMOVED/RENAMED` 语义合并主 spec,并在归档后校验主 spec 中没有残留 delta-only section 标题。 @@ -88,31 +83,57 @@ Spec 生命周期在此完成: brainstorming → delta spec → 实施 → 验证 → 主 spec 合并 → design doc 标注 → 归档 ``` -### 4. 提交归档改动 +### 4. 精确提交归档改动 归档脚本只移动文件和合并 spec,不会自动提交。归档完成后工作区会有以下未提交改动: - change 目录从 `openspec/changes//` 移动到 `openspec/changes/archive/YYYY-MM-DD-/` - 主 spec 按 delta 语义合并的内容 - design doc / plan 的归档元数据标注 -**必须提示用户提交这些归档改动**,否则归档成果会停留在工作区。展示待提交文件后建议执行: +归档后先读取 `git status --short`,并以归档前的 dirty-worktree 归因记录为基线。只允许暂存可归因于当前 change 的路径:原 active change 路径、脚本输出的实际 archive 路径、被本次 delta 更新的 main specs,以及当前 Design Doc/Plan 的归档元数据。存在无法归因的路径时停止并请求用户处理。 + +使用显式 pathspec 暂存核对后的路径,再检查 staged diff;不得使用全仓库暂存,也不得把用户已有改动混入归档提交: ```bash -git add -A +git add -- <逐项核对后的归档路径...> +git diff --cached --stat git commit -m "chore: archive " ``` -如分支处理(阶段 4)选择尚未合并到主分支,提交后按所选方式(合并 / PR / 保持分支)一并收尾。 +提交失败或 staged diff 含无关路径时停止,不得继续分支处理。 + +### 5. 归档提交后的分支处理 + +归档提交成功后,**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。该步骤必须位于归档与归档提交之后,确保最终分支/PR 包含 spec 合并和归档元数据。 + +如该技能不可用,停止流程并提示安装或启用;不得把 `branch_status` 标记为完成。技能加载后,按 `comet/reference/decision-point.md` 暂停让用户选择: + +1. 本地合并到主分支 +2. 推送并创建 PR +3. 保持当前分支稍后处理 + +归档已经完成,因此这里不提供“丢弃工作”选项。只有用户选择的操作成功完成(或明确选择保持分支)后,才运行: + +```bash +comet state set branch_status handled +comet guard archive +comet state clear-selection +``` + +archive guard 必须同时确认归档产物完整且 `branch_status: handled`;失败时流程仍未完成。 ## 退出条件 - 归档脚本执行成功(退出码 0) - 归档目录 `openspec/changes/archive/YYYY-MM-DD-/` 存在 - 归档后的 `.comet.yaml` 中 `archived: true` +- 归档改动已通过精确 pathspec 提交 +- 用户选择的分支处理已完成,归档状态中的 `branch_status: handled` +- `comet guard archive` 通过 归档脚本会把 `openspec/changes//` 移动到 `openspec/changes/archive/YYYY-MM-DD-/`。 -> **WARNING**: 归档成功后**不要再对原 change 名运行** `comet guard archive`,因为原活跃目录已经不存在。误调会导致 guard 报错"change directory not found"。归档完整性以脚本退出码和归档目录状态为准。 +`comet guard archive` 会按原 change 名解析实际归档目录;不要手工拼接日期目录名。 ## 完成 diff --git a/assets/skills-zh/comet-build/SKILL.md b/assets/skills-zh/comet-build/SKILL.md index 95fe1f3d..a7164057 100644 --- a/assets/skills-zh/comet-build/SKILL.md +++ b/assets/skills-zh/comet-build/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-build -description: "Use when full Comet change 已完成 design 阶段,需要创建/恢复实施计划、选择执行方式或继续 build 阶段任务。" +description: "仅在用户明确调用 /comet-build,或由 Comet 根 Skill/runtime 路由到 full workflow 的 build 阶段时使用;创建或恢复实施计划并执行任务。" --- # Comet 阶段 3:计划与构建(Build) @@ -23,7 +23,7 @@ comet state check build 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 -**幂等性**:build 阶段所有操作可安全重复执行。读取 `.comet.yaml` 的 `phase` 字段确认仍在 build 阶段,读取 plan 文件头的 `base-ref`,再用 `grep -n '\- \[ \]' tasks.md | head -1` 找到第一个未勾选任务继续执行。已提交的任务不得重复提交。 +**幂等性**:build 阶段所有操作可安全重复执行。读取 `.comet.yaml` 的 `phase` 字段确认仍在 build 阶段,读取 plan 文件头的 `base-ref`,再按文档顺序解析 tasks.md 的复选框,从第一个未勾选任务继续执行。已提交的任务不得重复提交。 ### 1. 制定计划(Subagent Offload) @@ -65,7 +65,7 @@ Subagent 完成后: - 若返回有效文件路径且文件存在,记录为 plan - 若 subagent 失败或返回路径无效,在主 session 内联加载 Superpowers `writing-plans` 技能创建计划(降级回退) -### 2. 更新计划状态并提供 plan-ready 暂停点 +### 2. 更新计划状态并联合确认工作方式 先记录 plan 路径: @@ -75,16 +75,18 @@ comet state set plan docs/superpowers/plans/YYYY-MM-DD-feature.md 无需手动更新 phase,阶段守卫(guard `--apply`)会在退出条件满足后推进 `phase` 字段。 -计划写入后,立即提供一个新的用户决策点: +展示联合决策前先检查当前平台能力:确认 `using-git-worktrees` 是否可用、是否存在真实后台 subagent/Task/multi-agent 调度能力,以及当前仓库能否安全创建分支。只展示当前真实可执行的隔离与执行选项;某个字段只剩一个合法值时说明原因并直接采用,不为单选项制造额外停顿。 + +计划写入后只提供**一个联合决策点**,一次收集:是否现在继续、可用的工作区隔离、可用的执行方式、TDD 模式和代码审查模式。选择 `branch` 时,分支名也必须在 Step 2 的同一个联合决策中确认或由用户覆盖。不得先询问“继续/暂停”,继续后又创建第二个配置或命名阻塞点。 | 选项 | 行为 | 说明 | |------|------|------| -| A | 继续执行 | 保持在当前模型中,进入 Step 3 选择工作区隔离、执行方式、TDD 模式和代码审查模式 | +| A | 继续执行并提交配置 | 在同一次回复中选择 Step 3 的隔离、执行、TDD 和审查配置;如选择 branch,同时提交分支名 | | B | 暂停切换模型 | 记录 `build_pause: plan-ready`,本次 `/comet-build` 停止,用户稍后可从 `/comet` 或 `/comet-build` 恢复 | -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**,不得自动继续,也不得把暂停写入 `build_mode`。 +这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议一次性展示计划摘要、暂停选项和 Step 3 全部可执行配置**。用户选择继续时,必须在同一回复中给出所有配置以及条件性的分支名;不得自动选择,也不得把暂停写入 `build_mode`。 -用户选择继续时: +用户选择继续并给出完整配置时: ```bash comet state set build_pause null @@ -98,17 +100,17 @@ comet state set build_pause plan-ready 设置 `build_pause: plan-ready` 后,当前调用停止。不要选择 `isolation` 或 `build_mode`,不要加载执行技能。 -### 3. 选择工作方式 +### 3. 应用已确认的工作方式 -如果恢复时检测到 `build_pause: plan-ready` 且 `plan` 文件存在,不要重新运行 `writing-plans`。先告知用户当前停在 plan-ready 暂停点;用户确认继续后,设置: +如果恢复时检测到 `build_pause: plan-ready` 且 `plan` 文件存在,不要重新运行 `writing-plans`。重新发起 Step 2 的同一个联合决策;只有用户同时给出完整配置后才清除暂停: ```bash comet state set build_pause null ``` -然后继续本步骤选择工作区隔离、执行方式、TDD 模式和代码审查模式。 +然后应用本步骤中的工作区隔离、执行方式、TDD 模式和代码审查模式。 -计划已写入当前分支。在开始执行前,**一次性询问用户**选择工作区隔离方式、执行方式、TDD 模式和代码审查模式: +计划已写入当前分支。以下配置必须由 Step 2 的联合决策一次性确认: **工作区隔离**: @@ -133,7 +135,7 @@ comet state set build_pause null - 任务数 ≤ 2 且无跨模块依赖 → 推荐 B - 来自 hotfix 路径 → 推荐 B -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择隔离方式、执行方式、TDD 模式和代码审查模式**,不得根据推荐规则自行选择 `branch` 或 `worktree`,也不得根据推荐规则自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 +这些表格是 Step 2 联合决策的一部分,不再单独暂停。先移除能力预检判定为不可执行的选项;在剩余多个合法选项时,不得根据推荐规则自行选择 `branch` 或 `worktree`,也不得自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 用户选择后,更新 `isolation`、执行方式、TDD 模式和代码审查模式相关字段: @@ -143,14 +145,14 @@ comet state set isolation - 若用户选择 `executing-plans`:运行 `comet state set subagent_dispatch null`,再运行 `comet state set build_mode executing-plans` - 若用户选择 `subagent-driven-development`:先确认当前平台存在可调用的真实后台 subagent / Task / multi-agent 调度能力;确认后先运行 `comet state set subagent_dispatch confirmed`,再运行 `comet state set build_mode subagent-driven-development` -- 若无法确认真实后台调度能力,不得写入 `build_mode: subagent-driven-development`;必须暂停等待用户改选 `executing-plans` +- 若无法确认真实后台调度能力,不得展示或写入 `build_mode: subagent-driven-development`。恢复状态若已记录该模式但能力不可用,回到 Step 2 的同一个联合决策并只展示可执行模式;不得另设“改选 executing-plans”停顿点 **TDD 模式**: | 选项 | 含义 | 适用场景 | |------|------|---------| | `tdd` | 每个任务先写失败测试再写实现 | 推荐。变更涉及业务逻辑、新功能、API | -| `direct` | 直接实现,不强制 TDD 流程 | 变更不需要测试覆盖,或用户选择跳过测试直接写代码。hotfix/tweak 预设默认使用 `direct` | +| `direct` | 实现优先,不强制逐任务 Red-Green-Refactor | 仍需运行相关测试并为 bug 修复保留回归证据;hotfix/tweak 预设默认使用 `direct` | 运行 `comet state set tdd_mode ` @@ -183,18 +185,18 @@ comet state set build_mode direct **执行隔离**: -- **branch**:根据 workflow 类型和当前日期推荐分支名,然后让用户确认或输入自定义名称。这是用户决策点——**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确确认或覆盖分支名**,不得跳过此步骤直接创建分支。 +- **branch**:使用 Step 2 已确认的分支名,不得再次暂停。若旧状态恢复时缺少该次联合决策中的分支名,重新进入 Step 2 的同一个联合决策;不得创建第二个独立分支命名决策点。 分支命名规范: - 读取 `.comet.yaml` 的 `workflow` 字段确定前缀 - `workflow: full` → 推荐 `feature/YYYYMMDD/` - `workflow: hotfix` → 推荐 `hotfix/YYYYMMDD/` - `workflow: tweak` → 推荐 `tweak/YYYYMMDD/` - - 日期取运行时 `date +%Y%m%d` 的结果 + - 日期取当前运行环境日期并格式化为 `YYYYMMDD`,不得依赖某一种 shell 的日期命令 示例:如果 change 名称为 `fix-login-bug`,今天是 2026-06-09,则推荐 `feature/20260609/fix-login-bug` - 用户确认或提供自定义分支名后,执行 `git checkout -b `,后续工作在新分支上进行。 + 分支名由 Step 2 确认后,立即执行 `git checkout -b `,后续工作在新分支上进行。 - **worktree**:必须使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能创建隔离工作区。禁止用普通 shell 命令或原生工具绕过该技能;如该技能不可用,停止流程并提示安装或启用 Superpowers 技能。 @@ -217,7 +219,7 @@ comet state select - `build_mode: executing-plans`:**立即执行:** 使用 Skill 工具加载 Superpowers `executing-plans` 技能。禁止跳过此步骤。若该技能不可用,停止流程并提示安装或启用对应技能,不要用普通对话替代该步骤。技能加载后,ARGUMENTS 必须包含与 Step 1 相同的 Language 约束:`Language: 使用 comet state get language 读取到的 Comet 配置产物语言输出`。按计划执行。 - `build_mode: subagent-driven-development`:主会话只负责协调,禁止直接编写实现代码。**立即执行:** 使用 Skill 工具加载 Superpowers `subagent-driven-development` 技能。技能加载后,读取 `comet/reference/subagent-dispatch.md` 获取 Comet 专属扩展(真实后台调度、任务隔离、勾选验证、TDD 约束、连续执行、上下文恢复),与技能工作流配合应用。若两者发生冲突,以更具体的 Comet 扩展为准。 -- 如果当前平台没有真实后台 agent 调度能力,必须暂停并等待用户选择改用主窗口执行。用户选择改用主窗口执行后,必须先运行 `comet state set build_mode executing-plans`,再按 `build_mode: executing-plans` 分支加载 Superpowers `executing-plans` 技能。用户未明确选择前,不得继续执行任务。 +- 如果执行前复检发现后台调度能力已失效,不得直接在主窗口执行,也不得创建新的二次决策;返回 Step 2 的同一个联合决策,移除不可用模式。用户在该联合决策中选择主窗口执行后,先运行 `comet state set build_mode executing-plans`,再按对应分支继续。 **TDD 模式执行约束**: @@ -237,7 +239,7 @@ comet state select 要求(适用于 `standard` 和 `thorough`): - `requesting-code-review` 技能必须在 `comet guard build --apply` 之前加载 -- 若 `requesting-code-review` 技能不可用,跳过 review gate 但必须在 tasks.md 中记录 ``,并继续 guard 流转 +- 若 `requesting-code-review` 技能不可用且当前为 `standard` 或 `thorough`,必须停止并请用户选择:安装/启用后重试,或明确切换为 `review_mode: off` 并记录原因。用户未明确切换前不得跳过 review gate 或继续 guard - CRITICAL review 发现(安全漏洞、数据丢失风险、构建/测试失败)必须先修复,不得带入 verify - 非 CRITICAL review 发现如选择接受,必须在 tasks.md、commit body、验证报告草稿或其他持久产物中记录接受原因和影响范围 @@ -275,7 +277,7 @@ comet state select Build 是最长阶段,可能跨越大量任务。为支持上下文压缩后断点恢复: -- **每完成一个 task**:按当前执行分支和 `review_mode` 完成验收后再勾选对应任务并提交。`subagent-driven-development` 在 `off` 时不派发每任务 reviewer;`standard` 下仅当任务命中风险信号时派发;`thorough` 下每个任务都派发每任务 reviewer。所有模式都必须按任务唯一文本完成定向检查。可用 `grep -c '\- \[ \]' tasks.md` 检查剩余未勾选数,无需重新读取整个文件 +- **每完成一个 task**:按当前执行分支和 `review_mode` 完成验收后再勾选对应任务并提交。`subagent-driven-development` 在 `off` 时不派发每任务 reviewer;`standard` 下仅当任务命中风险信号时派发;`thorough` 下每个任务都派发每任务 reviewer。所有模式都必须按任务唯一文本完成定向检查。通过解析 tasks.md 复选框统计剩余任务,无需反复读取与当前任务无关的正文 - **上下文压缩后恢复**:按 `comet/reference/context-recovery.md` 执行,phase 参数为 `build`。 - **用户手动修改恢复**:按 `comet/reference/dirty-worktree.md` 协议处理未提交改动。该协议定义了检查步骤、归因分类和禁令。build 阶段的特殊处理: 1. 归因后,若 diff 暗示计划或 spec 已变化,按 Step 4「Spec 增量更新」分级处理 @@ -320,5 +322,5 @@ comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 进入下一阶段 -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 diff --git a/assets/skills-zh/comet-design/SKILL.md b/assets/skills-zh/comet-design/SKILL.md index 76ab1fc1..6eccc43f 100644 --- a/assets/skills-zh/comet-design/SKILL.md +++ b/assets/skills-zh/comet-design/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-design -description: "Use when full Comet change 已完成 open 阶段但缺少 Superpowers Design Doc,或 design 阶段需要从 OpenSpec 交接包恢复。" +description: "仅在用户明确调用 /comet-design,或由 Comet 根 Skill/runtime 路由到 full workflow 的 design 阶段时使用;创建或恢复深度技术 Design Doc。" --- # Comet 阶段 2:深度设计(Design) @@ -20,7 +20,7 @@ description: "Use when full Comet change 已完成 open 阶段但缺少 Superpow ```bash comet state select -node "$COMET_STATE" check design +comet state check design ``` 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 @@ -32,7 +32,7 @@ node "$COMET_STATE" check design **必须由脚本生成,不允许 agent 临场手写 summary 代替。** ```bash -node "$COMET_HANDOFF" design --write +comet handoff design --write ``` 脚本会根据 change `.comet.yaml` 的 `context_compression` 快照生成并记录交接包。 @@ -71,7 +71,7 @@ beta 交接包是 **结构化 spec projection**,用于减少 OpenSpec 原文 t 如确实需要全文上下文,可显式运行: ```bash -node "$COMET_HANDOFF" design --write --full +comet handoff design --write --full ``` 交接包来源来自 OpenSpec open 阶段产物: @@ -87,7 +87,7 @@ node "$COMET_HANDOFF" design --write --full 技能加载时,ARGUMENTS 必须包含: ```text -Language: 使用 `"$COMET_BASH" "$COMET_STATE" get language` 读取到的 Comet 配置产物语言输出 +Language: 使用 `comet state get language` 读取到的 Comet 配置产物语言输出 ``` 技能加载后,按其指引使用以下上下文: @@ -147,9 +147,7 @@ brainstorming 产出设计方案后,**必须按 `comet/reference/decision-poin 用户确认设计方案后,在创建 Design Doc 前,创建或更新已增量维护的检查点文件,将其定稿为确认后的设计方案摘要: -```bash -mkdir -p openspec/changes//.comet/handoff -``` +使用当前平台的文件能力确保 `openspec/changes//.comet/handoff/` 存在;不要依赖 POSIX 专用目录命令。 `openspec/changes//.comet/handoff/brainstorm-summary.md` 结构: @@ -181,14 +179,9 @@ mkdir -p openspec/changes//.comet/handoff - `openspec/changes//.comet/handoff/design-context.md`(或 beta 模式的 `spec-context.md`) - `openspec/changes//.comet/handoff/design-context.json`(或 beta 模式的 `spec-context.json`) -### 1e. 主动式上下文压缩 - -完成 Step 1d 并确认 `brainstorm-summary.md` 已写入后,进入 Design Doc 创建前的主动式上下文压缩。此时 OpenSpec 交接包、brainstorming 决策和待确认项都已落盘,应主动释放前面读取 Spec 和 brainstorming 消耗的上下文,为 Step 2 及后续 Build 阶段保留窗口。 +### 1e. 压缩策略(此处不阻塞) -执行规则: -- 如果当前平台提供原生上下文压缩/清理机制(例如宿主 Agent 的 compact/compaction 命令、工具或 UI 操作),必须在这里触发一次主动压缩;不要尝试用 shell 脚本伪造压缩命令。 -- 压缩恢复提示必须包含 change 名称、当前步骤(Design Step 2)、以及上方三类需重新加载的 handoff 文件。 -- 如果当前平台无法由 agent 程序化触发压缩,必须暂停并提示用户在宿主平台执行手动压缩;用户确认无法压缩或要求继续时,才继续 Step 2。 +`brainstorm-summary.md` 是恢复检查点,但 Design Doc 尚未落盘时不得主动丢弃当前设计上下文。直接进入 Step 2;上下文压缩移到 Design Doc、状态和最新 handoff 全部持久化之后执行。 ### 2. 创建 Design Doc @@ -215,17 +208,25 @@ canonical_spec: openspec ```bash # 记录 design_doc 路径 -node "$COMET_STATE" set design_doc docs/superpowers/specs/YYYY-MM-DD-topic-design.md +comet state set design_doc docs/superpowers/specs/YYYY-MM-DD-topic-design.md # 如有 delta spec 变更,重新生成 handoff(更新 hash) -node "$COMET_HANDOFF" design --write +comet handoff design --write # 阶段守卫推进 phase 到下一阶段 -node "$COMET_GUARD" design --apply +comet guard design --apply ``` 如果没有 delta spec 变更,跳过 handoff 重新生成步骤。状态文件自动更新,无需手动编辑其他字段。 +### 3a. 可选主动式上下文压缩 + +只在 **Design Doc 和状态证据落盘后**、进入 Build 前考虑主动式压缩。先确认 `design_doc`、最新 handoff、`handoff_hash` 和 design guard 均已成功持久化;这样压缩后可从文件恢复,不会丢失尚未写入的设计判断。 + +- 当前平台提供可由 agent 调用的原生压缩机制,且上下文窗口确有压力时,可以触发一次,并在恢复提示中列出 change、下一步和需重新加载的 Design Doc/handoff 文件 +- 当前平台只能由用户手动压缩时,给出一次非阻塞建议并继续;**无法程序化触发时不得阻塞**、不得额外制造确认点 +- 不得用 shell 命令或摘要替代宿主平台的真实压缩机制 + ## 退出条件 - Design Doc 已创建并保存 @@ -236,12 +237,12 @@ node "$COMET_GUARD" design --apply - beta 模式下,`spec-context.json` 必须结构合法且引用当前源文件(由 guard 强制校验) - 如有新能力或补充验收场景,OpenSpec delta spec 已创建/更新 - `design_doc` 已写入 `.comet.yaml` -- **阶段守卫**:运行 `node "$COMET_GUARD" design --apply`,全部 PASS 后由守卫推进到 `phase: build`(此步骤更新 `phase` 字段,与 `auto_transition` 无关) +- **阶段守卫**:运行 `comet guard design --apply`,全部 PASS 后由守卫推进到 `phase: build`(此步骤更新 `phase` 字段,与 `auto_transition` 无关) 退出前必须使用 `--apply`: ```bash -node "$COMET_GUARD" design --apply +comet guard design --apply ``` ## 上下文压缩恢复 @@ -253,9 +254,9 @@ node "$COMET_GUARD" design --apply 按 `comet/reference/auto-transition.md` 执行。关键命令: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 进入下一阶段 -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 diff --git a/assets/skills-zh/comet-hotfix/SKILL.md b/assets/skills-zh/comet-hotfix/SKILL.md index c9f17744..9040e339 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-hotfix -description: "Use when 用户要修复已有行为 bug,且不新增 capability、不需要完整设计;也用于恢复 hotfix workflow。" +description: "仅在用户明确调用 /comet-hotfix,或由 Comet 根 Skill/runtime 路由到 hotfix preset 时使用;修复已有行为 bug,不用于普通的未托管 bugfix。" --- # Comet 预设路径:Hotfix @@ -20,7 +20,7 @@ description: "Use when 用户要修复已有行为 bug,且不新增 capability ### 0. 输出语言约束 -精简版 OpenSpec 产物必须使用 Comet 配置产物语言。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`,初始化后使用 `"$COMET_BASH" "$COMET_STATE" get language` 读取。 +精简版 OpenSpec 产物必须使用 Comet 配置产物语言。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`,初始化后使用 `comet state get language` 读取。 执行链路:open → build → verify → archive。Hotfix 为每个阶段提供默认决策:精简开启、直接构建、按规模验证、验证通过后进入归档前最终确认。 @@ -34,52 +34,54 @@ description: "Use when 用户要修复已有行为 bug,且不新增 capability **立即执行:** 使用 Skill 工具加载 `openspec-new-change` 技能。禁止跳过此步骤。 -技能加载后,按其指引创建精简版产物: - - `proposal.md` — 问题描述 + 根因分析 + 修复目标(无需方案对比) - - `design.md` — 修复方案(1 个即可,无需多方案对比) - - `tasks.md` — 修复任务清单 -- **无需 delta spec**(除非修复改变了已有 spec 的验收场景) - -初始化 Comet 状态文件: +技能加载后先创建 change 骨架,立即初始化可恢复状态并绑定当前 change: ```bash -node "$COMET_STATE" init hotfix +comet state init hotfix +comet state select +comet state check open ``` -初始化后验证状态: - -```bash -node "$COMET_STATE" check open -``` +hotfix 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能把它改为 `branch` 或 `worktree`。随后按指引创建精简版产物: + - `proposal.md` — 问题描述 + 根因分析 + 修复目标(无需方案对比) + - `design.md` — 修复方案(1 个即可,无需多方案对比) + - `tasks.md` — 修复任务清单 +- **无需 delta spec**(除非修复改变了已有 spec 的验收场景) 阶段守卫完成 open → build 过渡: ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` 检查 `auto_transition` 决定是否继续: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → 继续 Step 2 -- `NEXT: manual` → 暂停,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 按 `HINT` 交还控制权并结束当前调用;不要再询问用户是否继续 ### 2. 直接构建(预设 build) -使用 hotfix 默认值:`build_mode: direct`,`review_mode: off`(hotfix/tweak 跳过 review_mode 选择——guard 不要求预设工作流选择此项)。跳过 Superpowers `brainstorming` 和 `writing-plans`(除非任务 > 3 个;若超过 3 个任务,转入 `/comet-build` 的计划与执行方式选择——注意这不触发 full workflow 升级,仅切换执行方式)。 +使用 hotfix 默认值:`build_mode: direct`、`tdd_mode: direct`、`review_mode: off`、`isolation: current`。`direct` 表示不进入完整规划/TDD 编排,不表示可以跳过复现、回归测试或验证。跳过 Superpowers `brainstorming` 和 `writing-plans`;**任务数量本身不触发 `/comet-build`**,任务较多时仍在当前 hotfix 的 tasks.md 中按顺序执行,只有命中后文质变信号或范围 tripwire 才交给用户决定是否升级 full。 继续或开始修改前,按 `comet/reference/dirty-worktree.md` 协议处理未提交改动。若归因后发现修复命中质变信号或文件数 tripwire,按本文件「升级判定」处理。 -**立即执行:** 按 tasks.md 逐个执行任务: +修改实现前,必须**先复现问题并记录失败证据**: + +1. 用最小可重复步骤确认用户报告的旧行为确实失败,并记录命令、输入和实际结果 +2. 能自动化时先新增一个会失败的回归测试并实际运行,确认失败原因对应本 bug,而不是环境或测试本身错误 +3. 暂时无法自动化时,在 proposal/验证报告中记录不可自动化原因和可重复的手工失败证据;不得无证据直接改代码 + +完成 RED 证据后,按 tasks.md 逐个执行任务: 1. 读取 `openspec/changes//tasks.md`,获取未完成任务列表 2. 对每个未完成任务: - 根据任务描述修改代码 - 运行项目格式化命令(如 `mvn spotless:apply`、`npm run format` 等) - - 运行相关测试确认通过 + - 先运行新增的失败回归测试确认转绿,再运行相关测试确认通过 - 将 tasks.md 中对应 `- [ ]` 勾选为 `- [x]` - 提交代码,commit message 格式:`fix: <简述修复>` 3. 全部任务完成后,显式运行项目相关测试和构建命令 @@ -107,7 +109,7 @@ node "$COMET_STATE" next 根因确认消除后,运行阶段守卫完成 build → verify 过渡: ```bash -node "$COMET_GUARD" build --apply +comet guard build --apply ``` 状态文件自动更新为 `phase: verify`、`verify_result: pending`,然后进入验证。 @@ -118,7 +120,7 @@ node "$COMET_GUARD" build --apply **立即执行:** 使用 Skill 工具加载 `comet-verify` 技能。禁止跳过此步骤。 -无 delta spec 的小范围 hotfix 通常满足轻量验证条件(≤ 3 tasks、改动文件数低于 scale 阈值),comet-verify 的规模评估会选择轻量验证路径(6 项快速检查;默认 `review_mode: off` 时不自动派发代码审查)。若用户希望增加审查,可在验证前运行 `node "$COMET_STATE" set review_mode standard` 或 `thorough`。若 hotfix 创建了 delta spec,则根据 comet-verify 的规模评估规则进入完整验证路径。 +无 delta spec 的小范围 hotfix 通常满足轻量验证条件(≤ 3 tasks、改动文件数低于 scale 阈值),comet-verify 的规模评估会选择轻量验证路径(6 项快速检查;默认 `review_mode: off` 时不自动派发代码审查)。若用户希望增加审查,可在验证前运行 `comet state set review_mode standard` 或 `thorough`。若 hotfix 创建了 delta spec,则根据 comet-verify 的规模评估规则进入完整验证路径。 验证通过后,按 `/comet-verify` 的规则将 `.comet.yaml` 的 `verify_result` 记录为 `pass`,归档前不得跳过该状态。验证通过后仍必须进入 `/comet-archive` 的归档前最终确认,不得自动运行归档脚本。 @@ -134,12 +136,11 @@ node "$COMET_GUARD" build --apply ## 连续执行模式 -Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,agent 在 hotfix 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)停下,由用户手动运行下一阶段命令——此时连续执行降级为逐阶段手动推进,详见下方「自动衔接下一阶段」。但无论 `auto_transition` 取何值,以下情况都必须暂停等待用户确认: +Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,agent 在 hotfix 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 hotfix 流程,还是升级为完整 `/comet` 流程 -2. 任务超过 3 个转入 `/comet-build` 时的工作区隔离和执行方式选择 -3. 验证阶段(comet-verify)的验证失败决策和分支处理决策 -4. 归档前最终确认(comet-archive 执行归档脚本前) +2. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 +3. 归档前最终确认,以及归档提交后的分支处理决策 执行顺序:快速开启 → 直接构建 → 根因消除检查 → 验证 → 归档 → 完成 @@ -150,7 +151,7 @@ Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,age ## 升级判定 -hotfix 的升级判定只决定是否从预设流程转为 full;文件数不自动升级,`comet-state scale` 只决定验证轻重。 +hotfix 的升级判定只决定是否从预设流程转为 full;文件数不自动升级,`comet state scale` 只决定验证轻重。 若由 `/comet` 入口传入 intent frame,hotfix 在 build 前只复核 `risk_signal` 和升级信号:新增 capability、public API、schema 变更、跨模块协调或深层架构问题。命中时进入现有升级决策点;不得重新实现入口意图识别。 @@ -163,10 +164,10 @@ hotfix 的升级判定只决定是否从预设流程转为 full;文件数不 用户选择升级(选项 B)后,使用状态机合法的升级通道,单条命令完成预设流程 → full 转换并回退到 design 阶段: ```bash -node "$COMET_STATE" transition preset-escalate +comet state transition preset-escalate ``` -该命令原子地把 `workflow`/`classic_profile` 置为 `full`、`phase` 回退到 `design`、清空 `design_doc`(满足 comet-design 入口要求)。然后在当前 change 基础上补充 Design Doc:**立即使用 Skill 工具加载 `comet-design` skill**,后续正常走完整流程。 +该命令原子地把 `workflow`/`classic_profile` 置为 `full`、`phase` 回退到 `design`、清空 `design_doc`,并清除预设专属的 `build_mode`、`tdd_mode`、`review_mode`、`isolation` 和 `verify_mode`。然后在当前 change 基础上补充 Design Doc:**立即使用 Skill 工具加载 `comet-design` skill**;进入 build 后必须重新进行一次完整的联合工作方式选择。 用户选择继续(选项 A)时,继续 hotfix 流程,并记录用户确认继续的原因。 @@ -177,16 +178,16 @@ node "$COMET_STATE" transition preset-escalate - Bug 已修复,测试通过 - change 已归档 - 如有 spec 变更,已同步到 main spec -- **阶段守卫**:build → verify 前运行 `node "$COMET_GUARD" build --apply`,verify → archive 前按 `/comet-verify` 规则运行 `node "$COMET_GUARD" verify --apply` +- **阶段守卫**:build → verify 前运行 `comet guard build --apply`,verify → archive 前按 `/comet-verify` 规则运行 `comet guard verify --apply` ## 自动衔接下一阶段 按 `comet/reference/auto-transition.md` 执行。关键命令: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 继续 hotfix 流程(`phase: build` 返回 `comet-hotfix`,`verify` 返回 `comet-verify`,`archive` 返回 `comet-archive`) -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 diff --git a/assets/skills-zh/comet-open/SKILL.md b/assets/skills-zh/comet-open/SKILL.md index eef421ca..5eefa28b 100644 --- a/assets/skills-zh/comet-open/SKILL.md +++ b/assets/skills-zh/comet-open/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-open -description: "Use when Comet 需要创建新的 OpenSpec change,或 active change 缺少 proposal/design/tasks/.comet.yaml 初始化产物。" +description: "仅在用户明确调用 /comet-open,或由 Comet 根 Skill/runtime 路由到 open 阶段时使用;创建或恢复 OpenSpec change,并补齐 proposal/design/tasks/.comet.yaml。" --- # Comet 阶段 1:开启(Open) @@ -13,11 +13,15 @@ description: "Use when Comet 需要创建新的 OpenSpec change,或 active cha ### 0. 输出语言约束 -传递给 OpenSpec 的所有提问和产物要求都必须包含解析后的 Comet 产物语言,并使用 `en`、`zh-CN` 这类规范化 ID。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`;change 初始化后使用 `"$COMET_BASH" "$COMET_STATE" get language` 读取。没有配置语言时才回退到当前用户请求语言。生成的 `proposal.md`、`design.md`、`tasks.md` 必须以该语言为主语言。 +传递给 OpenSpec 的所有提问和产物要求都必须包含解析后的 Comet 产物语言,并使用 `en`、`zh-CN` 这类规范化 ID。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`;change 初始化后使用 `comet state get language` 读取。没有配置语言时才回退到当前用户请求语言。生成的 `proposal.md`、`design.md`、`tasks.md` 必须以该语言为主语言。 ### 0a. 当前 change 绑定 -恢复已有 change 时,第一项状态操作必须是: +恢复已有 change 时先检查 `openspec/changes//.comet.yaml`: + +- 状态文件存在且可解析:第一项状态操作是选择 change +- 状态文件缺失但 change 目录有效:先运行 `comet state init full`,再选择 change +- 状态文件格式异常:停止并报告解析错误;从版本控制、备份或可验证产物人工修复后再继续,不得用 `state set` 覆盖损坏文件 ```bash comet state select @@ -25,6 +29,16 @@ comet state select 创建新 change 时,必须先完成 `.comet.yaml` 初始化,再立即运行同一命令;状态文件不存在前不得伪造选择。 +### 0b. OpenSpec 兼容性检查 + +在任何 OpenSpec 状态或指令命令前运行: + +```bash +openspec --version +``` + +本流程要求 **OpenSpec >= 1.5.0**。版本低于 1.5.0、无法解析版本、命令不可用或返回非零退出码时立即停止,并提示运行 `npm install -g @fission-ai/openspec@latest` 后重试;不得继续使用缺少 `applyRequires`、`artifactPaths`、`changeRoot` 或 `resolvedOutputPath` 契约的旧 CLI。 + ### 1. 探索想法与需求澄清 **立即执行:** 使用 Skill 工具加载 `openspec-explore` 技能。禁止跳过此步骤。 @@ -67,36 +81,44 @@ comet state select 不得在用户完成 PRD 拆分选择前创建 proposal.md、design.md 或 tasks.md。若用户选择创建多个 change,当前 `/comet-open` 调用只负责完成拆分确认与调度,随后按用户确认的顺序分别进入每个拆分项的 `/comet-open`。 +用户确认创建多个 changes 后,必须立即把确认结果持久化到 `.comet/batches/.json`。`batch-id` 使用稳定的 kebab-case 标识;文件至少记录 `version`、原始目标摘要、创建时间、按顺序排列的 change 名称,以及每项的目标、范围、非目标、验收场景和 `pending|open-complete|selected` 状态。每创建或完成一个拆分项后原子更新该文件。它只是批量编排清单,不替代各 change 的 `.comet.yaml`。 + 批量拆分模式下,进入每个拆分项的 `/comet-open` 时必须明确标注「已确认拆分项」并携带该拆分项的目标、范围、非目标和验收场景。已确认拆分项默认跳过 PRD 拆分预检,除非该拆分项本身仍明显包含多个独立 capability。 -批量拆分模式下,单个拆分项完成 open 阶段后不得自动流转到 `/comet-design`。拆分完毕后必须暂停询问用户开始哪一个 change;用户选择后,只推进该 change 进入 `/comet-design`,其他 change 保持 active,稍后通过 `/comet` 恢复。 +批量拆分模式下,单个拆分项完成 open 阶段后不得自动流转到 `/comet-design`。 -最小断点恢复规则:不新增专用批量状态文件。若批量拆分过程中断,恢复时先检查已创建的 active changes;已存在且包含 `.comet.yaml` 的拆分项不得重复创建,未创建的拆分项按用户已确认的拆分清单继续通过 `/comet-open` 创建。若对话中已确认的拆分清单不可恢复,必须重新向用户确认拆分清单后再继续。 +**批量完成硬性检查(不得跳过)**:全部拆分项完成各自的 open 阶段后,对用户确认清单中的每个 `` 逐个运行: -### 1b. 需求澄清完成确认(阻塞点) +```bash +openspec status --change "" --json +comet state check design +``` -创建 OpenSpec artifacts 前,必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认需求澄清完成。 +解析 OpenSpec JSON 时必须同时确认: +- `changeRoot` 解析后必须等于仓库内 `openspec/changes/`;不匹配时停止,Classic runtime 不支持仓库外 change root +- schema 必须包含核心 artifact ID `proposal`、`design`、`tasks`;允许存在额外 artifacts,但核心 ID 缺失时停止并报告不兼容 schema +- `applyRequires` 列出的每个 artifact 在 `artifacts` 中都必须为 `done` +- `artifactPaths..existingOutputPaths`(或 instructions 返回的 `resolvedOutputPath`)对应的实际输出必须存在且非空 +- `isComplete` 仅作诊断信息;不能替代 `applyRequires` 的实现就绪判定,也不能要求非必需 artifact 阻塞阶段推进 -暂停时必须展示澄清摘要:目标、非目标、范围边界、关键未知项、验收场景草案。 +任一拆分项未通过检查时,不得宣告拆分完成,也不得询问用户开始哪个 change;必须停止并从该 change 的第一个 `ready` 或 `blocked` artifact 恢复 `/comet-open`。OpenSpec 检查通过但 Comet state 检查失败时,必须先修复 `.comet.yaml` 初始化或 phase,再重新执行整批检查。 -不得在用户确认需求澄清完成前创建 proposal.md、design.md 或 tasks.md,也不得使用 Skill 工具加载 `openspec-propose` 技能一次性生成全部 artifacts。 +只有所有拆分项都通过两项 CLI 检查后,才暂停询问用户开始哪一个 change;用户选择后,把批量清单中的该项标记为 `selected`,只推进该 change 进入 `/comet-design`,其他 change 保持 active,稍后通过 `/comet` 恢复。 -### 1c. Change 名称确认(阻塞点) +断点恢复时先读取 `.comet/batches/.json`,再对清单中已创建的 active changes 运行上述 CLI 检查;已完整通过的拆分项不得重复创建,未通过的拆分项从 OpenSpec 返回的第一个 `ready` artifact 继续。未创建项按持久清单继续创建。清单缺失或损坏时停止并请求用户重建/确认,不能从目录列表猜测原始批次边界。 -创建 change 目录(`openspec new change`)前,必须按 `comet/reference/decision-point.md` 的协议暂停,让用户决定 change 名称。不得自动生成或静默推断 change 名称。 +### 1b. 需求与 Change 名称解析(默认不阻塞) -OpenSpec change 名称必须是 **kebab-case 英文**(小写字母、数字、连字符;如 `refine-requirements-doc`)。中文或其他不合规名称无效。 +创建 OpenSpec artifacts 前,把 Step 1 的澄清结果整理为 resolved brief:目标、非目标、范围边界、关键未知项和验收场景草案,并基于它派生一个能准确表达范围的 kebab-case 英文 change 名称。 -暂停时必须展示: -- 基于已确认澄清摘要派生的 **2-3 个推荐 kebab-case 英文名**,每个附一行说明其隐含范围 -- 一个让用户 **自行输入名称** 的明确选项 -- 提示:**若用户输入中文(或任何非 kebab-case 文本),会被转换为合规的 kebab-case 英文名**,转换结果必须回显给用户确认后才能使用 +- **范围与命名都明确时直接继续**,不得仅为了让用户批准摘要或名称而创建停顿点;最终审视会统一确认 change 名称、范围和产物内容 +- 用户已经提供名称时,规范化为 kebab-case 并在进度说明中回显;规范化不改变含义时无需再次确认 +- 已确认批量拆分项直接复用批量清单中的摘要与名称;检测到范围漂移或清单信息缺失时,才重新澄清 +- 只有仍存在会改变范围或目标 change 身份的互斥选择时,才按 `comet/reference/decision-point.md` 提出一个联合问题;命名偏好本身不是独立阻塞点 -决策选项必须包含: -- 选择某个推荐名称 -- 「自行输入名称」——接收用户输入;若已是合规 kebab-case 英文则直接使用;若为中文或其他不合规形式,则转换为合规 kebab-case 英文并回显转换后的名称,确认后再继续 +OpenSpec change 名称必须是 kebab-case 英文(小写字母、数字、单连字符)。若名称冲突但目标仍明确,派生一个不冲突且语义稳定的名称并继续;只有无法判断应复用现有 change 还是创建新 change 时才交给用户选择。 -不得在用户确认最终 change 名称前运行 `openspec new change` 或创建 `.comet.yaml`。若选定/转换后的名称与已有 change 冲突,必须报告冲突并请用户另选名称。 +resolved brief 或 change 名称仍不明确时不得运行 `openspec new change`,也不得创建 proposal/design/tasks;继续澄清或处理真正的用户决策后再进入 Step 2。 ### 2. 创建 Change 结构 + 初始化状态 @@ -104,35 +126,50 @@ OpenSpec change 名称必须是 **kebab-case 英文**(小写字母、数字、 完整 `/comet` 流程默认不得使用 Skill 工具加载 `openspec-propose` 技能;只有用户明确要求一次性生成提案和 artifacts 时才允许加载。 -技能加载后,按其指引创建 change 骨架,但当 Step 1b 的已确认澄清摘要已存在于对话上下文时,覆盖其"STOP and wait for user direction"行为。 +技能加载后,按其指引创建 change 骨架;当 Step 1b 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为,避免重复询问。 + +直接使用 Step 1b 的 resolved brief 填充产物内容。只有 brief 仍有会改变范围的歧义时,才回退到技能的提问流程。 + +change 骨架创建后立即初始化可恢复状态,不能等 artifacts 全部生成后再写 `.comet.yaml`: -如果用户已确认澄清摘要(Step 1b),直接使用该摘要填充产物内容。如果不存在澄清摘要(边缘情况),回退到技能的默认行为,询问用户。 +```bash +comet state init full +comet state select +comet state check open +``` + +任一命令失败都停止。随后运行一次 `openspec status --change "" --json` 并执行兼容性预检: -change 骨架创建后,按以下标准产物循环逐个生成 `proposal`、`design`、`tasks`: +- `changeRoot` 解析后必须等于当前仓库的 `openspec/changes/`,`planningHome`(如存在)也必须位于当前仓库;不支持仓库外 artifact 路径 +- `artifacts` 必须包含核心 ID `proposal`、`design`、`tasks`,额外 artifacts 允许存在 +- `applyRequires` 必须是可解析的 artifact ID 列表,且每个 ID 都存在于 `artifacts` +- 载荷缺字段、路径越界或核心 ID 缺失时立即停止,不能回退为猜测的固定模板 -**标准产物循环**(对每个 `artifact-id`:`proposal` → `design` → `tasks`): +预检通过后,按 OpenSpec CLI 返回的 schema 和依赖图生成实现所需 artifacts: -1. 刷新状态:`openspec status --change "" --json` -2. 获取产物指令: +**OpenSpec 状态驱动产物循环**: + +1. 运行 `openspec status --change "" --json` 并解析完整 JSON。 +2. 若 `applyRequires` 中每一项都已是 `done`,退出循环;`isComplete` 只记录为诊断信息,不作为阶段阻塞条件。 +3. 从尚未完成且为 `status: "ready"` 的 artifacts 中,优先选择能够推进 `applyRequires` 依赖闭包的项,并按 CLI 返回顺序处理。不得硬编码生成顺序,也不得假设 schema 只有 proposal/design/tasks。 +4. 对每个 ready 的 `` 获取实时指令: ```bash - openspec instructions proposal --change "" --json - openspec instructions design --change "" --json - openspec instructions tasks --change "" --json + openspec instructions --change "" --json ``` -3. 对返回的 JSON 指令载荷,必须: +5. 对返回的 JSON 指令载荷,必须: - 读取 `dependencies` 中列出的每个已完成依赖产物 - 以 `template` 作为产物结构 - 遵循 `instruction` 的指引 - 将 `context` 和 `rules` 作为约束条件应用,**不得复制到 artifact 内容中** - - 写入 `resolvedOutputPath` - - 验证输出文件存在且非空 -4. 每创建一个 artifact 后,重新运行 `openspec status --change "" --json` 确认状态,然后继续下一个 artifact + - 写入 `resolvedOutputPath`;通配输出必须按 instruction 创建每个实际文件 + - 验证 CLI 返回的实际输出文件存在且非空 +6. 每创建一个 artifact 后,重新运行 status,并再次校验 `changeRoot`、核心 ID 和 `applyRequires`。已经变为 `done` 的项不得重复生成;新变为 `ready` 的项进入下一轮。 -**失败处理**:如果 `openspec instructions` 失败、返回无效 JSON、报告未满足的 `dependencies`、或未提供可用的 `resolvedOutputPath`,必须立即停止 artifact 创建并报告 OpenSpec 错误。不得回退为硬编码文档结构,因为那样会绕过项目规则。 +**阻塞与失败处理**:`applyRequires` 尚未全部完成但没有任何可推进其依赖闭包的 ready artifact 时,必须报告相关 `blocked` artifact 的 `missingDeps` 并停止,不得猜测顺序或跳过依赖。如果 `openspec status` / `openspec instructions` 失败、返回无效 JSON、路径逃逸仓库、或未提供可用的 `resolvedOutputPath`,也必须立即停止并报告 OpenSpec 错误。不得回退为硬编码文档结构。 -**命名与范围守卫**:change name 必须使用 Step 1c 中用户确认的 kebab-case 英文名,不得自动生成、推断或使用非 kebab-case(如中文)名称。变更范围必须与用户描述一致,不得自行扩大或缩小。 +**命名与范围守卫**:change name 必须使用 Step 1b 解析出的 kebab-case 英文名,不得使用非 kebab-case(如中文)名称。变更范围必须与 resolved brief 和用户描述一致,不得自行扩大或缩小。 确认以下产物已创建: @@ -145,43 +182,45 @@ openspec/changes// └── tasks.md # 任务清单(勾选框) ``` -创建 `.comet.yaml` 状态文件: - -先按 `comet/reference/scripts.md` 定位脚本(定位 `comet-env.mjs`),然后初始化状态: - -```bash -node "$COMET_STATE" init full -``` - ### 3. 入口状态验证 验证状态机已正确初始化: ```bash -node "$COMET_STATE" check open +comet state check open ``` 验证通过后继续 Step 4。验证失败时脚本会输出具体失败原因。 -**幂等性**:open 阶段所有操作可安全重复执行。如 `.comet.yaml` 已处于 `phase: open` 且三个产物文件均已存在,跳过已完成步骤,从第一个缺失步骤继续。 +**幂等恢复算法**:open 阶段所有操作可安全重复执行。恢复时按以下顺序处理: + +1. 状态文件缺失时先运行 `comet state init full`;格式异常时停止并修复,不得覆盖。随后选择 change 并运行 `comet state check open`。 +2. 运行 `openspec status --change "" --json`,重新验证 `changeRoot`、核心 ID、`applyRequires`、`artifacts` 和 `missingDeps`。 +3. `done`:该 artifact 已完成,保持原文件不变,不重复生成。 +4. `ready`:依赖已经满足,可以生成。先运行该 artifact 的 `openspec instructions`,按返回内容写入;写完后立刻重新运行 status。 +5. `blocked`:读取 `missingDeps`,先完成属于 `applyRequires` 依赖闭包的依赖 artifact;每完成一个依赖都重新运行 status,不能直接生成 blocked artifact。 +6. 重复上述处理,直到 `applyRequires` 全部为 `done`。 + +如果必需依赖图无法推进,必须列出相关 blocked artifact 及其 `missingDeps` 后停止并报告。目录或固定三个文件存在不能替代 CLI 判定;反过来,非 `applyRequires` 的可选 artifact 也不能仅因 `isComplete: false` 阻塞进入实现阶段。 ### 4. 内容完整性检查 -确认三个文档内容完整: -- **proposal.md**:问题背景、目标、范围、非目标 -- **design.md**:高层架构决策、方案选型、数据流 -- **tasks.md**:任务列表,每个任务有明确描述 +再次运行 `openspec status --change "" --json`,确认核心 ID 存在、`applyRequires` 每项均为 `done`,且这些必需 artifacts 的 `artifactPaths..existingOutputPaths` 返回的实际输出文件存在且非空。任一条件不满足时,不得进入 Step 5 或执行阶段守卫。 -**文件存在性验证**:逐个确认三个文件路径存在且非空。任一文件缺失或为空时,不得进入 Step 5 或执行阶段守卫,必须回到创建步骤补充。 +随后检查关键 artifact 内容:proposal 覆盖问题、目标、范围和非目标;design 覆盖高层决策与数据流;tasks 包含明确任务;schema 返回 specs 等其他 artifact 时,也必须按其 instructions 检查内容,不能因固定三件套存在而跳过。 ### 5. 用户审视确认(阻塞点) -三个文档创建完成且内容完整性检查通过后,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认**。不得在用户确认前执行阶段守卫或自动流转。 +全部 OpenSpec artifacts 完成且内容完整性检查通过后,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认**。不得在用户确认前执行阶段守卫或自动流转。 + +最终审视同时确认 change 名称、范围和产物内容;不得因 Step 1b 已完成解析而省略,也不得在此之前再增加一次常规摘要/命名确认。 用户确认问题必须以单选题形式呈现,包含以下摘要和选项: **摘要内容**: +- **change 名称与 resolved brief**:最终名称、目标、非目标、范围边界和关键未知项 - **proposal.md**:问题背景、目标、范围 +- **specs 等 schema artifacts**:能力、需求和关键验收场景 - **design.md**:高层架构决策、方案选型 - **tasks.md**:任务数量和关键任务描述 @@ -193,14 +232,14 @@ node "$COMET_STATE" check open ## 退出条件 -- proposal.md、design.md、tasks.md 均已创建且内容完整 -- **用户已确认** proposal、design、tasks 内容符合预期 -- **阶段守卫**:运行 `node "$COMET_GUARD" open --apply`,全部 PASS 后由守卫推进到下一阶段(此步骤更新 `phase` 字段,与 `auto_transition` 无关) +- `openspec status --change "" --json` 的兼容性预检通过,`applyRequires` 全部为 `done` 且必需输出非空 +- **用户已确认** 全部 OpenSpec artifacts 内容符合预期 +- **阶段守卫**:运行 `comet guard open --apply`,全部 PASS 后由守卫推进到下一阶段(此步骤更新 `phase` 字段,与 `auto_transition` 无关) 退出前必须使用 `--apply`,否则 `.comet.yaml` 仍停留在 `phase: open`,下一阶段入口检查会失败。 ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` 完整流程会自动更新为 `phase: design`;hotfix/tweak 预设会自动更新为 `phase: build`。 @@ -210,11 +249,11 @@ node "$COMET_GUARD" open --apply 按 `comet/reference/auto-transition.md` 执行。关键命令: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 进入下一阶段 -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 hotfix/tweak 预设由对应预设 Skill 控制后续流转(phase 直接进入 build),其 `next` 会返回对应预设 Skill。 diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 06101e14..88db7197 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-tweak -description: "Use when 用户要进行可收敛为单一 OpenSpec change 的轻量或中等变更,且不需要完整设计;也用于恢复 tweak workflow。" +description: "仅在用户明确调用 /comet-tweak,或由 Comet 根 Skill/runtime 路由到 tweak preset 时使用;处理可收敛为单一 OpenSpec change 的轻量或中等变更。" --- # Comet 预设路径:Tweak @@ -23,7 +23,7 @@ Tweak 是 Comet 五阶段能力的预设工作流,不是独立的平行流程 ### 0. 输出语言约束 -精简版 OpenSpec 产物必须使用 Comet 配置产物语言。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`,初始化后使用 `"$COMET_BASH" "$COMET_STATE" get language` 读取。 +精简版 OpenSpec 产物必须使用 Comet 配置产物语言。`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`,初始化后使用 `comet state get language` 读取。 执行链路:open → OpenSpec apply → verify → archive。Tweak 为每个阶段提供默认决策:精简开启、通过 OpenSpec apply 直接构建、按规模与 delta spec 判定验证轻重、验证通过后进入归档前最终确认。 @@ -46,19 +46,22 @@ Tweak 是 Comet 五阶段能力的预设工作流,不是独立的平行流程 初始化 Comet 状态文件: ```bash -node "$COMET_STATE" init tweak +comet state init tweak +comet state select ``` +tweak 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能改为 `branch` 或 `worktree`。 + 初始化后验证状态: ```bash -node "$COMET_STATE" check open +comet state check open ``` 阶段守卫完成 open → build 过渡: ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` ### 2. OpenSpec apply 构建(tweak 专用预设 build) @@ -96,7 +99,7 @@ node "$COMET_GUARD" open --apply 运行阶段守卫完成 build → verify 过渡: ```bash -node "$COMET_GUARD" build --apply +comet guard build --apply ``` 状态文件自动更新为 `phase: verify`、`verify_result: pending`,然后进入验证。 @@ -110,10 +113,10 @@ node "$COMET_GUARD" build --apply **带 delta spec 的验证分流**:tweak 接受 delta spec 作为正常产物。若本次 change 创建了 delta spec,进入 comet-verify 前显式设置完整验证模式,走 OpenSpec 原生验证(`openspec-verify-change`)以覆盖 delta spec 一致性: ```bash -node "$COMET_STATE" set verify_mode full +comet state set verify_mode full ``` -无 delta spec 的 tweak 通常满足轻量验证条件(≤ 3 tasks、改动文件数低于 scale 阈值),由 comet-verify 的规模评估选择轻量验证路径(6 项快速检查)。若用户希望增加审查,可在验证前运行 `node "$COMET_STATE" set review_mode standard` 或 `thorough`。 +无 delta spec 的 tweak 通常满足轻量验证条件(≤ 3 tasks、改动文件数低于 scale 阈值),由 comet-verify 的规模评估选择轻量验证路径(6 项快速检查)。若用户希望增加审查,可在验证前运行 `comet state set review_mode standard` 或 `thorough`。 验证通过后,按 `/comet-verify` 的规则将 `.comet.yaml` 的 `verify_result` 记录为 `pass`,归档前不得跳过该状态。验证通过后仍必须进入 `/comet-archive` 的归档前最终确认,不得自动运行归档脚本。 @@ -128,11 +131,11 @@ node "$COMET_STATE" set verify_mode full ## 连续执行模式 -Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent 在 tweak 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)停下,由用户手动运行下一阶段命令——此时连续执行降级为逐阶段手动推进,详见下方「自动衔接下一阶段」。但无论 `auto_transition` 取何值,以下情况都必须暂停等待用户确认: +Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent 在 tweak 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 tweak 轻量流程,还是升级为完整 `/comet` 流程 -2. 验证阶段(comet-verify)的验证失败决策和分支处理决策 -3. 归档前最终确认(comet-archive 执行归档脚本前) +2. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 +3. 归档前最终确认,以及归档提交后的分支处理决策 执行顺序:快速开启 → 构建(含升级判定检查)→ 验证 → 归档 → 完成 @@ -143,7 +146,7 @@ Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent ## 升级判定 -tweak 的升级判定只决定是否从轻量预设转为 full;delta spec 本身不是升级理由,文件数不自动升级,`comet-state scale` 只决定验证轻重。 +tweak 的升级判定只决定是否从轻量预设转为 full;delta spec 本身不是升级理由,文件数不自动升级,`comet state scale` 只决定验证轻重。 若由 `/comet` 入口传入 intent frame,tweak 在 build 前只复核 `risk_signal` 和升级信号:新增 capability、public API、schema 变更、跨模块协调或深层架构问题。命中时进入现有升级决策点;delta spec 仍是 tweak 的正常产物,不因存在 delta spec 自动升级;不得重新实现入口意图识别。 @@ -156,10 +159,10 @@ tweak 的升级判定只决定是否从轻量预设转为 full;delta spec 本 用户选择升级(选项 B)后,使用状态机合法的升级通道,单条命令完成预设流程 → full 转换并回退到 design 阶段: ```bash -node "$COMET_STATE" transition preset-escalate +comet state transition preset-escalate ``` -该命令原子地把 `workflow`/`classic_profile` 置为 `full`、`phase` 回退到 `design`、清空 `design_doc`(满足 comet-design 入口要求)。然后在当前 change 基础上补充 Design Doc:**立即使用 Skill 工具加载 `comet-design` skill**,后续正常走完整流程。 +该命令原子地把 `workflow`/`classic_profile` 置为 `full`、`phase` 回退到 `design`、清空 `design_doc`,并清除预设专属的 `build_mode`、`tdd_mode`、`review_mode`、`isolation` 和 `verify_mode`。然后在当前 change 基础上补充 Design Doc:**立即使用 Skill 工具加载 `comet-design` skill**;进入 build 后必须重新进行一次完整的联合工作方式选择。 用户选择继续(选项 A)时,继续 tweak 流程,并记录用户确认继续的原因。 @@ -170,16 +173,16 @@ node "$COMET_STATE" transition preset-escalate - 变更已完成,测试通过 - change 已归档 - 如有 spec 变更,已同步到 main spec -- **阶段守卫**:build → verify 前运行 `node "$COMET_GUARD" build --apply`,verify → archive 前按 `/comet-verify` 规则运行 `node "$COMET_GUARD" verify --apply` +- **阶段守卫**:build → verify 前运行 `comet guard build --apply`,verify → archive 前按 `/comet-verify` 规则运行 `comet guard verify --apply` ## 自动衔接下一阶段 按 `comet/reference/auto-transition.md` 执行。关键命令: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 继续 tweak 流程(`phase: build` 返回 `comet-tweak`,`verify` 返回 `comet-verify`,`archive` 返回 `comet-archive`) -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 414ca9aa..3e38c7dc 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -1,9 +1,9 @@ --- name: comet-verify -description: "Use when Comet change 已完成 build 阶段,需要验证实现、处理验证失败决策或完成分支收尾。" +description: "仅在用户明确调用 /comet-verify,或由 Comet 根 Skill/runtime 路由到 verify 阶段时使用;验证 Comet change、记录证据并处理修复循环。" --- -# Comet 阶段 4:验证与收尾(Verify) +# Comet 阶段 4:验证(Verify) ## 前置条件 @@ -14,7 +14,7 @@ description: "Use when Comet change 已完成 build 阶段,需要验证实现 ### 0a. 输出语言约束 -验证报告和分支处理说明必须使用 `comet state get language` 读取到的 Comet 配置产物语言。 +验证报告必须使用 `comet state get language` 读取到的 Comet 配置产物语言。 ### 0b. 入口状态验证(Entry Check) @@ -27,7 +27,7 @@ comet state check verify 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 -**幂等性**:verify 阶段所有检查可安全重复执行。如 `verify_result` 已为 `pass` 且 `branch_status` 已为 `handled`,说明验证已完成,直接执行 guard 流转。如 `verify_result` 为 `pending`,从头开始验证。 +**幂等性**:verify 阶段所有检查可安全重复执行。如 `verify_result` 已为 `pass`,说明验证已完成并应进入 archive;`branch_status` 在归档提交和最终分支处理完成前保持 `pending`。如 `verify_result` 为 `pending`,从头开始验证。 ### 1. 改动规模评估 @@ -41,27 +41,26 @@ comet state scale 验证开始前,按 `comet/reference/dirty-worktree.md` 协议检查并处理未提交改动。verify 阶段的特殊处理: -1. 若 dirty diff 属于当前 change 且涉及实现、测试、tasks、delta spec 或 design doc 变更,不在 verify 阶段直接修复或提交;报告失败项并进入 Step 1b 的验证失败决策阻塞点 -2. 若 dirty diff 只是 verify 本阶段产物(例如验证报告草稿、分支处理记录),可继续在 verify 阶段完成并记录状态 -3. 若 dirty diff 已实现但 tasks.md 未勾选,视为 build 状态滞后;报告失败项并进入 Step 1b,由用户决定回退修复或接受偏差 +1. 若 dirty diff 明确属于当前 change,它就是本次验证输入;继续验证,但不在 verify 阶段修改或提交实现、测试、tasks、delta spec 或 Design Doc +2. 若 dirty diff 只是 verify 本阶段产物(例如验证报告草稿),可继续在 verify 阶段完成并记录状态 +3. 若 dirty diff 显示实现已存在但 tasks.md 未勾选,视为 build 状态滞后;这是只有一个合法下一步的自动处理,运行 `verify-fail` 返回 build 核对证据并更新任务状态,不得询问是否接受未完成任务 +4. 若 dirty diff 无法归因或属于其他 change,按 dirty-worktree 协议报告停止条件;不要把归因失败伪装成“继续/忽略”决策 -用户选择修复后,才允许回退到 build 阶段: +需要回到 build 修复或补齐状态时运行: ```bash -# 仅在用户确认修复后执行 comet state transition verify-fail ``` -注意:verify-fail 回退到 build 时 `branch_status` 不会被重置。如果首次 verify 已完成分支处理,修复后再次进入 verify 时跳过已完成的分支处理步骤,直接使用 `comet state set branch_status handled` 保留原有分支处理结果。 - 注意:如果 build 阶段每个任务都已提交,脚本基于工作区 diff 的文件数可能低估改动规模。此时必须读取 plan 文件头的 `base-ref` 并用提交区间复核: ```bash -PLAN=$(comet state get plan) -BASE_REF=$(grep '^base-ref:' "$PLAN" 2>/dev/null | head -1 | sed 's/^base-ref: *//') -git diff --stat "$BASE_REF"...HEAD +comet state get plan +git diff --stat <从 plan frontmatter 读取的 base-ref>...HEAD ``` +第一条命令返回 plan 路径;使用当前平台的文件读取能力解析其 frontmatter 中唯一的 `base-ref`,确认它是有效提交后再代入第二条命令。不得依赖 POSIX 文本管道。 + 若提交区间显示改动超过轻量阈值(> 8 个文件、跨模块协调、或 delta spec 超过 1 个 capability),手动设置为完整验证: ```bash @@ -70,33 +69,34 @@ comet state set verify_mode full **覆盖机制**:如 agent 或用户认为自动评估结果不合适,可随时通过 `comet state set verify_mode ` 手动覆盖。 -### 1b. 验证失败决策(阻塞点) +### 1b. 验证失败自动修复与例外决策 -验证不通过时**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户决定修复或接受偏差**。不得自动运行 `comet state transition verify-fail`,也不得自动调用 `/comet-build`。 +先运行 `comet state get verify_failures` 读取已持久化的连续失败次数。前 3 次可修复失败自动回到 build:报告失败项后运行 `comet state transition verify-fail`,再调用 `/comet-build` 修复,不需要用户确认。 -暂停时必须列出: +报告必须列出: - 失败项 - 是否属于 CRITICAL 或 IMPORTANT(构建失败、测试失败、安全问题、核心验收场景失败、简化代码审查发现的正确性/安全/边界问题) - 推荐处理方式 -**不确定性原则**:无法确定严重程度时,降级处理(SUGGESTION > WARNING > CRITICAL)。仅对构建失败、测试失败、安全问题使用 CRITICAL;模糊或不确定的问题标为 WARNING 或 SUGGESTION。 +**不确定性原则**:无法确定严重程度时使用较低级别。仅对构建失败、测试失败、安全问题使用 CRITICAL;明确影响核心验收或正确性的项使用 IMPORTANT;模糊或不确定的问题标为 WARNING 或 SUGGESTION。 -用户选择后按以下方式继续: -- **全部修复**:运行 `comet state transition verify-fail`,然后调用 `/comet-build` 修复 -- **逐项处理**:CRITICAL 或 IMPORTANT 失败项必须修复;WARNING/SUGGESTION 失败项可选择接受偏差,但必须在验证报告中记录接受原因和影响范围。若存在任何 CRITICAL 或 IMPORTANT 失败项,不允许跳过修复直接全部接受 +按以下方式处理: +- **CRITICAL/IMPORTANT 或范围内可明确修复的问题**:未达到上限时自动回到 build 修复;不得创建“是否修复”的伪决策,也不允许接受偏差 +- **WARNING/SUGGESTION 且修复会引入行为、范围或风险取舍**:按 `comet/reference/decision-point.md` 让用户选择修复或接受偏差;接受时必须在验证报告中记录原因和影响范围 +- **WARNING/SUGGESTION 且修复安全、局部、无取舍**:未达到上限时自动修复,不因级别较低而强制停顿 -**重试上限**:连续 3 次 verify-fail 循环后,第 4 次失败时代理不得自动选择继续修复;**必须使用当前平台可用的用户输入/确认机制暂停**,仅给出两个选项:「接受所有偏差并记录」或「继续修复」,由用户明确决定。 +只有接受 WARNING/SUGGESTION 偏差或第 4 次失败后的策略选择才是用户决策点。当前 `verify_failures >= 3` 时不得自动执行下一次 `verify-fail`;按协议只提供「继续修复」或「停止当前 workflow 并寻求外部决策」两个选项。用户选择继续后才记录下一次失败并回到 build。CRITICAL/IMPORTANT 始终不可豁免。 ### 2. 产物上下文加载(Hash 按需读) 验证需要读取 OpenSpec 产物时,先检查产物是否自 design 阶段以来发生变化: ```bash -RECORDED_HASH=$(comet state get handoff_hash) -CURRENT_HASH=$(comet handoff --hash-only 2>/dev/null || echo "") +comet state get handoff_hash +comet handoff --hash-only ``` -- 若 `RECORDED_HASH` = `CURRENT_HASH` 且均非空且均非 `null`:OpenSpec 产物未变化,**tasks.md 无需重新读取全文**(用 `grep -c '\- \[ \]' tasks.md` 确认完成数即可)。proposal.md、design.md、delta spec 仍需读取用于对照检查。 +- 分别读取两条命令的标准输出;若记录值与当前值相等,且均非空、非 `null`:OpenSpec 产物未变化,**tasks.md 无需重新读取全文**(解析复选框确认无未完成项即可)。proposal.md、design.md、delta spec 仍需读取用于对照检查。 - 若 `RECORDED_HASH` 为空、为 `null`、或与 `CURRENT_HASH` 不一致:产物已变化或 hash 未记录,正常读取所有所需文件全文。 此优化仅跳过 tasks.md 的重复全文读取。proposal.md 和 design.md 包含验证检查项所需的完整上下文,不得因 hash 匹配而跳过。 @@ -124,16 +124,15 @@ comet state record-check verify --command "<实际运行的验证 `--command` 只记录命令文本,Comet **绝不会执行该文本**。verify 与 build 证据彼此独立,不能互相替代;即使兼容流程使用 `COMET_SKIP_BUILD=1`,也不能把该绕过标记视为可审计的验证或构建证据。 -简化代码审查的输入应限定为本次改动 diff、tasks.md 和必要的测试结果;审查范围只覆盖实现正确性、安全风险和边界条件,不执行 spec 覆盖率、Design Doc 一致性或漂移检查。若审查发现 CRITICAL 或 IMPORTANT 问题,按验证失败处理并进入 Step 1b。`review_mode: off` 只跳过自动 code review,不跳过构建、测试、安全检查或异常调试协议。 +简化代码审查的输入应限定为本次改动 diff、tasks.md 和必要的测试结果;审查范围只覆盖实现正确性、安全风险和边界条件,不执行 spec 覆盖率、Design Doc 一致性或漂移检查。若审查发现 CRITICAL 或 IMPORTANT 问题,按 Step 1b 的自动修复/重试规则处理。`review_mode: off` 只跳过自动 code review,不跳过构建、测试、安全检查或异常调试协议。 **与 build 阶段审查的去重**:若 build 阶段(`executing-plans` 或 `subagent-driven-development`)已按 `review_mode` 对同一 diff 完成最终代码审查,verify 的这次轻量审查聚焦「实现是否符合 spec/tasks 的正确性」与「build 之后新增的改动」,不重复评审 build 已审过且未变化的 diff。 **通过标准**:6 项全部 OK,无 CRITICAL 或 IMPORTANT 问题。 -**不通过时**:报告失败项,进入 Step 1b 的验证失败决策阻塞点。用户选择修复后,才执行以下命令记录失败并回退到 build 阶段,然后调用 `/comet-build` 修复: +**不通过时**:报告失败项并按 Step 1b 分类。未达到自动修复上限且问题必须或适合修复时,直接执行以下命令回到 build 阶段,然后调用 `/comet-build`: ```bash -# 仅在用户确认修复后执行 comet state transition verify-fail ``` @@ -160,10 +159,9 @@ comet state transition verify-fail 6. delta spec 与 design doc 无矛盾(若 Build 阶段有增量修改 spec,检查 design doc 是否有对应记录) 7. `docs/superpowers/specs/` 关联的设计文档可定位(文件存在且与当前 change 相关) -验证不通过时:报告缺失项,进入 Step 1b 的验证失败决策阻塞点。用户选择修复后,才执行以下命令记录失败并回退到 build 阶段,然后调用 `/comet-build` 补充: +验证不通过时:报告缺失项并按 Step 1b 分类。未达到自动修复上限且缺失项可在当前 change 内补齐时,直接执行以下命令回到 build 阶段,然后调用 `/comet-build`: ```bash -# 仅在用户确认修复后执行 comet state transition verify-fail ``` @@ -173,46 +171,24 @@ comet state transition verify-fail - 选项 B:用户选择 B 后,运行 `comet state transition verify-fail`,然后调用 `/comet-build`;由 `/comet-build` 的 Spec 增量更新规则加载 Superpowers `brainstorming` 更新 Design Doc + delta spec - 选项 C:确认偏差可接受,继续验证(归档时 design doc 将标记为 `superseded-by-main-spec`) -### 3. 收尾(Superpowers) - -**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。禁止跳过此步骤。 - -如 Superpowers `finishing-a-development-branch` 技能不可用,停止流程并提示安装或启用 Superpowers 技能,不要用普通对话替代该步骤。 - -技能加载后,按其指引收尾。分支处理选项: -1. 本地合并到主分支 -2. 推送并创建 PR -3. 保持分支(稍后处理) -4. 丢弃工作 +### 3. 记录验证证据 -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式**,不得根据推荐、默认值或当前分支状态自行选择。只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 - -**确认项**: -- 全部测试通过 -- 无硬编码密钥或安全问题 - -### 4. 记录验证证据 - -验证报告必须落盘,并在 `.comet.yaml` 中记录;分支处理完成后也必须写入状态字段。不要手动设置 `verify_result: pass`,由阶段守卫 `--apply` 推进。 +验证报告必须落盘,并在 `.comet.yaml` 中记录。不要在 verify 阶段处理、合并或丢弃分支,也不要写入 `branch_status: handled`;归档会产生必须包含在最终提交中的 spec 和元数据改动,分支收尾统一由 `/comet-archive` 在归档提交后执行。不要手动设置 `verify_result: pass`,由阶段守卫 `--apply` 推进。 ```bash -mkdir -p docs/superpowers/reports -# 将本次验证结论写入报告文件,例如: -# docs/superpowers/reports/YYYY-MM-DD--verify.md - comet state set verification_report docs/superpowers/reports/YYYY-MM-DD--verify.md -comet state set branch_status handled ``` +使用当前平台的文件能力创建 `docs/superpowers/reports/` 和报告文件,不依赖 POSIX 专用目录命令。 + ## 退出条件 - 验证报告通过 -- 分支已处理 - `.comet.yaml` 中 `verification_report` 指向已存在的验证报告文件 -- `.comet.yaml` 中 `branch_status: handled` +- `.comet.yaml` 中 `branch_status` 仍为 `pending` - **阶段守卫**:运行 `comet guard verify --apply`,全部 PASS 后由守卫通过 `comet state transition verify-pass` 推进到 `phase: archive`(此步骤更新 `phase` 字段,与 `auto_transition` 无关) -验证和分支处理均完成后,运行阶段守卫推进 phase(此步骤与 `auto_transition` 无关): +验证证据完成后,运行阶段守卫推进 phase(此步骤与 `auto_transition` 无关): ```bash comet guard verify --apply @@ -233,7 +209,7 @@ comet state next ``` - `NEXT: auto` → 调用 `SKILL` 指向的 skill 进入下一阶段 -- `NEXT: manual` → 不要调用下一 skill,按 `HINT` 提示用户手动运行 `/` +- `NEXT: manual` → 不调用下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 注意:无论 `NEXT` 为 `auto` 还是 `manual`,`comet-archive` 进入后必须先执行归档前最终确认阻塞点,等待用户明确选择「确认归档」后才允许运行归档脚本。不得因为验证已通过就自动归档。 diff --git a/assets/skills-zh/comet/SKILL.md b/assets/skills-zh/comet/SKILL.md index 5a463243..b0bb95ba 100644 --- a/assets/skills-zh/comet/SKILL.md +++ b/assets/skills-zh/comet/SKILL.md @@ -1,6 +1,6 @@ --- name: comet -description: "Use when 用户要启动或恢复 Comet 工作流,需要根据 active change、.comet.yaml、hotfix/tweak 意图路由到对应阶段 Skill。" +description: "用于用户明确调用 /comet、要求启动或恢复 Comet 托管工作流,或仓库中存在可无歧义恢复的 active Comet change;负责通过 intent runtime 和 .comet.yaml 路由阶段。" --- # Comet — OpenSpec + Superpowers 双星开发流程 @@ -22,7 +22,7 @@ agent 做决策只需读本节,参考附录按需查阅。 ### 输出语言规则 -所有 OpenSpec 和 Superpowers 产物都必须使用 Comet 配置的产物语言。配置值是规范化语言 ID,`en` 或 `zh-CN`。已有 change 优先通过 `"$COMET_BASH" "$COMET_STATE" get language` 读取 `openspec/changes//.comet.yaml` 中的 `language`;`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`;都不存在时才回退到当前用户请求语言。调用外部 OpenSpec/Superpowers skill 时,必须把解析后的语言显式写入 prompt 或 ARGUMENTS。 +所有 OpenSpec 和 Superpowers 产物都必须使用 Comet 配置的产物语言。配置值是规范化语言 ID,`en` 或 `zh-CN`。已有 change 优先通过 `comet state get language` 读取 `openspec/changes//.comet.yaml` 中的 `language`;`.comet.yaml` 尚不存在时依次读取项目 `.comet/config.yaml` 和全局 `~/.comet/config.yaml` 的 `language`;都不存在时才回退到当前用户请求语言。调用外部 OpenSpec/Superpowers skill 时,必须把解析后的语言显式写入 prompt 或 ARGUMENTS。 ### 阶段自动检测 @@ -126,22 +126,22 @@ node "$COMET_RESUME_PROBE" probe --stdin - 每次恢复上下文时,先重新执行 Step 0 和 Step 1,不依赖对话历史判断阶段 - 只要存在 active change 且工作区有未提交改动,必须按 `comet/reference/dirty-worktree.md` 协议处理。该协议定义了检查步骤、归因分类和禁令,本文件不重复 - 若 `phase: build`,先检查 `build_pause`、`plan`、`isolation`、`build_mode`、`tdd_mode` 和 `review_mode`(详见下方): - - 若 `build_pause: plan-ready` 但 `isolation`、`build_mode`、`tdd_mode` 和 `review_mode` 都已经设置,则视为 stale pause:先输出 `[COMET] 检测到 stale pause(build_pause=plan-ready 但 isolation/build_mode/tdd_mode/review_mode 已设置),自动清除并继续`,再运行 `node "$COMET_STATE" set build_pause null`,然后读取 tasks.md 的下一个未勾选任务并按 `build_mode` 恢复执行 + - 若 `build_pause: plan-ready` 但 `isolation`、`build_mode`、`tdd_mode` 和 `review_mode` 都已经设置,则视为 stale pause:先输出 `[COMET] 检测到 stale pause(build_pause=plan-ready 但 isolation/build_mode/tdd_mode/review_mode 已设置),自动清除并继续`,再运行 `comet state set build_pause null`,然后读取 tasks.md 的下一个未勾选任务并按 `build_mode` 恢复执行 - 若 `build_pause: plan-ready` 且 plan 文件存在,但 `isolation`、`build_mode`、`tdd_mode` 或 `review_mode` 尚未设置,回到 `/comet-build` 的 plan-ready 恢复点,提示用户继续补齐/确认工作区隔离、执行方式、TDD 模式和代码审查模式,不重新生成 plan - 若 `build_pause: plan-ready` 但 plan 文件缺失,回到 `/comet-build` 处理状态损坏或重新生成 plan - 若 `isolation`、`build_mode`、`tdd_mode` 或 `review_mode` 未设置,回到 `/comet-build` 对应步骤补充后再执行 - 若均已设置,读取 tasks.md 的下一个未勾选任务,并按 `build_mode` 恢复执行: - 若 `build_mode: subagent-driven-development`,不得在主窗口直接执行任务;必须回到 `/comet-build` 的后台 subagent 调度规则,由主窗口只做协调 - 其他执行方式按 `/comet-build` 的对应规则继续 -- 若 `phase: verify` 且 `verify_result: fail`,进入验证失败决策阻塞点:暂停并询问用户修复或接受偏差;用户选择修复后才运行 `node "$COMET_STATE" transition verify-fail` 并调用 `/comet-build` -- 若 `phase: open` 但 proposal/design/tasks 已完整,先运行 `node "$COMET_GUARD" open --apply` 修正状态,再继续判定 -- 若 `phase: archive`,只允许调用 `/comet-archive`;`/comet-archive` 必须先等待归档前最终确认,归档成功后 change 会移动到 archive 目录,不再对原活跃目录运行 guard +- 若 `verify_result: fail`,读取 `verify_failures`:未超过 3 次时直接调用 `/comet-build` 继续已记录的修复循环,不重复询问;超过自动修复上限时回到 `/comet-verify` 的例外决策点。只有接受 WARNING/SUGGESTION 偏差或超限后的继续/停止策略需要用户选择 +- 若 `phase: open` 但 OpenSpec `applyRequires` 已完整,先运行 `comet guard open --apply` 修正状态,再继续判定 +- 若 `phase: archive`,只允许调用 `/comet-archive`;归档前先等待最终确认,归档后精确提交归档改动,再处理分支并运行 archive guard **Step 2: 阶段判定**(按顺序,命中即停) 1. `archived: true` 或 change 已移入 archive → 流程已完成 2. `verify_result: pass` 且 `archived` 不是 `true` → `/comet-archive`(先进行归档前最终确认) -3. `verify_result: fail` → 进入验证失败决策阻塞点(暂停询问修复或接受偏差;用户选择修复后才 `verify-fail` 并 `/comet-build`) +3. `verify_result: fail` → 自动调用 `/comet-build` 继续修复;若 `verify_failures` 已超过自动修复上限,则进入 `/comet-verify` 的超限策略决策点 4. `phase: verify` 或 tasks.md 全部勾选 → `/comet-verify` 5. `phase: build` 或已有 Design Doc 但计划/执行未完成 → 优先按 workflow 路由:`hotfix` → `/comet-hotfix`,`tweak` → `/comet-tweak`,`full` → `/comet-build` 6. `phase: design` 或有 change 但无 Design Doc → `/comet-design` @@ -156,11 +156,11 @@ hotfix/tweak 的范围判定采用三层分工,避免「用纯文件数当硬 1. **质变信号**(agent 语义识别,命中任一即暂停交用户二选一):跨模块协调修改、需要新增 capability、数据库 schema 变更、引入新的 public API、触及深层架构问题(各预设沿用这套核心信号,并可追加自身语境的特有信号,如 tweak 的「需要拆分为多个 OpenSpec changes」) 2. **文件数 tripwire**(用户拍板,非自动升级):改动文件数超提示阈值时,暂停交用户决定继续预设流程还是升级 full,不自动踢 -3. **验证级别**(scale 脚本判定):`comet-state scale` 仅决定 `verify_mode`(验证轻重),不卡流程、不触发升级 +3. **验证级别**(scale 脚本判定):`comet state scale` 仅决定 `verify_mode`(验证轻重),不卡流程、不触发升级 **升级决策点(用户二选一)**: - 继续预设轻量流程(用户确认范围可控) -- 升级为完整 `/comet`(使用 `node "$COMET_STATE" transition preset-escalate` 合法回退到 design 阶段,补 Design Doc 和 Superpowers plan) +- 升级为完整 `/comet`(使用 `comet state transition preset-escalate` 合法回退到 design 阶段,同时清除预设专属的 build 配置;补 Design Doc 后重新联合选择完整工作方式) 详细判定规则见 `comet-hotfix` / `comet-tweak` 各自的「升级判定」章节。 @@ -170,7 +170,8 @@ hotfix/tweak 的范围判定采用三层分工,避免「用纯文件数当硬 |------|---------| | `openspec list --json` 失败 | 检查 openspec 是否已安装,提示 `openspec init` | | 子 skill 不可用 | 停止流程,提示安装或启用对应 skill | -| `.comet.yaml` 格式异常或缺失 | 以文件状态为准,用 `node "$COMET_STATE" set` 修正后继续 | +| `.comet.yaml` 缺失 | 进入对应 preset 的 `/comet-open` 初始化状态,再运行 `comet state select`;不得跳过初始化 | +| `.comet.yaml` 格式异常 | 停止并报告解析错误;从版本控制、备份或可验证产物人工修复,不能用 `comet state set` 覆盖损坏文件 | | 构建/测试失败 | 返回 build 阶段修复,不进入 verify | | change 目录结构不完整 | 按 `comet-open` 产物要求补齐 | @@ -183,20 +184,21 @@ hotfix/tweak 的范围判定采用三层分工,避免「用纯文件数当硬 **连续执行要求**:从检测到的阶段开始,agent 自动推进后续阶段。但**自动推进仅适用于没有用户决策的衔接点**。遇到用户决策点时,**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确回复**,不得用推荐规则、默认值或历史偏好代替用户确认,也不得仅输出文字提示后继续执行。 -**阶段推进与自动衔接的区分**:每个子 skill 退出前都会运行阶段守卫 `--apply` 推进 `.comet.yaml` 的 `phase` 字段——这一步**始终发生**,与 `auto_transition` 无关。之后子 skill 运行 `node "$COMET_STATE" next ` 解析下一步:`auto_transition` 不为 `false` 时输出 `NEXT: auto`(自动调用下一 skill),为 `false` 时输出 `NEXT: manual`(不调用下一 skill,提示用户手动运行)。因此 `auto_transition` **只控制是否自动调用下一个 skill,不影响 phase 推进**。无论 `auto_transition` 取何值,下方的用户决策点都必须阻塞等待。 +**阶段推进与自动衔接的区分**:每个子 skill 退出前都会运行阶段守卫 `--apply` 推进 `.comet.yaml` 的 `phase` 字段——这一步**始终发生**,与 `auto_transition` 无关。之后子 skill 运行 `comet state next ` 解析下一步:`auto_transition` 不为 `false` 时输出 `NEXT: auto`(自动调用下一 skill),为 `false` 时输出 `NEXT: manual`(不调用下一 skill,按 `HINT` 交还控制权)。`NEXT: manual` 不是用户决策点,不得再询问“是否继续”。因此 `auto_transition` **只控制是否自动调用下一个 skill,不影响 phase 推进**。无论 `auto_transition` 取何值,下方真正的用户决策点都必须阻塞等待。 **决策点是阻塞点**:只要到达下列任一节点,当前 `/comet` 调用必须停住,并按 `comet/reference/decision-point.md` 的协议获取用户明确选择。用户明确选择后才能写入对应状态字段、执行对应操作,随后再继续自动流转。 需要用户参与的节点(仅在这些节点暂停): -1. open 阶段 proposal/design/tasks 审视确认 -2. brainstorming 确认设计方案 -3. build 阶段 plan-ready 暂停选择,以及随后选择工作方式(工作区隔离 + 执行方式 + TDD 模式 + 代码审查模式) -4. verify 不通过时决定修复或接受偏差(含 Spec 漂移处理方式选择) -5. finishing-branch 选择分支处理方式 +1. workflow 目标选择:多个 active changes、继续现有 change/创建新 change、或批量拆分完成后选择先启动哪一个 +2. open 阶段 proposal/design/tasks 最终审视确认(同时确认 change 名称与范围;清晰请求不做前置摘要/命名确认) +3. brainstorming 确认设计方案 +4. build 阶段一次性联合选择 plan-ready 暂停或完整工作方式(可用的工作区隔离 + 执行方式 + TDD 模式 + 代码审查模式;选择 branch 时同时确认分支名) +5. verify 阶段接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移,或第 4 次失败后选择继续修复/停止;前 3 次明确可修复失败自动闭环 6. archive 阶段执行归档脚本前的最终确认 -7. 遇到升级判定信号(hotfix/tweak → 用户二选一:继续预设流程 / 升级完整流程) -8. build 阶段范围扩张需重新设计或拆分新 change -9. open 阶段大型 PRD 需确认拆分为多个 change +7. 归档改动精确提交后选择 finishing-branch 分支处理方式 +8. 遇到升级判定信号(hotfix/tweak → 用户二选一:继续预设流程 / 升级完整流程) +9. build 阶段范围扩张需重新设计或拆分新 change +10. open 阶段大型 PRD 是否拆分为多个 changes agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须自动继续推进,不得中途退出。到达决策点时,**禁止跳过用户确认或自动选择——必须通过当前平台可用的用户输入/确认机制明确获取用户选择后才能继续**。 @@ -220,8 +222,8 @@ agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须 | `/comet-open` | 1. 开启 | OpenSpec | proposal.md、design.md、tasks.md | | `/comet-design` | 2. 深度设计 | Superpowers | Design Doc、delta spec | | `/comet-build` | 3. 计划与构建 | Superpowers | 实施计划、代码提交 | -| `/comet-verify` | 4. 验证与收尾 | Both | 验证报告、分支处理 | -| `/comet-archive` | 5. 归档 | OpenSpec | delta→main spec 同步、design doc 标注、归档 | +| `/comet-verify` | 4. 验证 | Both | 验证报告 | +| `/comet-archive` | 5. 归档与收尾 | OpenSpec | delta→main spec 同步、design doc 标注、归档提交、分支处理 | | `/comet-hotfix` | 预设路径 | Both | 快速修复(跳过 brainstorming) | | `/comet-tweak` | 预设路径 | Both | 串联 OpenSpec 的中等改动(delta spec 为一等公民,跳过 brainstorming 和完整 plan) | @@ -254,24 +256,24 @@ agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须 ### 状态机硬约束 -- `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree` +- full workflow 的 `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`;hotfix/tweak 可如实使用 `current` - `build → verify` 前,`build_mode` 必须已选择 - `build_mode: subagent-driven-development` 必须同时有 `subagent_dispatch: confirmed` - full workflow 离开 build 阶段前 `tdd_mode` 必须已选择为 `tdd` 或 `direct` - full workflow 离开 build 阶段前 `review_mode` 必须已选择为 `off`、`standard` 或 `thorough` - `build_mode: direct` 默认只允许 `hotfix` / `tweak`;full workflow 需要 `direct_override: true` - `build_pause` 不是执行方式,不得写入 `build_mode` -- 这些约束同时存在于 `comet-guard.mjs build --apply` 和 `comet-state.mjs transition build-complete` +- 这些约束同时由 `comet guard build --apply` 和 `comet state transition build-complete` 执行 ### 脚本定位 -Comet 脚本随 skill 包分发在 `comet/scripts/` 下。**不硬编码路径** — 定位一次,缓存到环境变量。完整引导块、命令参考(`--apply`、`transition`、`next`、`archive`)和输出格式见 `comet/reference/scripts.md`。每会话运行一次该引导,后续全程复用 `$COMET_GUARD`、`$COMET_STATE`、`$COMET_HANDOFF`、`$COMET_ARCHIVE`、`$COMET_RUNTIME`。关键入口: +面向 workflow 的状态、守卫、handoff 和 archive 操作统一使用稳定 `comet` CLI;只有尚无公开子命令的 intent/resume probe 才按 `comet/reference/scripts.md` 定位内部 launcher。关键入口: ```bash -node "$COMET_GUARD" --apply # 阶段守卫 + 自动状态更新 -node "$COMET_STATE" transition # open-complete | design-complete | build-complete | verify-pass | verify-fail -node "$COMET_STATE" next # NEXT: auto|manual|done + SKILL: ;auto_transition:false → manual,只暂停下一 skill 调用,不影响已发生的 phase 推进 -node "$COMET_ARCHIVE" # 一键完成归档 +comet guard --apply # 阶段守卫 + 自动状态更新 +comet state transition # open-complete | design-complete | build-complete | verify-pass | verify-fail +comet state next # NEXT: auto|manual|done + SKILL: +comet archive # 一键完成归档 ``` ### 文件结构 diff --git a/assets/skills-zh/comet/reference/auto-transition.md b/assets/skills-zh/comet/reference/auto-transition.md index 5c7d644f..aa11c42b 100644 --- a/assets/skills-zh/comet/reference/auto-transition.md +++ b/assets/skills-zh/comet/reference/auto-transition.md @@ -13,7 +13,7 @@ 退出条件满足且阶段守卫推进 phase 后,运行: ```bash -node "$COMET_STATE" next +comet state next ``` 脚本根据 `phase`、`workflow`、`auto_transition` 输出确定性的下一步: diff --git a/assets/skills-zh/comet/reference/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index 5fee7953..b1ba24cb 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -22,6 +22,7 @@ auto_transition: true isolation: branch verify_mode: light verify_result: pending +verify_failures: 0 verification_report: null branch_status: pending created_at: 2026-05-26 @@ -43,14 +44,15 @@ archived: false | `build_mode` | 已选择的执行方式,可为空。取值:`subagent-driven-development`(隔离后台 subagent 逐任务实现并审查)、`executing-plans`(主会话按计划顺序执行)、`direct`(主会话直接编码,默认仅 hotfix/tweak 允许,full workflow 需 `direct_override: true`) | | `build_pause` | build 阶段内部暂停点。`null` 表示无暂停,`plan-ready` 表示 plan 已生成,用户选择切换模型后暂停 | | `subagent_dispatch` | `null` 或 `confirmed`。仅当已确认当前平台存在真实后台 subagent / Task / multi-agent 调度能力时,`build_mode: subagent-driven-development` 才能写入并用于离开 build 阶段 | -| `tdd_mode` | `tdd` 或 `direct`。full workflow 离开 build 阶段前必须已选择。`tdd` 强制每个任务先写失败测试再实现;`direct` 不强制 TDD。hotfix/tweak 默认 `direct` | +| `tdd_mode` | `tdd` 或 `direct`。full workflow 离开 build 阶段前必须已选择。`tdd` 强制每个任务先写失败测试再实现;`direct` 不强制逐任务 TDD,但仍需相关测试与 bug 回归证据。hotfix/tweak 默认 `direct` | | `review_mode` | `off`、`standard` 或 `thorough`。full workflow 离开 build 阶段前必须已选择;hotfix/tweak 默认 `off` | -| `isolation` | `branch` 或 `worktree`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 默认 `branch` | +| `isolation` | `current`、`branch` 或 `worktree`。full 初始化可为 `null`,离开 build 前必须实际创建/选择 `branch` 或 `worktree`;hotfix/tweak 默认 `current`,不得在未创建分支时虚构为 `branch` | | `verify_mode` | `light` 或 `full`,可为空 | | `auto_transition` | `true` 或 `false`。只控制阶段守卫推进 phase 后是否自动调用下一个 skill;`false` 时由 `comet-state next` 输出 `manual`,暂停下一 skill 调用,但不阻止 phase 字段更新 | | `verify_result` | `pending`、`pass` 或 `fail` | +| `verify_failures` | 机器维护的连续验证失败次数;`verify-fail` 自动加一,`verify-pass` 或 `archive-reopen` 重置为 `0`。达到 `3` 后下一次失败必须进入超限策略决策 | | `verification_report` | 验证报告文件路径,verify 通过前必须指向已存在文件 | -| `branch_status` | `pending` 或 `handled`,分支处理完成后设为 `handled` | +| `branch_status` | `pending` 或 `handled`。verify 和 archive 执行期间保持 `pending`;归档改动提交且用户选择的分支处理完成后设为 `handled` | | `created_at` | change 创建日期(init 时自动写入),格式 `YYYY-MM-DD` | | `verified_at` | 验证通过时间,可为空 | | `archive_confirmation` | `null`、`pending` 或 `confirmed`。`verify-pass` 进入 archive 阶段时写入 `pending`;用户在 `/comet-archive` 最终确认选择「确认归档」后,`archive-confirm` transition 写入 `confirmed`;`archive-reopen` 会清空该字段,防止复用旧确认 | @@ -64,7 +66,7 @@ archived: false ## 状态机硬约束 -- `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree` +- full workflow 的 `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`;hotfix/tweak 可使用 `current` - `build → verify` 前,`build_mode` 必须已选择 - `build_mode: subagent-driven-development` 必须同时有 `subagent_dispatch: confirmed` - full workflow 离开 build 阶段前 `tdd_mode` 必须已选择为 `tdd` 或 `direct` diff --git a/assets/skills-zh/comet/reference/context-recovery.md b/assets/skills-zh/comet/reference/context-recovery.md index a2cb19c0..68ee0a70 100644 --- a/assets/skills-zh/comet/reference/context-recovery.md +++ b/assets/skills-zh/comet/reference/context-recovery.md @@ -9,7 +9,7 @@ 用户可能直接从 `/comet-open`、`/comet-design`、`/comet-build`、`/comet-verify`、`/comet-archive`、`/comet-hotfix` 或 `/comet-tweak` 回到流程。进入任意子 Skill 时,都先按 `comet/reference/scripts.md` 定位脚本,再用当前子 Skill 对应 phase 运行入口检查或恢复检查。不得依赖对话历史判断阶段。 ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` 若检查结果显示实际 phase、workflow 或 evidence 应由其他 Skill 处理,按脚本输出和 `/comet` 路由规则切换;不要在错误阶段继续补写状态。若存在未提交改动,先按 `comet/reference/dirty-worktree.md` 归因。 @@ -27,7 +27,7 @@ node "$COMET_RESUME_PROBE" probe --stdin ## 恢复步骤 ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` 脚本输出结构化恢复上下文(phase、已完成字段、待完成字段、恢复动作)。按 **Recovery action** 决定下一步。 diff --git a/assets/skills-zh/comet/reference/decision-point.md b/assets/skills-zh/comet/reference/decision-point.md index 31e6ad80..05ef6636 100644 --- a/assets/skills-zh/comet/reference/decision-point.md +++ b/assets/skills-zh/comet/reference/decision-point.md @@ -4,6 +4,17 @@ 本协议由所有包含用户决策点的 comet 子 skill 共享。凡标注为“阻塞点”或“用户决策点”的步骤,都必须按本协议处理。 +## 先判断是否真的需要用户决策 + +区分用户决策点、自动处理与停止条件: + +- **用户决策点**:存在两个或更多均合法、会改变范围、行为、风险承担或不可逆结果的选项,必须由用户选择 +- **自动处理**:只有一个安全且在当前请求范围内的下一步,例如修复明确失败、补齐可验证状态、重试幂等检查或按已持久化配置继续;直接执行并报告,不得制造确认 +- **停止条件**:依赖缺失、状态损坏、路径越界或外部命令不可用,导致当前没有合法下一步;报告阻塞原因和恢复条件,不得伪造选项 +- **手动衔接**:`NEXT: manual` 只是交还控制权,不是新的用户决策点;输出 `HINT` 后结束当前调用,不再询问“是否继续” + +只有第一类使用本协议。相邻且可同时回答的选择必须合并;已经持久化且仍有效的选择不得重复询问。展示选项前先检查平台能力和状态,只展示真实可执行的选项;若某字段只剩一个合法值,说明原因后直接采用,不为它单独创建停顿点。 + ## 核心规则 - 决策点是阻塞点。到达决策点时必须暂停,等待用户明确选择后才能继续 diff --git a/assets/skills-zh/comet/reference/subagent-dispatch.md b/assets/skills-zh/comet/reference/subagent-dispatch.md index 056efff1..06c8642d 100644 --- a/assets/skills-zh/comet/reference/subagent-dispatch.md +++ b/assets/skills-zh/comet/reference/subagent-dispatch.md @@ -11,9 +11,10 @@ > 仅在以下情况才停止并等待用户输入: > - 任务处于 **BLOCKED** 状态(`review_mode: standard` 下风险任务 1 轮 review-fix 或最终轻量复查仍未通过,或 `review_mode: thorough` 下任务级/最终审查 2 轮审查-修复仍未通过) > - 存在无法从仓库、计划或既有上下文消除的真实歧义 -> - 平台没有真实后台 agent 调度能力,需要用户改选 `executing-plans` > - 用户**明确**要求暂停 > +> 后台调度能力在执行中失效属于运行停止条件,不自动构成新的用户决策点:退出派发循环并返回 `/comet-build` Step 2 的同一个联合决策,移除 `subagent-driven-development`。若只剩一个合法执行方式,说明原因后直接采用;仍有多个合法方式时才等待用户重新选择。 +> > 此规则适用于整个派发循环,而非单个任务。 ## 开始前 @@ -34,14 +35,14 @@ - **Claude Code**:对每个 implementer,以及 `review_mode` 要求的 task reviewer、修复 agent 和 final reviewer 使用 `Agent` 工具并设置 `run_in_background: true`。禁止内联执行 task,禁止错误进入需要预先创建 team 的团队模式。 - **其他平台**:使用平台等效的后台 agent / Task / 多 agent 派发机制。 - **禁止**跨 task 或角色复用 implementer、reviewer 或修复 agent。每个 agent 拥有全新的隔离上下文,并且只接收当前角色所需的单个 task 上下文。 -- 若平台无真实后台派发能力,不得继续;暂停并等待用户改选 `build_mode: executing-plans`。 +- 若真实后台派发能力在执行中失效,不得继续派发或由主会话代写实现;返回 `/comet-build` Step 2 的同一个联合决策并移除不可用模式。不得另设“是否改用 executing-plans”的停顿点;只剩一个合法模式时直接采用。 ### 1. 派发 Prompt 与回报契约 每个 implementer 或修复 agent prompt 必须包含: - 当前单个 task 的完整文本、架构背景和依赖上下文 -- `Language: 使用 "$COMET_BASH" "$COMET_STATE" get language 读取到的 Comet 配置产物语言输出` +- `Language: 使用 comet state get language 读取到的 Comet 配置产物语言输出` - 允许修改的文件范围和禁止修改的范围 - 必须执行的测试命令和提交要求 - 修复 agent 还必须收到对应 reviewer 的完整反馈 @@ -142,8 +143,8 @@ Comet 不读取、不写入、也不要求任何 Superpowers `subagent-driven-de 4. 运行定向验证: ```bash -node "$COMET_STATE" task-checkoff "$PLAN_FILE" "$PLAN_TASK_TEXT" -node "$COMET_STATE" task-checkoff "openspec/changes//tasks.md" "$OPENSPEC_TASK_TEXT" +comet state task-checkoff +comet state task-checkoff openspec/changes//tasks.md ``` 仅在对应映射存在时运行第二条。脚本会要求任务文本恰好出现一次且该项已勾选;验证失败时不得进入下一个 task。 diff --git a/assets/skills/comet-any/SKILL.md b/assets/skills/comet-any/SKILL.md index 6b496a86..1adfb944 100644 --- a/assets/skills/comet-any/SKILL.md +++ b/assets/skills/comet-any/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-any -description: "Use when the user wants to customize Comet five-phase skills, create a workflow skill, organize an existing skill, or compose skills with Workflow Node / Skill Binding / Output Schema." +description: "Use only when explicitly invoked as /comet-any or when the user explicitly wants to customize Comet's five-phase workflow or create/upgrade a workflow Skill managed by Comet Creator. Do not use for general Skill authoring, cleanup, or review." --- # Comet Any - Skill Creator diff --git a/assets/skills/comet-any/reference/authored-zone-example.md b/assets/skills/comet-any/reference/authored-zone-example.md index bb27c524..0f6f2780 100644 --- a/assets/skills/comet-any/reference/authored-zone-example.md +++ b/assets/skills/comet-any/reference/authored-zone-example.md @@ -62,21 +62,26 @@ Run `node "$WORKFLOW_STATE" status` to confirm the detected Node. If the script' - If workflow state shows a Node as complete but its expected artifacts are missing, treat the Node as incomplete and re-enter it. - If the user resumes mid-Node with a different topic, confirm whether to continue the current Node or start a new one. -### Decision Points (must pause) +### Decision Classification And Decision Points + +Classify before acting: a user decision has two or more valid options that change scope, behavior, accepted risk, or an irreversible outcome; a sole safe next action is automatic handling; a missing dependency, corrupt state, or guard failure with no valid continuation is a stop condition; `NEXT: manual` only returns control. Only the first category must pause. | Situation | Action | |-----------|--------| -| First invocation, no workflow state exists | Initialize state, confirm the topic/scope with the user before starting the first Node | -| User input is ambiguous between two Nodes | Ask the user which Node they mean; do not guess | +| First invocation with an unambiguous topic and scope | Initialize state automatically and enter the first Node; do not pause to approve known information | +| Topic, scope, or target Node has two or more mutually exclusive valid interpretations | Merge them into one question and let the user choose; do not guess | | Node requires user approval of output before advancing | Stop after recording evidence; wait for explicit confirmation | -| Node fails its guard and the cause is unclear | Present the guard output and ask the user how to proceed | +| Accepting a WARNING/deviation or performing an irreversible publish has a real tradeoff | Show only currently executable options and persist the choice | + +When a Node guard fails, inspect evidence and perform the sole safe repair first. If a missing dependency or corrupt state prevents progress, report the stop condition and recovery requirement. Escalate to the decision table only when multiple valid recovery options would change scope or risk. ### Red Flags | Agent Thought | Actual Risk | |--------------|-------------| -| "The user mentioned the topic, so research is implicitly confirmed" | Mentioning ≠ confirming. Pause at the first Node boundary and confirm scope. | +| "Every first invocation needs another confirmation" | Clear input does not need duplicate approval; ask only when mutually exclusive interpretations still change scope. | | "The script returned NEXT: auto, so I should immediately load the next Skill" | `NEXT: auto` means the Node is done, not that you should skip confirmation. Check if the next Node has a decision point. | +| "The guard failed, so ask the user what to do" | Diagnose automatically and apply the sole safe repair first; if no valid action exists, report a stop condition instead of inventing options. | | "This looks like the same topic as last time, resume from where we left off" | Always re-read state. Conversation memory is unreliable after context compaction. | | "The exit check passed, so the work is good enough" | Exit checks are mechanical. Your job is to judge quality beyond the check — sparse notes, shallow analysis, or missing perspectives are not caught by scripts. | ``` diff --git a/assets/skills/comet-any/reference/subagents/pause-points-author.md b/assets/skills/comet-any/reference/subagents/pause-points-author.md index e814b712..dac03db0 100644 --- a/assets/skills/comet-any/reference/subagents/pause-points-author.md +++ b/assets/skills/comet-any/reference/subagents/pause-points-author.md @@ -4,7 +4,7 @@ This file is a portable lane brief, not a platform-native custom agent. If you n ## Responsibilities -Design the places where the user must pause and choose, plus cross-device recovery. Pause points must be explicit user choices that cannot be bypassed by default recommendations, historical preferences, or automatic advancement. +Design only the places where the user genuinely must choose, plus cross-device recovery. First distinguish four categories: user decision, automatic handling, stop condition, and manual handoff. Create a user pause only when two or more valid options change scope, behavior, accepted risk, or an irreversible outcome. Execute a sole safe action directly, report a missing dependency or corrupt state as a stop condition, and return control for a manual handoff. Genuine user decisions cannot be bypassed by defaults, historical preferences, or automatic advancement. Must cover: @@ -15,11 +15,10 @@ Must cover: Read the common input from the main session, especially: -- `confirm-generate`, `revise-proposal`, and `cancel` from the Skill Creator confirmation page. -- Eval workload choice: `skip / quick / full eval`. -- Human approval before installation. -- Blockers such as unresolved candidates, ambiguity, capability gaps, and executable disclosures. -- Runner recovery state and cross-device recovery entry. +- `confirm-generate`, `revise-proposal`, and `cancel` from the Skill Creator confirmation page: these are user decisions that change the generated result. +- Eval workload (`skip / quick / full eval`) and human approval before installation: treat them as decisions only when multiple valid options actually remain. +- Missing or stale eval evidence, unresolved candidates, ambiguity, capability gaps, and executable disclosures: classify each as automatically repairable, a no-path stop condition, or a real decision with multiple recovery choices. Do not turn all blockers into pause points. +- Runner recovery state and cross-device recovery entry: reuse persisted choices that remain valid instead of asking again on resume. Use file handoff: the main session provides paths instead of pasting large bodies of text. Do not inherit main-session history; use only this brief, common input, workflow protocol, and existing drafts. @@ -33,21 +32,23 @@ model: prompt: You are the pause point author subagent. First read this brief, the common input path, workflow protocol path, Skill draft path, and report file path. - Start by asking questions: if user choices, blocker recovery, or cross-device state are unclear, return NEEDS_CONTEXT. - Do not guess or fill in missing pause points. + First classify each candidate as a user decision, automatic handling, stop condition, or manual handoff. If facts needed for classification are missing, return NEEDS_CONTEXT. + Do not guess missing choices, and do not disguise automatic repair, guard failure, capability gaps, a sole valid action, or manual handoff as a user pause. Only produce decision-points and recovery drafts; do not write Bundle state and do not execute candidate scripts. Write the full pause point draft to the report file path and return only a status summary of 15 lines or fewer. ``` ## Output Requirements -Return a pause point draft that explains: +Return a classification table first, then describe genuine user pause points: +- Which category each candidate belongs to and the evidence for that classification. - The trigger condition for every pause point. - The choices available to the user. - Which Node each choice enters. - Where pause point evidence is written. -- During recovery, how to show current Node, blocking reason, suggested next step, and options. +- How automatic handling advances directly, and how stop conditions report recovery requirements without inventing options. +- During recovery, how to show the current Node, blocking reason, suggested next step, and real options while reusing persisted choices. Pause points must fit the current workflow protocol, not merely list original Comet pause points. @@ -56,8 +57,10 @@ Pause points must fit the current workflow protocol, not merely list original Co Before returning, check: - Every user pause point has trigger condition, options, next Node, and evidence location. +- Every pause has at least two currently executable valid options; adjacent choices that can be answered together are merged, and a sole valid value creates no pause. +- Guard failures, deterministic retries, state reconciliation, capability gaps, and `NEXT: manual` are classified according to their actual semantics instead of defaulting to user questions. - Default recommendations, historical preferences, and automatic advancement cannot bypass required pause points. -- The recovery summary can show current Node, blocking reason, suggested next step, and options. +- The recovery summary can show the current Node, blocking reason, suggested next step, and real options without re-asking choices that remain valid. - Cross-device recovery does not rely on current-session memory. - Pause points fit the current composed Skill instead of copying original Comet pause points. diff --git a/assets/skills/comet-any/reference/subagents/skill-reviewer.md b/assets/skills/comet-any/reference/subagents/skill-reviewer.md index 5fd0094b..542a0893 100644 --- a/assets/skills/comet-any/reference/subagents/skill-reviewer.md +++ b/assets/skills/comet-any/reference/subagents/skill-reviewer.md @@ -62,6 +62,8 @@ Any of these must produce blocking findings: Node Skill, or a subagent handoff Node does not require the implementation subagent prompt to load that Skill. - User pause points are missing, or can be bypassed by defaults. +- Deterministic repair, guard failure, state reconciliation, a capability gap, a sole valid action, or `NEXT: manual` is treated as a user pause by default; or adjacent choices that can be answered together are split into serial confirmations. +- The entry Skill frontmatter description does not identify it as the managed workflow entry/resume router, or an internal Node Skill description allows ordinary tasks to trigger it without explicit invocation or entry/runtime routing. - English Skills mix in Chinese process sentences. - Nested Skill calls use provider prefixes. - User-visible `SKILL.md` leaks generated audit sections, source hashes, or internal metadata. diff --git a/assets/skills/comet-any/reference/subagents/workflow-entry-author.md b/assets/skills/comet-any/reference/subagents/workflow-entry-author.md index f790b2ef..c8cf92a6 100644 --- a/assets/skills/comet-any/reference/subagents/workflow-entry-author.md +++ b/assets/skills/comet-any/reference/subagents/workflow-entry-author.md @@ -20,7 +20,7 @@ Quality bar: the `comet/SKILL.md` Decision Core (see `reference/authored-zone-ex - **Semantic current-Node detection** — how to determine which Node the user is in, beyond just running the script. Model comet's Step 0 (detect intent from user message, check Node order, handle "belongs to earlier/later Node" conflicts) + Step 1 (read state, trust files over stale state). - **Resume and drift rules** — what to do when context resumes (re-detect from scratch, never trust conversation history), when state says DONE but artifacts are missing, when the user's topic shifts mid-Node. -- **Decision points** — explicit table of situations that MUST pause for user confirmation (first invocation scope, ambiguous Node, user approval required, guard failure). +- **Decision classification and decision points** — first distinguish user decisions, automatic handling, stop conditions, and manual handoffs, then tabulate only genuine user choices. A clear first invocation, an objectively repairable guard failure, a sole valid next action, and `NEXT: manual` must not manufacture confirmation. - **Red flags** — the "agent thought → actual risk" pattern that catches self-deception (e.g., "user mentioned the topic so research is confirmed" → mentioning ≠ confirming). A Decision Core without these four sections is a stub, not a Decision Core. The entry is the most-loaded file — it is what makes the Skill feel intelligent or mechanical. @@ -49,10 +49,10 @@ model: prompt: You are the workflow entry author subagent. First read this brief, the common input path, script contract path, workflow protocol path, and report file path. - Start by asking questions: if startup routing, recovery paths, current-Node detection, or user pause points are unclear, return NEEDS_CONTEXT. + First classify user decisions, automatic handling, stop conditions, and manual handoffs. If startup routing, recovery paths, current-Node detection, or genuine user choices are unclear, return NEEDS_CONTEXT. Do not guess or fill in missing flow details. Only write the entry SKILL.md draft; do not write internal Node Skills, Bundle state, or execute candidate scripts. - The Decision Core MUST include four subsections: ### Automatic Node Detection (Step 0 intent detection + Step 1 state read + resume rules), ### Decision Points (explicit pause table), ### Red Flags (agent thought → actual risk table). A Decision Core without these is a stub. + The Decision Core MUST include four subsections: ### Automatic Node Detection (Step 0 intent detection + Step 1 state read + resume rules), ### Decision Classification And Decision Points (genuine user choices only), ### Red Flags (agent thought → actual risk table). Do not list guard failures, deterministic repair, a sole valid action, or manual handoff as a user decision. A Decision Core without these is a stub. Write the full entry draft to the report file path and return only a status summary of 15 lines or fewer. ``` @@ -66,7 +66,7 @@ The entry draft must show: - The Node route table is reference only; it must not use "immediately execute" or "must load" execution directives. - When users customize existing Comet Skills, the entry must list Required Skill Calls as Node-local obligations, not as an immediate execution checklist. -- User pause points, recovery paths, and reference files are visible. +- User decisions, automatic handling, stop conditions, manual handoffs, recovery paths, and reference files are visible. Pause only when at least two real valid options remain, and merge adjacent choices. - When users customize existing Comet Skills, preserve the open / design / build / verify / archive main path and Guardrails. Forbidden: @@ -85,6 +85,7 @@ Before returning, check: - The entry has no immediate-load checklist for Node Skills. - The Node route is reference, not execution steps. - Automatic advancement references script outputs `NEXT:` and `SKILL:`. +- A clear first invocation initializes directly, guard failures are diagnosed automatically or reported as stop conditions, and `NEXT: manual` only returns control; none is presented as a fabricated user decision. - User-visible English prose is consistent and does not mix in Chinese process sentences. ## Required Claim diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 3da26d89..0e26601d 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-archive -description: "Use when a Comet change has passed verification and needs archive confirmation, delta spec merge, or archive-phase recovery." +description: "Use only when explicitly invoked as /comet-archive or routed by the root Comet skill/runtime to the archive phase; confirm archive, merge delta specs, and finish the branch." --- # Comet Phase 5: Archive (Archive) @@ -8,7 +8,7 @@ description: "Use when a Comet change has passed verification and needs archive ## Prerequisites - Verification passed (Phase 4 complete) -- Branch handled +- Archive commit and branch handling are still pending (`branch_status: pending`) - `verify_result: pass` in `openspec/changes//.comet.yaml` ## Steps @@ -35,7 +35,7 @@ After entry verification passes, **must follow the `comet/reference/decision-poi Before confirmation, show the user a brief summary: - Change name - Verification report path and result -- Branch handling status +- Current branch/workspace and attribution summary for pre-existing dirty changes - Irreversible actions this archive will perform: merge main specs with OpenSpec delta semantics, annotate design doc / plan, and move the change to the archive directory The user confirmation question must be presented as a single-select question with these options: @@ -70,11 +70,6 @@ The script automatically executes: If script returns non-zero exit code, report error and stop. If script returns zero exit code, archive is complete. -After a successful archive, clear the current execution context; this command is idempotent: - -```bash -comet state clear-selection -``` The summary `X/Y steps succeeded` counts real executed steps and does not double-count delta spec sync or document annotation. The script calls OpenSpec archive to merge `ADDED/MODIFIED/REMOVED/RENAMED` delta semantics into main specs, then verifies main specs do not contain delta-only section headings. @@ -88,31 +83,57 @@ Spec lifecycle completes here: brainstorming → delta spec → implementation → verification → main spec merge → design doc annotation → archive ``` -### 4. Commit the Archive Changes +### 4. Commit Archive Changes with Exact Paths The archive script only moves files and merges the spec; it does not commit. After archiving, the worktree holds these uncommitted changes: - The change directory moved from `openspec/changes//` to `openspec/changes/archive/YYYY-MM-DD-/` - The main spec content merged via delta semantics - Archive metadata annotations on the design doc / plan -**You must prompt the user to commit these archive changes**, otherwise the archived result stays in the worktree. After showing the pending files, suggest: +After archive, read `git status --short` and compare it with the pre-archive dirty-worktree attribution baseline. Stage only paths attributable to this change: the original active path, actual archive path printed by the command, main specs changed by this delta, and archive metadata on this Design Doc/Plan. Stop if any path cannot be attributed. + +Use explicit pathspecs, then inspect the staged diff. Never stage the whole repository or mix the user's pre-existing changes into the archive commit: ```bash -git add -A +git add -- +git diff --cached --stat git commit -m "chore: archive " ``` -If branch handling (phase 4) chose not to merge into the main branch yet, finish up via the selected option (merge / PR / keep branch) together with this commit. +Stop if the commit fails or the staged diff contains unrelated paths. + +### 5. Handle the Branch After the Archive Commit + +After the archive commit succeeds, **immediately execute:** use the Skill tool to load Superpowers `finishing-a-development-branch`. This ordering ensures the final branch or PR contains the main-spec merge and archive metadata. + +If the skill is unavailable, stop and prompt the user to enable/install it; do not mark `branch_status` handled. After loading it, pause under `comet/reference/decision-point.md` and let the user choose: + +1. Merge locally into the main branch +2. Push and create a PR +3. Keep the current branch for later + +Archive is already complete, so do not offer "discard work". Only after the selected operation succeeds (or the user explicitly keeps the branch), run: + +```bash +comet state set branch_status handled +comet guard archive +comet state clear-selection +``` + +The archive guard must verify both archive completeness and `branch_status: handled`; a failure means the workflow is still incomplete. ## Exit Conditions - Archive script executed successfully (exit code 0) - Archive directory `openspec/changes/archive/YYYY-MM-DD-/` exists - Archived `.comet.yaml` contains `archived: true` +- Archive changes were committed with exact pathspecs +- The user's branch decision completed and archived state has `branch_status: handled` +- `comet guard archive` passes The archive script moves `openspec/changes//` to `openspec/changes/archive/YYYY-MM-DD-/`. -> **WARNING**: After successful archive, **do not run** `comet guard archive` against the old active change name; the active directory no longer exists. Doing so will cause the guard to error with "change directory not found". Archive completeness is determined by script exit code and archived directory state. +`comet guard archive` resolves the actual archive directory from the original change name; do not construct a dated archive path manually. ## Complete diff --git a/assets/skills/comet-build/SKILL.md b/assets/skills/comet-build/SKILL.md index ce28368e..f2065785 100644 --- a/assets/skills/comet-build/SKILL.md +++ b/assets/skills/comet-build/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-build -description: "Use when a full Comet change has completed design and needs an implementation plan, execution-mode selection, or resumed build tasks." +description: "Use only when explicitly invoked as /comet-build or routed by the root Comet skill/runtime to a full workflow build phase; create or recover the implementation plan and execute tasks." --- # Comet Phase 3: Plan and Build (Build) @@ -23,7 +23,7 @@ comet state check build Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. -**Idempotency**: All build phase operations can be safely re-executed. Read `.comet.yaml` `phase` field to confirm still in build, read plan header `base-ref`, then use `grep -n '\- \[ \]' tasks.md | head -1` to find the first unchecked task. Already-committed tasks must not be re-committed. +**Idempotency**: All build phase operations can be safely re-executed. Read `.comet.yaml` `phase` to confirm build, read the plan header `base-ref`, then parse tasks.md checkboxes in document order and resume from the first unchecked task. Already-committed tasks must not be re-committed. ### 1. Create Plan (Subagent Offload) @@ -65,7 +65,7 @@ After the subagent completes: - If a valid file path is returned and the file exists, record it as the plan - If the subagent fails or returns an invalid path, fall back to loading the Superpowers `writing-plans` skill inline in the main session (degraded fallback) -### 2. Update Plan Status and Provide Plan-Ready Pause Point +### 2. Update Plan Status and Jointly Confirm Workflow Configuration Record plan path: @@ -75,16 +75,18 @@ comet state set plan docs/superpowers/plans/YYYY-MM-DD-feature.md No manual phase update needed — guard auto-transitions when exit conditions are met. -After the plan is recorded, immediately provide a new user decision point: +Check current platform capabilities before presenting the joint decision: verify whether `using-git-worktrees` is available, whether a real background subagent/Task/multi-agent dispatcher exists, and whether the repository can safely create a branch. Show only isolation and execution options that are currently executable. If a field has only one valid value, explain why and apply it without manufacturing another pause. + +After recording the plan, provide exactly **one joint decision point** that collects whether to continue now, available workspace isolation, available execution method, TDD mode, and code review mode. The branch name must be confirmed in the same Step 2 joint decision when `branch` is selected. Do not ask continue/pause first and then create another configuration or naming blocker. | Option | Behavior | Description | |--------|----------|-------------| -| A | Continue execution | Stay in the current model and proceed to Step 3 to choose workspace isolation, execution method, TDD mode, and code review mode | +| A | Continue with configuration | Provide all Step 3 isolation, execution, TDD, and review choices in the same response; include the branch name when branch is selected | | B | Pause to switch model | Record `build_pause: plan-ready`, stop this `/comet-build` invocation, and allow the user to resume later from `/comet` or `/comet-build` | -This is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to explicitly choose**. Must not auto-continue and must not write the pause into `build_mode`. +This is a user decision point. **Follow `comet/reference/decision-point.md` once and show the plan summary, pause option, and every executable Step 3 setting together**. Continuing requires all settings and any conditional branch name in the same response. Do not auto-select or write the pause into `build_mode`. -When the user chooses to continue: +When the user chooses to continue and supplies complete configuration: ```bash comet state set build_pause null @@ -98,17 +100,17 @@ comet state set build_pause plan-ready After setting `build_pause: plan-ready`, stop the current invocation. Do not choose `isolation` or `build_mode`, and do not load an execution skill. -### 3. Select Workflow Configuration +### 3. Apply the Confirmed Workflow Configuration -If resuming with `build_pause: plan-ready` and the `plan` file exists, do not rerun `writing-plans`. First tell the user the workflow is stopped at the plan-ready pause point; after the user confirms continuing, set: +If resuming with `build_pause: plan-ready` and the `plan` file exists, do not rerun `writing-plans`. Reissue the same joint Step 2 decision and clear the pause only after the user supplies complete configuration: ```bash comet state set build_pause null ``` -Then continue this step to choose workspace isolation, execution method, TDD mode, and code review mode. +Then apply the workspace isolation, execution method, TDD mode, and code review mode below. -Plan has been written to the current branch. Before starting execution, **ask the user to choose workspace isolation, execution method, TDD mode, and code review mode in a single interaction**: +The plan is on the current branch. These settings are all part of the single Step 2 decision: **Workspace Isolation**: @@ -133,7 +135,7 @@ Plan has been written to the current branch. Before starting execution, **ask th - Task count ≤ 2 and no cross-module dependencies → Recommend B - From hotfix path → Recommend B -This is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to explicitly choose isolation method, execution method, TDD mode, and code review mode**. Must not choose `branch` or `worktree` based on recommendation rules, and must not choose the execution method, TDD mode, or code review mode based on recommendation rules. Recommendation rules are for suggestion only, not a substitute for user confirmation. +These tables are part of the Step 2 joint decision and do not create another pause. First remove options that capability preflight found unavailable. When multiple valid options remain, do not choose `branch` or `worktree`, execution method, TDD mode, or review mode from recommendations. Recommendations explain a preference; they never replace user confirmation. After user selection, update `isolation`, execution method, TDD mode, and code review mode fields: @@ -143,14 +145,14 @@ comet state set isolation - If the user chooses `executing-plans`: run `comet state set subagent_dispatch null`, then run `comet state set build_mode executing-plans` - If the user chooses `subagent-driven-development`: first confirm the current platform has real background subagent / Task / multi-agent dispatch capability; after confirming, run `comet state set subagent_dispatch confirmed`, then run `comet state set build_mode subagent-driven-development` -- If real background dispatch capability cannot be confirmed, must not write `build_mode: subagent-driven-development`; must pause and wait for the user to choose `executing-plans` instead +- If real background dispatch capability cannot be confirmed, do not show or write `build_mode: subagent-driven-development`. If recovered state already records that mode but capability is unavailable, return to the same Step 2 joint decision with only executable modes; do not create a separate "switch to executing-plans" pause **TDD Mode**: | Option | Meaning | Applicable Scenario | |--------|---------|---------------------| | `tdd` | Write a failing test first for each task, then implement | Recommended. Changes involving business logic, new features, APIs | -| `direct` | Implement directly, no enforced TDD flow | Changes that don't need test coverage, or user chooses to skip tests and write code directly. hotfix/tweak presets default to `direct` | +| `direct` | Implementation-first, no per-task Red-Green-Refactor requirement | Still requires relevant tests and bug-regression evidence; hotfix/tweak presets default to `direct` | Run `comet state set tdd_mode ` @@ -183,18 +185,18 @@ Without `direct_override: true`, `build_mode=direct` in full workflow is blocked **Execute isolation**: -- **branch**: Recommend a branch name based on the workflow type and current date, then let the user confirm or input a custom name. This is a user decision point — **must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly confirm or override the branch name**. Must not skip this step and create the branch directly. +- **branch**: Use the branch name already confirmed in Step 2; do not pause again. If legacy recovery no longer has the branch name from that joint decision, re-enter the same Step 2 decision instead of creating a separate branch-naming decision. Branch naming convention: - Read the `workflow` field from `.comet.yaml` to determine the prefix - `workflow: full` → recommend `feature/YYYYMMDD/` - `workflow: hotfix` → recommend `hotfix/YYYYMMDD/` - `workflow: tweak` → recommend `tweak/YYYYMMDD/` - - Date is derived from `date +%Y%m%d` at runtime + - Format the current runtime date as `YYYYMMDD`; do not depend on one shell's date command Example: if change name is `fix-login-bug` and today is 2026-06-09, recommend `feature/20260609/fix-login-bug` - After the user confirms or provides a custom branch name, run `git checkout -b `, subsequent work on the new branch. + Immediately after Step 2 confirms the branch name, run `git checkout -b ` and continue on the new branch. - **worktree**: Must use the Skill tool to load the Superpowers `using-git-worktrees` skill to create isolated workspace. Do not bypass this skill with plain shell commands or native tools; if the skill is unavailable, stop the process and prompt to install or enable Superpowers skills. @@ -217,7 +219,7 @@ Do not begin source writes until this binding succeeds. - `build_mode: executing-plans`: **Immediately execute:** Use the Skill tool to load the Superpowers `executing-plans` skill. Skipping this step is prohibited. If the skill is unavailable, stop the process and prompt to install or enable the corresponding skill; do not substitute with normal conversation. After the skill loads, ARGUMENTS must include the same Language constraint as Step 1: `Language: Use the configured Comet artifact language from comet state get language`. Execute according to plan. - `build_mode: subagent-driven-development`: The main session only coordinates and must not write implementation code directly. **Immediately execute:** Use the Skill tool to load the Superpowers `subagent-driven-development` skill. After the skill loads, read `comet/reference/subagent-dispatch.md` for Comet-specific extensions (real background dispatch, task isolation, checkoff verification, TDD constraints, continuous execution, context recovery) and apply them alongside the skill's workflow. If they conflict, the more specific Comet extensions take precedence. -- If the current platform has no real background agent dispatch capability, must pause and wait for the user to choose main window execution instead. After the user chooses, must run `comet state set build_mode executing-plans`, then follow the `build_mode: executing-plans` branch to load the Superpowers `executing-plans` skill. Must not continue executing tasks before the user explicitly chooses. +- If the execution preflight finds that background dispatch capability has disappeared, do not execute directly in the main window and do not create a new second decision. Return to the same Step 2 joint decision with the unavailable mode removed. After the user selects main-window execution there, run `comet state set build_mode executing-plans`, then continue through that branch. **TDD Mode Execution Constraints**: @@ -237,7 +239,7 @@ Under `executing-plans`, the main session executes tasks directly (no isolated i Requirements (apply to `standard` and `thorough`): - the `requesting-code-review` skill must be loaded before `comet guard build --apply` -- if `requesting-code-review` skill is unavailable, skip the review gate but must record `` in tasks.md, then continue guard transition +- if `requesting-code-review` is unavailable under `standard` or `thorough`, stop and ask the user to install/enable it and retry, or explicitly switch to `review_mode: off` with a recorded reason; never skip the gate or continue guard before that explicit switch - CRITICAL review findings (security vulnerabilities, data loss risk, build/test failures) must be fixed first and must not be carried into verify - if non-CRITICAL review findings are accepted, record the acceptance reason and impact scope in tasks.md, the commit body, a verification report draft, or another durable artifact @@ -275,7 +277,7 @@ When creating an independent change, must invoke `/comet-open`, not `/opsx:new` Build is the longest phase and may span many tasks. To support resume after context compaction: -- **After each task**: complete acceptance per the current execution branch and `review_mode` before checking off and committing. `subagent-driven-development` dispatches no per-task reviewer under `off`; under `standard`, a per-task reviewer fires only when the task hits a risk signal; under `thorough`, every task gets a per-task reviewer. All modes must perform targeted verification by unique task text. Use `grep -c '\- \[ \]' tasks.md` to check remaining unchecked count; no need to re-read the entire file +- **After each task**: complete acceptance per the current execution branch and `review_mode` before checking off and committing. `subagent-driven-development` dispatches no per-task reviewer under `off`; under `standard`, a per-task reviewer fires only when the task hits a risk signal; under `thorough`, every task gets a per-task reviewer. All modes must perform targeted verification by unique task text. Parse tasks.md checkboxes to count remaining work without rereading unrelated task bodies - **Context compression recovery**: Follow `comet/reference/context-recovery.md` with phase set to `build`. - **User manual-change resume**: handle uncommitted changes through `comet/reference/dirty-worktree.md`. That protocol defines checks, attribution, and prohibitions. Build-specific handling: 1. After attribution, if the diff implies plan or spec changes, handle it through Step 4 "Spec Incremental Updates" @@ -320,5 +322,5 @@ comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to enter the next phase -- `NEXT: manual` → do not invoke the next skill; prompt user to run `/` manually +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed diff --git a/assets/skills/comet-design/SKILL.md b/assets/skills/comet-design/SKILL.md index e960b900..580c3c03 100644 --- a/assets/skills/comet-design/SKILL.md +++ b/assets/skills/comet-design/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-design -description: "Use when a full Comet change has completed open but lacks a Superpowers Design Doc, or design must resume from an OpenSpec handoff package." +description: "Use only when explicitly invoked as /comet-design or routed by the root Comet skill/runtime to a full workflow design phase; create or recover the deep technical Design Doc." --- # Comet Phase 2: Deep Design (Design) @@ -20,7 +20,7 @@ Locate scripts via `comet/reference/scripts.md`, then run entry verification. Wh ```bash comet state select -node "$COMET_STATE" check design +comet state check design ``` Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. @@ -32,7 +32,7 @@ Proceed to Step 1 after verification passes. The script outputs specific failure **Must be generated by script. Agent writing summaries on the fly is not allowed.** ```bash -node "$COMET_HANDOFF" design --write +comet handoff design --write ``` The script reads the change `.comet.yaml` `context_compression` snapshot, then generates and records the matching handoff package. @@ -71,7 +71,7 @@ The beta handoff package is a **structured spec projection** that reduces OpenSp If full context is genuinely needed, explicitly run: ```bash -node "$COMET_HANDOFF" design --write --full +comet handoff design --write --full ``` Handoff package sources come from OpenSpec open phase artifacts: @@ -87,7 +87,7 @@ Handoff package sources come from OpenSpec open phase artifacts: When loading the skill, ARGUMENTS must include: ```text -Language: Use the configured Comet artifact language from `"$COMET_BASH" "$COMET_STATE" get language` +Language: Use the configured Comet artifact language from `comet state get language` ``` After the skill loads, follow its guidance and use the following context: @@ -146,9 +146,7 @@ Only after the user explicitly confirms, proceed to Step 2. If the user requests After the user confirms the design proposal, before creating the Design Doc, create or update the incrementally maintained checkpoint file and finalize it as the confirmed design summary: -```bash -mkdir -p openspec/changes//.comet/handoff -``` +Use the current platform's file API to ensure `openspec/changes//.comet/handoff/` exists; do not rely on a POSIX-only directory command. `openspec/changes//.comet/handoff/brainstorm-summary.md` structure: @@ -180,14 +178,9 @@ mkdir -p openspec/changes//.comet/handoff - `openspec/changes//.comet/handoff/design-context.md` (or `spec-context.md` in beta mode) - `openspec/changes//.comet/handoff/design-context.json` (or `spec-context.json` in beta mode) -### 1e. Active Context Compaction Gate - -After Step 1d completes and `brainstorm-summary.md` is written, enter the active compaction gate before creating the Design Doc. At this point the OpenSpec handoff, brainstorming decisions, and pending items are durable, so the agent should proactively release the earlier Spec and brainstorming context to preserve window space for Step 2 and the later Build phase. +### 1e. Compaction Policy (Non-blocking Here) -Rules: -- If the current platform provides a native context compaction/cleanup mechanism (for example, the host agent's compact/compaction command, tool, or UI action), trigger active compaction here once; do not try to fake compaction through a shell script. -- The compaction resume prompt must include the change name, current step (Design Step 2), and the three handoff file categories listed above. -- If the current platform cannot be compacted programmatically by the agent, pause and tell the user to run the host platform's manual compaction action; continue to Step 2 only after the user confirms compaction is unavailable or asks to continue. +`brainstorm-summary.md` is a recovery checkpoint, but do not discard live design context before the Design Doc is persisted. Continue directly to Step 2. Active compaction moves until after the Design Doc, state, and latest handoff are all durable. ### 2. Create Design Doc @@ -214,17 +207,25 @@ First record the design_doc path. If Spec Patches wrote back delta spec (added o ```bash # Record design_doc path -node "$COMET_STATE" set design_doc docs/superpowers/specs/YYYY-MM-DD-topic-design.md +comet state set design_doc docs/superpowers/specs/YYYY-MM-DD-topic-design.md # If delta spec changes exist, regenerate handoff (update hash) -node "$COMET_HANDOFF" design --write +comet handoff design --write # Auto-transition to next phase -node "$COMET_GUARD" design --apply +comet guard design --apply ``` If there are no delta spec changes, skip the handoff regeneration step. The state file updates automatically; no manual editing of other fields needed. +### 3a. Optional Active Context Compaction + +Consider active compaction only **after the Design Doc and state evidence are persisted** and before Build. First confirm `design_doc`, the latest handoff, `handoff_hash`, and the design guard are durable so recovery never depends on an unwritten design judgment. + +- If the host exposes a programmatically callable native compaction mechanism and the context window is under pressure, invoke it once and include the change, next step, Design Doc, and handoff files in the resume prompt +- If only the user can trigger compaction, give one non-blocking suggestion and continue; it **must not block when programmatic compaction is unavailable** and must not create another confirmation point +- Never fake host compaction with a shell command or an agent summary + ## Exit Conditions - Design Doc created and saved @@ -235,12 +236,12 @@ If there are no delta spec changes, skip the handoff regeneration step. The stat - In beta mode, `spec-context.json` must be structurally valid and reference the current source files (enforced by guard) - If new capabilities or supplementary acceptance scenarios exist, OpenSpec delta spec has been created/updated - `design_doc` written to `.comet.yaml` -- **Phase guard**: Run `node "$COMET_GUARD" design --apply`; after all PASS, auto-transitions to `phase: build` +- **Phase guard**: Run `comet guard design --apply`; after all PASS, auto-transitions to `phase: build` Must use `--apply` before exit: ```bash -node "$COMET_GUARD" design --apply +comet guard design --apply ``` ## Context Compression Recovery @@ -252,9 +253,9 @@ Follow `comet/reference/context-recovery.md` with phase set to `design`. Follow `comet/reference/auto-transition.md`. Key command: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to enter the next phase -- `NEXT: manual` → do not invoke the next skill; prompt user to run `/` manually +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed diff --git a/assets/skills/comet-hotfix/SKILL.md b/assets/skills/comet-hotfix/SKILL.md index 35e5244a..a54c8592 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-hotfix -description: "Use when the user wants to fix an existing behavior bug without adding capability or needing full design; also use when resuming hotfix workflow." +description: "Use only when explicitly invoked as /comet-hotfix or routed by the root Comet skill/runtime to the hotfix preset; fix an existing behavior bug, not an ordinary unmanaged bugfix." --- # Comet Preset Path: Hotfix @@ -20,7 +20,7 @@ Quick bug fix workflow: open → build → verify → archive. Skip brainstormin ### 0. Output Language Constraint -Streamlined OpenSpec artifacts must use the configured Comet artifact language. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after initialization, use `"$COMET_BASH" "$COMET_STATE" get language`. +Streamlined OpenSpec artifacts must use the configured Comet artifact language. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after initialization, use `comet state get language`. Execution chain: open → build → root cause check → verify → archive. Hotfix provides default decisions for each phase: streamlined open, direct build, root cause confirmation, scale-based verification, and final archive confirmation after verification passes. @@ -34,52 +34,54 @@ Reuse Comet open capability to create change, but use hotfix defaults: do not ex **Immediately execute:** Use the Skill tool to load the `openspec-new-change` skill. Skipping this step is prohibited. -After the skill loads, follow its guidance to create streamlined artifacts: - - `proposal.md` — problem description + root cause analysis + fix goal (no solution comparison needed) - - `design.md` — fix solution (one is enough, no multi-solution comparison needed) - - `tasks.md` — fix task list -- **No delta spec needed** (unless fix changes existing spec acceptance scenarios) - -Initialize Comet state file: +After the skill loads, create the change skeleton first, then immediately initialize recoverable state and bind the current change: ```bash -node "$COMET_STATE" init hotfix +comet state init hotfix +comet state select +comet state check open ``` -Verify initialized state: - -```bash -node "$COMET_STATE" check open -``` +Hotfix defaults to `isolation: current`, truthfully indicating execution in the current workspace. Change it to `branch` or `worktree` only after that workspace is actually created/selected. Then create the streamlined artifacts: + - `proposal.md` — problem description + root cause analysis + fix goal (no solution comparison needed) + - `design.md` — fix solution (one is enough, no multi-solution comparison needed) + - `tasks.md` — fix task list +- **No delta spec needed** (unless fix changes existing spec acceptance scenarios) Run phase guard to transition open → build: ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` Check `auto_transition` to decide whether to continue: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → continue to Step 2 -- `NEXT: manual` → pause, follow `HINT` to prompt user to run `/` manually +- `NEXT: manual` → return control with `HINT` and end the current invocation; do not ask whether to continue ### 2. Direct Build (preset build) -Use hotfix defaults: `build_mode: direct`, `review_mode: off` (hotfix/tweak skip review_mode selection — the guard does not require it for preset workflows). Skip Superpowers `brainstorming` and `writing-plans` (unless tasks > 3; if exceeds 3 tasks, transfer to `/comet-build`'s plan and execution method selection — note this does NOT trigger full workflow upgrade, only switches execution method). +Use hotfix defaults: `build_mode: direct`, `tdd_mode: direct`, `review_mode: off`, and `isolation: current`. Here `direct` skips full planning/TDD orchestration; it never skips reproduction, regression coverage, or verification. Skip Superpowers `brainstorming` and `writing-plans`; **task count alone does not route to `/comet-build`**. Keep larger task lists ordered in the current hotfix and ask about upgrading only when a qualitative-change signal or scope tripwire is hit. Before continuing or starting changes, handle uncommitted changes through `comet/reference/dirty-worktree.md`. If attribution shows a qualitative-change signal or file-count tripwire is hit, handle it through this file's "Upgrade Assessment". -**Immediately execute:** Execute tasks one by one according to tasks.md: +Before implementation, **reproduce the bug and record failing evidence first**: + +1. Confirm the reported old behavior with minimal repeatable steps and record the command, input, and actual result +2. When automatable, add and run a regression test that fails for this bug; confirm the failure is caused by the bug rather than the environment or test itself +3. If automation is temporarily impossible, record why plus repeatable manual failing evidence in the proposal/verification report; never edit code without evidence + +After RED evidence exists, execute tasks one by one according to tasks.md: 1. Read `openspec/changes//tasks.md`, get incomplete task list 2. For each incomplete task: - Modify code according to task description - Run project formatter (e.g., `mvn spotless:apply`, `npm run format`) - - Run related tests to confirm pass + - First rerun the new failing regression test and confirm it turns green, then run related tests - Check corresponding `- [ ]` to `- [x]` in tasks.md - Commit code, commit message format: `fix: ` 3. After all tasks complete, explicitly run relevant project tests and build commands @@ -107,7 +109,7 @@ For specific investigation, minimal failing test, fix verification, and keeping After root cause is confirmed eliminated, run phase guard to transition build → verify: ```bash -node "$COMET_GUARD" build --apply +comet guard build --apply ``` State automatically updates to `phase: verify`, `verify_result: pending`, then enter verification. @@ -118,7 +120,7 @@ Reuse `/comet-verify`, with comet-verify's scale assessment deciding lightweight **Immediately execute:** Use the Skill tool to load the `comet-verify` skill. Skipping this step is prohibited. -Small-scale hotfixes without delta spec usually meet lightweight verification conditions (≤ 3 tasks, changed files below the scale threshold), comet-verify's scale assessment will select the lightweight verification path (6 quick checks; default `review_mode: off` does not dispatch automatic code review). If the user wants to increase review, they can run `node "$COMET_STATE" set review_mode standard` or `thorough` before verification. If hotfix created delta spec, enter full verification path according to comet-verify's scale assessment rules. +Small-scale hotfixes without delta spec usually meet lightweight verification conditions (≤ 3 tasks, changed files below the scale threshold), comet-verify's scale assessment will select the lightweight verification path (6 quick checks; default `review_mode: off` does not dispatch automatic code review). If the user wants to increase review, they can run `comet state set review_mode standard` or `thorough` before verification. If hotfix created delta spec, enter full verification path according to comet-verify's scale assessment rules. After verification passes, record `.comet.yaml` `verify_result` as `pass` according to `/comet-verify` rules, must not skip this status before archiving. After verification passes, still enter `/comet-archive`'s final archive confirmation; do not automatically run the archive script. @@ -136,14 +138,13 @@ If there is delta spec, sync to main spec according to comet-archive rules, and Hotfix workflow is **one-time continuous execution**. After invoking `/comet-hotfix`, agent must automatically advance through hotfix steps, without pausing to wait for user input mid-way. -Exception: when `.comet.yaml` has `auto_transition: false`, after each phase guard advances `phase`, do not auto-invoke the next skill. In this case, use `node "$COMET_STATE" next ` output and pause for manual continuation as instructed. +Exception: when `.comet.yaml` has `auto_transition: false`, end the current invocation at each phase boundary and return control with `HINT`; the user may run the next phase later. This is a manual handoff, not a new confirmation point. -The following situations must also pause and wait for user confirmation: +The following genuine user decisions still pause: 1. Encountering an upgrade-assessment signal (see "Upgrade Assessment" section). **Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly choose**: continue the hotfix flow, or upgrade to the full `/comet` workflow -2. workspace isolation and execution-method selection when tasks exceed 3 and transfer to `/comet-build` -3. verify phase (comet-verify) verification-failure and branch-handling decisions -4. Final archive confirmation (before comet-archive runs the archive script) +2. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically +3. Final archive confirmation and the branch-handling decision after the archive commit Execution order: quick open → direct build → root cause check → verification → archive → complete @@ -154,7 +155,7 @@ After each step completes, immediately enter next step. Within each phase, must ## Upgrade Assessment -Hotfix upgrade assessment only decides whether to move from the preset workflow to full; file count never upgrades automatically, and `comet-state scale` only decides verification weight. +Hotfix upgrade assessment only decides whether to move from the preset workflow to full; file count never upgrades automatically, and `comet state scale` only decides verification weight. If `/comet` passes an intent frame from the entry, hotfix must recheck `risk_signal` and escalation signals only before build: new capability, public API, schema change, cross-module coordination, or deep architecture work. When any signal matches, enter the existing escalation decision point; do not reimplement entry intent recognition. @@ -167,10 +168,10 @@ When a qualitative-change signal or file-count tripwire is hit, **must pause und After the user chooses upgrade (option B), use the legal state-machine upgrade channel, a single command that converts the preset workflow to full and rolls back to design: ```bash -node "$COMET_STATE" transition preset-escalate +comet state transition preset-escalate ``` -This command atomically sets `workflow`/`classic_profile` to `full`, rolls `phase` back to `design`, and clears `design_doc` (satisfying comet-design entry requirements). Then add the Design Doc on the current change: **immediately use the Skill tool to load the `comet-design` skill**, then proceed through the normal full workflow. +This command atomically sets `workflow`/`classic_profile` to `full`, rolls `phase` back to `design`, clears `design_doc`, and clears preset-only `build_mode`, `tdd_mode`, `review_mode`, `isolation`, and `verify_mode`. Then add the Design Doc on the current change: **immediately use the Skill tool to load the `comet-design` skill**. On entering build, run the full joint workflow-configuration decision again. When the user chooses continue (option A), continue the hotfix workflow and record the user's reason for continuing. @@ -181,16 +182,16 @@ When the user chooses continue (option A), continue the hotfix workflow and reco - Bug fixed, tests pass - Change archived - If spec changes, synced to main spec -- **Phase guard**: Before build → verify run `node "$COMET_GUARD" build --apply`; before verify → archive follow `/comet-verify` and run `node "$COMET_GUARD" verify --apply` +- **Phase guard**: Before build → verify run `comet guard build --apply`; before verify → archive follow `/comet-verify` and run `comet guard verify --apply` ## Automatic Handoff to Next Phase Follow `comet/reference/auto-transition.md`. Key command: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to continue hotfix workflow (`phase: build` returns `comet-hotfix`, `verify` returns `comet-verify`, `archive` returns `comet-archive`) -- `NEXT: manual` → do not invoke the next skill; prompt user to manually run `/` per `HINT` +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed diff --git a/assets/skills/comet-open/SKILL.md b/assets/skills/comet-open/SKILL.md index 84a1f703..a621dd56 100644 --- a/assets/skills/comet-open/SKILL.md +++ b/assets/skills/comet-open/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-open -description: "Use when Comet needs to create a new OpenSpec change, or an active change is missing proposal/design/tasks/.comet.yaml initialization artifacts." +description: "Use only when explicitly invoked as /comet-open or routed by the root Comet skill/runtime to the open phase; create or recover an OpenSpec change and its proposal/design/tasks/.comet.yaml artifacts." --- # Comet Phase 1: Open @@ -13,11 +13,15 @@ description: "Use when Comet needs to create a new OpenSpec change, or an active ### 0. Output Language Constraint -Every prompt and artifact request passed to OpenSpec must include the resolved Comet artifact language, using normalized ids such as `en` or `zh-CN`. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after the change is initialized, use `"$COMET_BASH" "$COMET_STATE" get language`. If no configured language exists, fall back to the current user request language. The generated `proposal.md`, `design.md`, and `tasks.md` must use that language as their main language. +Every prompt and artifact request passed to OpenSpec must include the resolved Comet artifact language, using normalized ids such as `en` or `zh-CN`. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after the change is initialized, use `comet state get language`. If no configured language exists, fall back to the current user request language. The generated `proposal.md`, `design.md`, and `tasks.md` must use that language as their main language. ### 0a. Current Change Binding -When resuming an existing change, the first state operation must be: +When resuming an existing change, inspect `openspec/changes//.comet.yaml` first: + +- If it exists and parses, select the change as the first state operation +- If it is missing but the change directory is valid, run `comet state init full`, then select the change +- If it is malformed, stop and report the parse error; repair it manually from version control, a backup, or verifiable artifacts before continuing, and never overwrite a damaged file with `state set` ```bash comet state select @@ -25,6 +29,16 @@ comet state select When creating a new change, initialize `.comet.yaml` first, then immediately run the same command; never fabricate a selection before state exists. +### 0b. OpenSpec Compatibility Check + +Before any OpenSpec status or instructions command, run: + +```bash +openspec --version +``` + +This flow requires **OpenSpec >= 1.5.0**. Stop immediately if the version is older than 1.5.0, cannot be parsed, the command is unavailable, or it exits non-zero. Ask the user to run `npm install -g @fission-ai/openspec@latest` and retry. Never continue with an older CLI that lacks the `applyRequires`, `artifactPaths`, `changeRoot`, or `resolvedOutputPath` contracts. + ### 1. Explore Ideas and Clarify Requirements **Immediately execute:** Use the Skill tool to load the `openspec-explore` skill. Skipping this step is prohibited. @@ -67,36 +81,44 @@ Every accepted split item must be created as an independent change through `/com Must not create proposal.md, design.md, or tasks.md before the user completes the PRD split choice. If the user chooses to create multiple changes, the current `/comet-open` invocation only completes split confirmation and coordination, then enters `/comet-open` for each split item in the user-confirmed order. +Immediately after the user confirms multiple changes, persist the accepted split to `.comet/batches/.json`. Use a stable kebab-case `batch-id`. The file must record at least `version`, the original goal summary, creation time, the ordered change names, and each item's goals, scope, non-goals, acceptance scenarios, and `pending|open-complete|selected` status. Atomically update it after each item is created or completed. This is a batch orchestration manifest, not a replacement for each change's `.comet.yaml`. + In batch split mode, entering `/comet-open` for each split item must explicitly mark it as a "confirmed split item" and carry that split item's goals, scope, non-goals, and acceptance scenarios. Confirmed split items skip the PRD split preflight by default, unless the split item itself still clearly contains multiple independent capabilities. -In batch split mode, a single split item must not auto-advance to `/comet-design` after completing the open phase. After splitting is complete, must pause and ask the user which change to start; after the user chooses, advance only that change into `/comet-design`, while other changes remain active and can be resumed later through `/comet`. +In batch split mode, a single split item must not auto-advance to `/comet-design` after completing the open phase. -Minimal resume rule: do not add a dedicated batch state file. On resume, first check already-created active changes; split items that already exist and contain `.comet.yaml` must not be created again, while uncreated split items continue through `/comet-open` according to the user-confirmed split list. If the confirmed split list cannot be recovered from the conversation, must ask the user to confirm the split list again before continuing. +**Batch completion hard check (must not be skipped)**: after every split item completes its own open phase, run the following for each `` in the user-confirmed list: -### 1b. Requirements Clarification Completion Confirmation (Blocking Point) +```bash +openspec status --change "" --json +comet state check design +``` -Before creating OpenSpec artifacts, must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to confirm requirements clarification is complete. +The OpenSpec JSON must satisfy all of these conditions: +- Resolved `changeRoot` must equal repository-local `openspec/changes/`; stop if it does not, because Classic runtime does not support an external change root +- The schema must include core artifact ids `proposal`, `design`, and `tasks`; extra artifacts are allowed, but a missing core id is an incompatible schema +- Every artifact listed in `applyRequires` must be `done` in `artifacts` +- Concrete outputs in `artifactPaths..existingOutputPaths` (or `resolvedOutputPath` from instructions) must exist and be non-empty +- Treat `isComplete` as diagnostic only; it neither replaces the `applyRequires` implementation-readiness check nor lets optional artifacts block phase advancement -When pausing, present the clarification summary: goals, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios. +If any split item fails these checks, must not report splitting complete or ask which change to start. Stop and resume `/comet-open` from that change's first `ready` or `blocked` artifact. If OpenSpec passes but Comet state fails, repair `.comet.yaml` initialization or phase, then rerun the checks for the entire batch. -Must not create proposal.md, design.md, or tasks.md before the user confirms requirements clarification is complete, and must not use the Skill tool to load the `openspec-propose` skill to generate all artifacts in one pass. +Only after every split item passes both CLI checks may you pause and ask which change to start. Mark the chosen item `selected` in the batch manifest, then advance only that change into `/comet-design`; other changes remain active and can be resumed later through `/comet`. -### 1c. Change Name Confirmation (Blocking Point) +On resume, read `.comet/batches/.json` first, then run the CLI checks above for already-created active changes. Do not recreate items that fully pass; resume incomplete items from the first `ready` artifact returned by OpenSpec. Create missing items from the persisted manifest. If the manifest is missing or damaged, stop and ask the user to rebuild/confirm it instead of inferring the original batch boundary from directory names. -Before creating the change directory (`openspec new change`), must follow the `comet/reference/decision-point.md` protocol to pause and let the user decide the change name. Must not auto-generate or silently infer the change name. +### 1b. Resolve Requirements and Change Name (Non-blocking by Default) -OpenSpec change names must be **kebab-case English** (lowercase letters, digits, hyphens; e.g. `refine-requirements-doc`). Chinese or other non-conforming names are invalid. +Before creating OpenSpec artifacts, turn Step 1 clarification into a resolved brief containing the goal, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios. Derive one kebab-case English change name that accurately represents that scope. -When pausing, present: -- **2-3 recommended kebab-case English names** derived from the confirmed clarification summary, each with a one-line description of the scope it implies -- An explicit option for the user to **enter their own name** -- A note that **if the user enters Chinese (or any non-kebab-case text), it will be converted into a compliant kebab-case English name**, and the converted result must be shown back to the user for confirmation before use +- **Continue directly when scope and naming are both unambiguous**. Do not pause merely to approve a summary or name; final review confirms the change name, scope, and artifacts together +- If the user supplied a name, normalize it to kebab-case and echo it in the progress update. Do not re-confirm when normalization preserves meaning +- Reuse a confirmed batch item's persisted summary and name. Re-clarify only when scope drift or missing manifest data is detected +- Use `comet/reference/decision-point.md` for one joint question only when mutually exclusive choices still change scope or the target change identity. Naming preference alone is not a blocking point -The decision options must include: -- Pick one of the recommended names -- "Enter a custom name" — accept the user's input; if it is already valid kebab-case English, use it directly; if it is Chinese or otherwise non-conforming, convert it to compliant kebab-case English and show the converted name for confirmation before continuing +OpenSpec names must be kebab-case English using lowercase letters, digits, and single hyphens. When a collision exists but the target remains clear, derive a stable non-conflicting name and continue. Ask only when Comet cannot determine whether to reuse the existing change or create a new one. -Must not run `openspec new change` or create `.comet.yaml` before the user confirms the final change name. If the chosen/converted name collides with an existing change, report the collision and ask the user to choose another name. +Do not run `openspec new change` or create proposal/design/tasks while the resolved brief or name remains ambiguous. Continue clarification or resolve the genuine user decision before Step 2. ### 2. Create Change Structure + Initialize State @@ -104,35 +126,50 @@ Must not run `openspec new change` or create `.comet.yaml` before the user confi Full `/comet` workflow must not use the Skill tool to load the `openspec-propose` skill by default; only load it when the user explicitly requests generating the proposal and artifacts in one pass. -After the skill loads, follow its guidance to create the change skeleton, but override its "STOP and wait for user direction" behavior when a confirmed clarification summary from Step 1b is already available in the conversation context. +After the skill loads, follow its guidance to create the change skeleton. When Step 1b has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior to avoid a duplicate question. + +Use the Step 1b resolved brief directly to populate artifact content. Fall back to the skill's question flow only when ambiguity remains that would change scope. + +Immediately after creating the change skeleton, initialize recoverable state instead of waiting until every artifact is generated: -If the user has already confirmed a clarification summary (Step 1b), use that summary directly to populate artifact content. If no clarification summary exists (edge case), fall back to the skill's default behavior of asking the user. +```bash +comet state init full +comet state select +comet state check open +``` + +Stop if any command fails. Then run `openspec status --change "" --json` once and perform compatibility preflight: -After the change skeleton is created, generate `proposal`, `design`, and `tasks` one by one using the standard artifact loop: +- Resolved `changeRoot` must equal repository-local `openspec/changes/`, and `planningHome` (when present) must remain inside the current repository +- `artifacts` must contain core ids `proposal`, `design`, and `tasks`; extra artifacts are allowed +- `applyRequires` must be a parseable list of artifact ids and every id must exist in `artifacts` +- Stop on missing fields, escaping paths, or missing core ids; never fall back to a guessed fixed template -**Standard Artifact Loop** (for each `artifact-id`: `proposal` → `design` → `tasks`): +After preflight, generate the implementation-required artifacts from the OpenSpec schema and dependency graph: -1. Refresh status: `openspec status --change "" --json` -2. Fetch artifact instructions: +**OpenSpec status-driven artifact loop**: + +1. Run `openspec status --change "" --json` and parse the complete JSON. +2. Exit when every item in `applyRequires` is `done`; record `isComplete` as diagnostic only and do not use it as a phase blocker. +3. From unfinished `ready` artifacts, prioritize items that advance the `applyRequires` dependency closure and process them in CLI-returned order. Must not hard-code generation order or assume the schema contains only proposal/design/tasks. +4. Fetch current instructions for each ready ``: ```bash - openspec instructions proposal --change "" --json - openspec instructions design --change "" --json - openspec instructions tasks --change "" --json + openspec instructions --change "" --json ``` -3. For the returned JSON instruction payload, you must: +5. For the returned JSON instruction payload, you must: - Read every completed dependency artifact listed in `dependencies` - Use `template` as the artifact structure - Follow `instruction` guidance - - Apply `context` and `rules` as constraints — **must not copy them into the artifact content** - - Write to `resolvedOutputPath` - - Verify the output file exists and is non-empty -4. After creating each artifact, re-run `openspec status --change "" --json` to confirm status before continuing to the next artifact + - Apply `context` and `rules` as constraints — **must not copy them into artifact content** + - Write to `resolvedOutputPath`; for wildcard outputs, create each concrete file required by the instruction + - Verify the concrete output files returned by the CLI exist and are non-empty +6. Re-run status after creating each artifact and revalidate `changeRoot`, core ids, and `applyRequires`. Do not regenerate items that become `done`; process newly `ready` items in the next loop. -**Failure handling**: If `openspec instructions` fails, returns invalid JSON, reports unmet `dependencies`, or does not provide a usable `resolvedOutputPath`, must immediately stop artifact creation and report the OpenSpec error. Must not fall back to hard-coded artifact prose because that would silently bypass project rules. +**Blocking and failure handling**: if `applyRequires` is incomplete and no ready artifact can advance its dependency closure, report `missingDeps` for the relevant `blocked` artifacts and stop. Do not guess order or skip dependencies. Also stop if status/instructions fails, returns invalid JSON, escapes the repository, or provides no usable `resolvedOutputPath`. Must not fall back to hard-coded artifact prose. -**Naming and scope guard**: Change name must be the kebab-case English name confirmed by the user in Step 1c — must not auto-generate, infer, or use a non-kebab-case (e.g. Chinese) name. Change scope must match the user's description — must not expand or narrow it independently. +**Naming and scope guard**: Use the kebab-case English name resolved in Step 1b; never use a non-kebab-case name. Change scope must match the resolved brief and user request; do not expand or narrow it independently. Confirm the following artifacts have been created: @@ -145,43 +182,45 @@ openspec/changes// └── tasks.md # Task checklist (checkboxes) ``` -Create `.comet.yaml` state file: - -First locate scripts via `comet/reference/scripts.md`, then initialize state: - -```bash -node "$COMET_STATE" init full -``` - ### 3. Entry State Verification Verify state machine has been correctly initialized: ```bash -node "$COMET_STATE" check open +comet state check open ``` Proceed to Step 4 after verification passes. The script outputs specific failure reasons when verification fails. -**Idempotency**: All open phase operations can be safely re-executed. If `.comet.yaml` is already at `phase: open` and all three artifact files exist, skip completed steps and continue from the first missing step. +**Idempotent recovery algorithm**: all open phase operations can be safely re-executed. On recovery, process the status in this order: + +1. If state is missing, run `comet state init full`; if malformed, stop and repair it instead of overwriting it. Then select the change and run `comet state check open`. +2. Run status and revalidate `changeRoot`, core ids, `applyRequires`, `artifacts`, and `missingDeps`. +3. `done`: keep the artifact unchanged and do not regenerate it. +4. `ready`: fetch its instructions, write the returned output, and immediately rerun status. +5. `blocked`: follow `missingDeps` and first complete dependencies in the `applyRequires` closure; never generate a blocked artifact directly. +6. Repeat until every item in `applyRequires` is `done`. + +If the required dependency graph cannot advance, list the relevant blocked artifacts and `missingDeps`, then stop. Directory or fixed-file presence cannot replace the CLI decision; conversely, an optional artifact outside `applyRequires` must not block implementation solely because `isComplete` is false. ### 4. Content Completeness Check -Confirm the three documents have complete content: -- **proposal.md**: problem background, goals, scope, non-goals -- **design.md**: high-level architecture decisions, approach selection, data flow -- **tasks.md**: task list, each task has a clear description +Run status again. Confirm core ids exist, every item in `applyRequires` is `done`, and concrete files in `artifactPaths..existingOutputPaths` for required artifacts exist and are non-empty. If any condition fails, do not enter Step 5 or execute the phase guard. -**File existence verification**: Confirm all three file paths exist and are non-empty. If any file is missing or empty, must not enter Step 5 or execute phase guard — return to creation step to fill the gap. +Then check key artifact content: proposal covers problem, goals, scope, and non-goals; design covers high-level decisions and data flow; tasks contains clear work items. If the schema returns specs or other artifacts, check their content against their instructions as well; the fixed three documents must not hide an incomplete schema artifact. ### 5. User Review and Confirmation (Blocking Point) -After the three documents are created and content completeness check passes, **must follow the `comet/reference/decision-point.md` protocol to pause and wait for user confirmation**. Must not execute phase guard or auto-transition before user confirmation. +After all OpenSpec artifacts are complete and the content check passes, **must follow the `comet/reference/decision-point.md` protocol to pause and wait for user confirmation**. Must not execute the phase guard or auto-transition before user confirmation. + +The final review confirms the change name, scope, and artifact content together. Do not skip it because Step 1b resolved the brief, and do not add another routine summary/name confirmation before it. The user confirmation question must be presented as a single-select question with the following summary and options: **Summary content**: +- **Change name and resolved brief**: final name, goal, non-goals, scope boundaries, and key unknowns - **proposal.md**: problem background, goals, scope +- **specs and other schema artifacts**: capabilities, requirements, and key acceptance scenarios - **design.md**: high-level architecture decisions, approach selection - **tasks.md**: task count and key task descriptions @@ -193,14 +232,14 @@ After user selects "Confirm", proceed to exit conditions. When user selects "Nee ## Exit Conditions -- proposal.md, design.md, tasks.md all created with complete content -- **User has confirmed** proposal, design, tasks content meets expectations -- **Phase guard**: Run `node "$COMET_GUARD" open --apply`; after all PASS, auto-transitions to next phase +- OpenSpec compatibility preflight passes, every `applyRequires` item is `done`, and required outputs are non-empty +- **User has confirmed** all OpenSpec artifact content meets expectations +- **Phase guard**: Run `comet guard open --apply`; after all PASS, auto-transitions to next phase Must use `--apply` before exit, otherwise `.comet.yaml` remains at `phase: open` and the next phase entry check will fail. ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` Full workflow auto-transitions to `phase: design`; hotfix/tweak presets auto-transition to `phase: build`. @@ -210,11 +249,11 @@ Full workflow auto-transitions to `phase: design`; hotfix/tweak presets auto-tra Follow `comet/reference/auto-transition.md`. Key command: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to enter the next phase -- `NEXT: manual` → do not invoke the next skill; prompt user to run `/` manually +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed hotfix/tweak presets are controlled by their corresponding preset skill (phase goes directly to build); their `next` returns the corresponding preset skill. diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 9e905acd..1bcb1d3f 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -1,6 +1,6 @@ --- name: comet-tweak -description: "Use when the user wants a lightweight or medium change that fits a single OpenSpec change and does not need full design; also use when resuming tweak workflow." +description: "Use only when explicitly invoked as /comet-tweak or routed by the root Comet skill/runtime to the tweak preset; handle a lightweight or medium change that fits one OpenSpec change." --- # Comet Preset Path: Tweak @@ -23,7 +23,7 @@ Applicable for OpenSpec-chained lightweight changes, such as configuration adjus ### 0. Output Language Constraint -Streamlined OpenSpec artifacts must use the configured Comet artifact language. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after initialization, use `"$COMET_BASH" "$COMET_STATE" get language`. +Streamlined OpenSpec artifacts must use the configured Comet artifact language. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; after initialization, use `comet state get language`. Execution chain: open → OpenSpec apply → verify → archive. Tweak provides default decisions for each phase: streamlined open, direct build through OpenSpec apply, scale- and delta-spec-driven verification weight, and final archive confirmation after verification passes. @@ -46,19 +46,22 @@ After the skill loads, follow its guidance to create streamlined artifacts: Initialize Comet state file: ```bash -node "$COMET_STATE" init tweak +comet state init tweak +comet state select ``` +Tweak defaults to `isolation: current`, truthfully indicating execution in the current workspace. Change it to `branch` or `worktree` only after that workspace is actually created/selected. + Verify initialized state: ```bash -node "$COMET_STATE" check open +comet state check open ``` Run phase guard to transition open → build: ```bash -node "$COMET_GUARD" open --apply +comet guard open --apply ``` ### 2. OpenSpec Apply Build (tweak-only preset build) @@ -95,7 +98,7 @@ For specific investigation, minimal failing test, fix verification, and keeping 7. Run phase guard to transition build → verify: ```bash -node "$COMET_GUARD" build --apply +comet guard build --apply ``` State automatically updates to `phase: verify`, `verify_result: pending`, then enter verification. @@ -109,10 +112,10 @@ Reuse `/comet-verify`; let comet-verify's scale assessment decide lightweight or **Delta-spec verification routing**: tweak accepts delta spec as a normal artifact. If this change created a delta spec, explicitly set full verification mode before entering comet-verify, to run OpenSpec-native verification (`openspec-verify-change`) covering delta-spec consistency: ```bash -node "$COMET_STATE" set verify_mode full +comet state set verify_mode full ``` -A tweak without delta spec usually meets lightweight verification conditions (≤ 3 tasks, changed files below the scale threshold); comet-verify's scale assessment selects the lightweight verification path (6 quick checks). If the user wants to add review, run `node "$COMET_STATE" set review_mode standard` or `thorough` before verification. +A tweak without delta spec usually meets lightweight verification conditions (≤ 3 tasks, changed files below the scale threshold); comet-verify's scale assessment selects the lightweight verification path (6 quick checks). If the user wants to add review, run `comet state set review_mode standard` or `thorough` before verification. After verification passes, record `.comet.yaml` `verify_result` as `pass` according to `/comet-verify` rules, must not skip this status before archiving. After verification passes, still enter `/comet-archive`'s final archive confirmation; do not automatically run the archive script. @@ -129,13 +132,13 @@ Reuse `/comet-archive`. Must satisfy `verify_result: pass` in `.comet.yaml` befo Tweak workflow is **one-time continuous execution**. After invoking `/comet-tweak`, agent must automatically advance through tweak steps, without pausing to wait for user input mid-way. -Exception: when `.comet.yaml` has `auto_transition: false`, after each phase guard advances `phase`, do not auto-invoke the next skill. In this case, use `node "$COMET_STATE" next ` output and pause for manual continuation as instructed. +Exception: when `.comet.yaml` has `auto_transition: false`, end the current invocation at each phase boundary and return control with `HINT`; the user may run the next phase later. This is a manual handoff, not a new confirmation point. -The following situations must pause and wait for user confirmation: +The following genuine user decisions still pause: 1. Encountering an upgrade-assessment signal (see "Upgrade Assessment" section). **Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly choose**: continue the tweak lightweight flow, or upgrade to the full `/comet` workflow -2. verify phase (comet-verify) verification-failure and branch-handling decisions -3. Final archive confirmation (before comet-archive runs the archive script) +2. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically +3. Final archive confirmation and the branch-handling decision after the archive commit Execution order: quick open → build (with upgrade assessment) → verification → archive → complete @@ -146,7 +149,7 @@ After each phase completes, immediately enter next phase. Within each phase, mus ## Upgrade Assessment -Tweak upgrade assessment only decides whether to move from the lightweight preset to full; delta spec alone is not an upgrade reason, file count never upgrades automatically, and `comet-state scale` only decides verification weight. +Tweak upgrade assessment only decides whether to move from the lightweight preset to full; delta spec alone is not an upgrade reason, file count never upgrades automatically, and `comet state scale` only decides verification weight. If `/comet` passes an intent frame from the entry, tweak must recheck `risk_signal` and escalation signals only before build: new capability, public API, schema change, cross-module coordination, or deep architecture work. When any signal matches, enter the existing escalation decision point. Delta spec remains a normal tweak artifact and must not trigger escalation by itself; do not reimplement entry intent recognition. @@ -159,10 +162,10 @@ When a qualitative-change signal or file-count tripwire is hit, **must pause und After the user chooses upgrade (option B), use the legal state-machine upgrade channel, a single command that converts the preset workflow to full and rolls back to design: ```bash -node "$COMET_STATE" transition preset-escalate +comet state transition preset-escalate ``` -This command atomically sets `workflow`/`classic_profile` to `full`, rolls `phase` back to `design`, and clears `design_doc` (satisfying comet-design entry requirements). Then add the Design Doc on the current change: **immediately use the Skill tool to load the `comet-design` skill**, then proceed through the normal full workflow. +This command atomically sets `workflow`/`classic_profile` to `full`, rolls `phase` back to `design`, clears `design_doc`, and clears preset-only `build_mode`, `tdd_mode`, `review_mode`, `isolation`, and `verify_mode`. Then add the Design Doc on the current change: **immediately use the Skill tool to load the `comet-design` skill**. On entering build, run the full joint workflow-configuration decision again. When the user chooses continue (option A), continue the tweak workflow and record the user's reason for continuing. @@ -173,16 +176,16 @@ When the user chooses continue (option A), continue the tweak workflow and recor - Change completed, tests pass - Change archived - If spec changed, synced to main spec -- **Phase guard**: Before build → verify run `node "$COMET_GUARD" build --apply`; before verify → archive follow `/comet-verify` and run `node "$COMET_GUARD" verify --apply` +- **Phase guard**: Before build → verify run `comet guard build --apply`; before verify → archive follow `/comet-verify` and run `comet guard verify --apply` ## Automatic Handoff to Next Phase Follow `comet/reference/auto-transition.md`. Key command: ```bash -node "$COMET_STATE" next +comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to continue tweak workflow (`phase: build` returns `comet-tweak`, `verify` returns `comet-verify`, `archive` returns `comet-archive`) -- `NEXT: manual` → do not invoke the next skill; prompt user to manually run `/` per `HINT` +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index a80c9050..c97c5d53 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -1,9 +1,9 @@ --- name: comet-verify -description: "Use when a Comet change has completed build and needs implementation verification, verification-failure decisions, or branch closeout." +description: "Use only when explicitly invoked as /comet-verify or routed by the root Comet skill/runtime to the verify phase; verify a Comet change, record evidence, and manage repair loops." --- -# Comet Phase 4: Verify and Close (Verify) +# Comet Phase 4: Verify ## Prerequisites @@ -14,7 +14,7 @@ description: "Use when a Comet change has completed build and needs implementati ### 0a. Output Language Constraint -Verification reports and branch-handling notes must use the configured Comet artifact language from `comet state get language`. +Verification reports must use the configured Comet artifact language from `comet state get language`. ### 0b. Entry State Verification (Entry Check) @@ -27,7 +27,7 @@ comet state check verify Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. -**Idempotency**: All verify phase checks can be safely re-executed. If `verify_result` is already `pass` and `branch_status` is `handled`, verification is complete — execute guard to transition. If `verify_result` is `pending`, start verification from the beginning. +**Idempotency**: All verify checks are safe to repeat. If `verify_result` is already `pass`, verification is complete and archive should continue; keep `branch_status: pending` until archive changes are committed and final branch handling finishes. If `verify_result` is `pending`, start verification from the beginning. ### 1. Scale Assessment @@ -41,27 +41,26 @@ The script automatically counts tasks, delta spec count, changed file count, det Before verification begins, handle uncommitted changes through `comet/reference/dirty-worktree.md` protocol. Verify phase special handling: -1. If dirty diff belongs to current change and involves implementation, tests, tasks, delta spec, or design doc changes, do not fix or commit directly in verify phase; report failures and enter Step 1b verification failure decision blocking point -2. If dirty diff is only verify phase artifacts (e.g., verification report draft, branch handling records), may continue and record state in verify phase -3. If dirty diff shows implementation but tasks.md not checked, treat as build state lag; report failures and enter Step 1b, let user decide to roll back for fix or accept deviation +1. If dirty diff clearly belongs to the current change, it is verification input. Continue verification, but do not modify or commit implementation, tests, tasks, delta specs, or the Design Doc in verify +2. If dirty diff is only a verify phase artifact such as a verification report draft, may continue and record state in verify phase +3. If dirty diff shows implementation but tasks.md remains unchecked, treat it as lagging build state. This has one valid next action: run `verify-fail`, return to build, verify evidence, and update task state without asking whether to accept incomplete tasks +4. If dirty diff cannot be attributed or belongs to another change, report a stop condition through the dirty-worktree protocol. Do not disguise attribution failure as a continue/ignore choice -Only after user chooses fix, allow rollback to build phase: +When repair or state reconciliation must return to build, run: ```bash -# Execute only after user confirms fix comet state transition verify-fail ``` -Note: When verify-fail rolls back to build, `branch_status` is not reset. If branch handling was already completed during the first verify attempt, skip the branch handling step on re-verify and keep the existing `branch_status: handled`. - Note: If every task in build phase was committed, the script's file count based on working tree diff may underestimate change scale. In this case, must read plan file header `base-ref` and verify with commit range: ```bash -PLAN=$(comet state get plan) -BASE_REF=$(grep '^base-ref:' "$PLAN" 2>/dev/null | head -1 | sed 's/^base-ref: *//') -git diff --stat "$BASE_REF"...HEAD +comet state get plan +git diff --stat ...HEAD ``` +The first command returns the plan path. Use the host's file reader to parse the single `base-ref` frontmatter field, validate it as a commit, then substitute it into the second command. Do not depend on POSIX text pipelines. + If commit range shows changes exceed lightweight threshold (> 8 files, cross-module coordination, or delta spec spans more than 1 capability), manually set to full verification: ```bash @@ -70,33 +69,34 @@ comet state set verify_mode full **Override mechanism**: If the agent or user believes the automated assessment is inappropriate, override at any time with `comet state set verify_mode `. -### 1b. Verification Failure Decision (Blocking Point) +### 1b. Automatic Verification Repair and Exception Decisions -When verification does not pass, **must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to decide whether to fix or accept the deviation**. Must not automatically run `comet state transition verify-fail`, nor automatically invoke `/comet-build`. +Run `comet state get verify_failures` first to read the persisted consecutive failure count. Automatically return to build for the first 3 repairable failures: report the failures, run `comet state transition verify-fail`, then invoke `/comet-build` without asking for confirmation. -When pausing, must list: +The report must list: - Failed items - Whether CRITICAL or IMPORTANT (build failure, test failure, security issues, core acceptance scenario failure, lightweight code review correctness/security/edge-case issue) - Recommended handling approach -**Uncertainty principle**: When severity is unclear, downgrade (SUGGESTION > WARNING > CRITICAL). Only use CRITICAL for build failures, test failures, and security issues; ambiguous or uncertain issues should be WARNING or SUGGESTION. +**Uncertainty principle**: Use a lower severity when evidence is unclear. Reserve CRITICAL for build failures, test failures, and security issues; use IMPORTANT for confirmed core-acceptance or correctness failures; mark ambiguous findings WARNING or SUGGESTION. -After user selection, continue as follows: -- **Fix all**: Run `comet state transition verify-fail`, then invoke `/comet-build` to fix -- **Handle item by item**: CRITICAL or IMPORTANT failures must be fixed; WARNING/SUGGESTION failures may choose to accept deviation, but must record acceptance reason and impact scope in verification report. If any CRITICAL or IMPORTANT failure exists, skipping fix to accept all is not allowed +Handle failures as follows: +- **CRITICAL/IMPORTANT or objectively repairable in-scope issues**: automatically return to build below the retry limit. Do not manufacture a "whether to fix" decision, and never accept these as deviations +- **WARNING/SUGGESTION whose fix introduces a behavior, scope, or risk tradeoff**: use `comet/reference/decision-point.md` to ask whether to fix or accept. Record the reason and impact scope when accepted +- **WARNING/SUGGESTION with a safe, local, tradeoff-free fix**: repair automatically below the retry limit; low severity alone does not justify a pause -**Retry limit**: After 3 consecutive verify-fail cycles, on the 4th failure the agent must not automatically choose to continue fixing; **must use the current platform's available user input/confirmation mechanism to pause** with only two options: "Accept all deviations and record" or "Continue fixing", for the user to explicitly decide. +Only accepting WARNING/SUGGESTION deviations or choosing a strategy after the 4th failure is a user decision point. When `verify_failures >= 3`, do not automatically execute another `verify-fail`. Offer only "Continue fixing" or "Stop this workflow and seek an external decision" under the decision protocol. Record the next failure and return to build only after the user chooses continue. CRITICAL/IMPORTANT findings are never waivable. ### 2. Artifact Context Loading (Hash On-Demand Read) When verification needs to read OpenSpec artifacts, first check whether they have changed since the design phase: ```bash -RECORDED_HASH=$(comet state get handoff_hash) -CURRENT_HASH=$(comet handoff --hash-only 2>/dev/null || echo "") +comet state get handoff_hash +comet handoff --hash-only ``` -- If `RECORDED_HASH` = `CURRENT_HASH` and both are non-empty and neither is `null`: OpenSpec artifacts are unchanged. **tasks.md does not need to be re-read in full** (use `grep -c '\- \[ \]' tasks.md` to confirm completion count). proposal.md, design.md, and delta specs must still be read for comparison checks. +- Read the two standard outputs separately. If they match and both are non-empty and non-`null`, OpenSpec artifacts are unchanged. **tasks.md does not need to be re-read in full**; parse its checkboxes to confirm none remain unchecked. proposal.md, design.md, and delta specs must still be read for comparison checks. - If `RECORDED_HASH` is empty, is `null`, or differs from `CURRENT_HASH`: artifacts have changed or hash was never recorded. Read all required files in full normally. This optimization only skips re-reading tasks.md in full. proposal.md and design.md contain the full context needed for verification checks and must not be skipped due to hash match. @@ -116,7 +116,7 @@ Run these 6 checks: 5. No obvious security issues (no hardcoded keys, no new unsafe operations) 6. Code review strategy: when `review_mode: standard` or `thorough`, use the Skill tool to load the Superpowers `requesting-code-review` skill and request a lightweight review that checks only correctness, security, and edge cases; when `review_mode: off`, skip automatic code review and record the skip reason in the verification report -The lightweight code review input should be limited to this change's diff, tasks.md, and necessary test results; the review scope covers implementation correctness, security risk, and edge cases only, and does not perform spec coverage, Design Doc consistency, or drift checks. If the review finds CRITICAL or IMPORTANT issues, treat verification as failed and enter Step 1b. `review_mode: off` only skips automatic code review, not build, test, security checks, or debug gate protocol. +The lightweight code review input should be limited to this change's diff, tasks.md, and necessary test results; the review scope covers implementation correctness, security risk, and edge cases only, and does not perform spec coverage, Design Doc consistency, or drift checks. If the review finds CRITICAL or IMPORTANT issues, follow Step 1b automatic repair and retry handling. `review_mode: off` only skips automatic code review, not build, test, security checks, or debug gate protocol. If the project has no automatically inferred verification command, the user or Agent must run the real verification command first, then record its evidence separately: @@ -130,10 +130,9 @@ comet state record-check verify --command " verify-fail ``` @@ -160,10 +159,9 @@ After the skill loads, follow its guidance to verify. Check items: 6. No contradictions between delta spec and design doc (if Build phase had incremental spec modifications, check if design doc has corresponding records) 7. Associated design documents under `docs/superpowers/specs/` are locatable (file exists and is related to current change) -When verification does not pass: report missing items, enter Step 1b verification failure decision blocking point. Only after user confirms fix, execute the following command to record failure and roll back to build phase, then invoke `/comet-build` to supplement: +When verification does not pass, report missing items and classify them under Step 1b. Below the automatic retry limit, when the current change can supply the missing evidence, run the following command directly and invoke `/comet-build`: ```bash -# Execute only after user confirms fix comet state transition verify-fail ``` @@ -173,46 +171,24 @@ comet state transition verify-fail - Option B: After user selects B, run `comet state transition verify-fail`, then invoke `/comet-build`; `/comet-build`'s Spec Incremental Update rules will load the Superpowers `brainstorming` skill to update Design Doc + delta spec - Option C: Confirm deviation is acceptable, continue verification (design doc will be marked as `superseded-by-main-spec` during archiving) -### 3. Finishing (Superpowers) - -**Immediately execute:** Use the Skill tool to load the Superpowers `finishing-a-development-branch` skill. Skipping this step is prohibited. - -If the Superpowers `finishing-a-development-branch` skill is unavailable, stop the process and prompt to install or enable Superpowers skills. Do not substitute this step with normal conversation. - -After the skill loads, follow its guidance to finish. Branch handling options: -1. Merge to main branch locally -2. Push and create PR -3. Keep branch (handle later) -4. Discard work +### 3. Record Verification Evidence -This is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to choose branch handling method**. Must not select based on recommendations, defaults, or current branch status. Only after the user completes selection and the corresponding operation finishes, may `branch_status: handled` be written. - -**Confirmation items**: -- All tests pass -- No hardcoded keys or security issues - -### 4. Record Verification Evidence - -Verification report must be saved to disk and recorded in `.comet.yaml`; after branch handling completes, state fields must also be written. Do not manually set `verify_result: pass`; use guard for auto-transition. +Save the verification report and record it in `.comet.yaml`. Do not handle, merge, or discard branches in verify and do not write `branch_status: handled`: archive creates spec and metadata changes that belong in the final commit, so `/comet-archive` owns branch finishing after that commit. Do not set `verify_result: pass` manually; use the phase guard. ```bash -mkdir -p docs/superpowers/reports -# Write verification conclusions to report file, e.g.: -# docs/superpowers/reports/YYYY-MM-DD--verify.md - comet state set verification_report docs/superpowers/reports/YYYY-MM-DD--verify.md -comet state set branch_status handled ``` +Use the host's file API to create `docs/superpowers/reports/` and the report file; do not depend on a POSIX-only directory command. + ## Exit Conditions - Verification report passed -- Branch handled - `verification_report` in `.comet.yaml` points to an existing verification report file -- `branch_status: handled` in `.comet.yaml` +- `branch_status` remains `pending` - **Phase guard**: Run `comet guard verify --apply`; after all PASS, auto-transitions to `phase: archive` through `comet state transition verify-pass` -After both verification and branch handling are complete, run guard for auto-transition: +After verification evidence is complete, run guard for auto-transition: ```bash comet guard verify --apply @@ -233,7 +209,7 @@ comet state next ``` - `NEXT: auto` → invoke the skill pointed to by `SKILL` to enter the next phase -- `NEXT: manual` → do not invoke the next skill; prompt user to run `/` manually +- `NEXT: manual` → do not invoke the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → workflow is complete, no further action needed Note: after `comet-archive` starts, it must first execute the final archive confirmation blocking point and wait for the user to explicitly choose "Confirm archive" before running the archive script. Must not automatically archive just because verification passed. diff --git a/assets/skills/comet/SKILL.md b/assets/skills/comet/SKILL.md index 5e12120c..6e5433c4 100644 --- a/assets/skills/comet/SKILL.md +++ b/assets/skills/comet/SKILL.md @@ -1,6 +1,6 @@ --- name: comet -description: "Use when the user wants to start or resume a Comet workflow and route by active change, .comet.yaml, and hotfix/tweak intent." +description: "Use when the user explicitly invokes /comet, asks to start or resume a Comet-managed workflow, or repository evidence identifies one unambiguous active Comet change; route through the intent runtime and .comet.yaml." --- # Comet — OpenSpec + Superpowers Dual-Star Development Workflow @@ -22,7 +22,7 @@ Agents need only read this section for decision-making. Refer to the Reference A ### Output Language Rule -Use the configured Comet artifact language as the output language for every OpenSpec and Superpowers artifact. The configured value is a normalized language id, `en` or `zh-CN`. For an existing change, read `language` from `openspec/changes//.comet.yaml` using `"$COMET_BASH" "$COMET_STATE" get language`. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; if neither exists, fall back to the current user request language. Include the resolved language explicitly in every prompt or ARGUMENTS passed to external OpenSpec/Superpowers skills. +Use the configured Comet artifact language as the output language for every OpenSpec and Superpowers artifact. The configured value is a normalized language id, `en` or `zh-CN`. For an existing change, read `language` from `openspec/changes//.comet.yaml` using `comet state get language`. Before `.comet.yaml` exists, read `language` from project `.comet/config.yaml`, then fall back to global `~/.comet/config.yaml`; if neither exists, fall back to the current user request language. Include the resolved language explicitly in every prompt or ARGUMENTS passed to external OpenSpec/Superpowers skills. ### Automatic Phase Detection @@ -126,22 +126,22 @@ Prefer reading `openspec/changes//.comet.yaml`. If not available, fall bac - On every context resume, rerun Step 0 and Step 1; do not trust conversation history for phase detection - If there is an active change and the worktree has uncommitted changes, handle them through `comet/reference/dirty-worktree.md`. That protocol defines checks, attribution, and prohibitions; this file does not repeat them - If `phase: build`, first check `build_pause`, `plan`, `isolation`, `build_mode`, `tdd_mode`, and `review_mode` (see details below): - - If `build_pause: plan-ready` but `isolation`, `build_mode`, `tdd_mode`, and `review_mode` are all already set, treat as stale pause: first output `[COMET] Detected stale pause (build_pause=plan-ready but isolation/build_mode/tdd_mode/review_mode are set), auto-clearing and continuing`, then run `node "$COMET_STATE" set build_pause null`, then read the next unchecked task from tasks.md and resume execution per `build_mode` + - If `build_pause: plan-ready` but `isolation`, `build_mode`, `tdd_mode`, and `review_mode` are all already set, treat as stale pause: first output `[COMET] Detected stale pause (build_pause=plan-ready but isolation/build_mode/tdd_mode/review_mode are set), auto-clearing and continuing`, then run `comet state set build_pause null`, then read the next unchecked task from tasks.md and resume execution per `build_mode` - If `build_pause: plan-ready` and the plan file exists, but `isolation`, `build_mode`, `tdd_mode`, or `review_mode` is not yet set, return to the `/comet-build` plan-ready resume point, prompt the user to complete/confirm workspace isolation, execution method, TDD mode, and code review mode, and do not regenerate the plan - If `build_pause: plan-ready` but the plan file is missing, return to `/comet-build` to handle corrupted state or regenerate the plan - If `isolation`, `build_mode`, `tdd_mode`, or `review_mode` is unset, return to the corresponding `/comet-build` step to supplement before executing - If all are set, read the next unchecked task from tasks.md and continue: - If `build_mode: subagent-driven-development`, do not execute tasks directly in the main window; return to `/comet-build`'s background subagent dispatch rules, main window only coordinates - Other execution modes follow `/comet-build`'s corresponding rules -- If `phase: verify` and `verify_result: fail`, enter the verification failure decision blocking point: pause and ask the user to fix or accept deviation; only after the user chooses fix, run `node "$COMET_STATE" transition verify-fail` and invoke `/comet-build` -- If `phase: open` but proposal/design/tasks are complete, first run `node "$COMET_GUARD" open --apply` to repair state, then continue detection -- If `phase: archive`, only invoke `/comet-archive`; `/comet-archive` must first wait for final archive confirmation. After archive succeeds, the change moves to the archive directory, so do not run guard against the old active directory +- If `verify_result: fail`, read `verify_failures`. At 3 or fewer failures, invoke `/comet-build` directly to continue the recorded repair loop without re-asking. Above the automatic limit, return to `/comet-verify` for the exception decision. User input is required only to accept a WARNING/SUGGESTION deviation or choose a strategy after the retry limit +- If `phase: open` but OpenSpec `applyRequires` is complete, run `comet guard open --apply` to repair state, then continue detection +- If `phase: archive`, only invoke `/comet-archive`; confirm first, archive, commit exact archive paths, then handle the branch and run the archive guard **Step 2: Phase Determination** (check in order, first match wins) 1. `archived: true` or change moved to archive → Workflow complete 2. `verify_result: pass` and `archived` is not `true` → Invoke `/comet-archive` (first perform final archive confirmation) -3. `verify_result: fail` → Enter verification failure decision blocking point (pause and ask fix or accept deviation; only after user chooses fix, run `verify-fail` then `/comet-build`) +3. `verify_result: fail` → Invoke `/comet-build` automatically to continue repair. If `verify_failures` exceeds the automatic limit, enter `/comet-verify`'s retry-limit strategy decision 4. `phase: verify` or tasks.md all checked → Invoke `/comet-verify` 5. `phase: build` or has Design Doc but plan/execution incomplete → Route by workflow: `hotfix` → `/comet-hotfix`, `tweak` → `/comet-tweak`, `full` → `/comet-build` 6. `phase: design` or has change but no Design Doc → Invoke `/comet-design` @@ -156,11 +156,11 @@ hotfix/tweak scope assessment uses a three-layer division of labor, avoiding "us 1. **Qualitative-change signals** (agent semantic recognition; hitting any one pauses and delegates a two-choice decision to the user): cross-module coordinated change, new capability needed, database schema change, introduces new public API, hits deep architecture issues (each preset reuses this core signal set and may add its own context-specific signal, such as tweak's "needing to split into multiple OpenSpec changes") 2. **File-count tripwire** (user decides; not an automatic upgrade): when changed files exceed a hint threshold, pause and let the user decide whether to continue the preset or upgrade to full; do not auto-kick -3. **Verification weight** (scale script decides): `comet-state scale` only decides `verify_mode` (verification weight); it does not block the flow or trigger an upgrade +3. **Verification weight** (scale script decides): `comet state scale` only decides `verify_mode` (verification weight); it does not block the flow or trigger an upgrade **Upgrade decision point (user chooses one of two)**: - Continue the preset lightweight flow (user confirms scope is manageable) -- Upgrade to full `/comet` (use `node "$COMET_STATE" transition preset-escalate` to legally rewind to the design phase, supplementing Design Doc and Superpowers plan) +- Upgrade to full `/comet` (use `comet state transition preset-escalate` to legally rewind to design and clear preset-only build settings; after the Design Doc, choose the full workflow configuration again in one joint decision) See the "Upgrade Assessment" section of each `comet-hotfix` / `comet-tweak` for detailed rules. @@ -170,7 +170,8 @@ See the "Upgrade Assessment" section of each `comet-hotfix` / `comet-tweak` for |----------|----------| | `openspec list --json` fails | Check if openspec is installed, prompt user to run `openspec init` | | Sub-skill unavailable | Stop workflow, prompt to install or enable the corresponding skill | -| `.comet.yaml` malformed or missing | Use file state as source of truth, correct with `node "$COMET_STATE" set` then continue | +| `.comet.yaml` missing | Enter the relevant preset's `/comet-open` initialization, then run `comet state select`; never skip initialization | +| `.comet.yaml` malformed | Stop and report the parse error; repair from version control, backup, or verifiable artifacts, never overwrite it with `comet state set` | | Build/test fails | Return to build phase for fixes, do not enter verify | | Incomplete change directory structure | Fill missing files according to `comet-open` artifact requirements | @@ -183,20 +184,21 @@ Flow chain: open → design → build → verify → archive **Continuous execution requirement**: starting from the detected phase, the agent automatically continues through all later phases. But **auto-advancing only applies at transition points without user decisions**. When encountering user decision points, **must use the current platform's available user input/confirmation mechanism to pause and wait for the user's explicit response**. Must not use recommendation rules, defaults, or historical preferences to substitute for user confirmation, and must not just output a text prompt and then continue executing. -**Distinguish phase advancement vs automatic handoff**: each sub-skill runs phase guard `--apply` before exit to advance the `.comet.yaml` `phase` field. This step **always happens** and is not controlled by `auto_transition`. After that, the sub-skill runs `node "$COMET_STATE" next ` to resolve the next action: when `auto_transition` is not `false`, output is `NEXT: auto` (auto-invoke next skill); when `auto_transition` is `false`, output is `NEXT: manual` (do not invoke next skill, show a manual run hint). Therefore `auto_transition` **only controls next skill invocation, not phase advancement**. Regardless of `auto_transition`, user decision points below remain blocking. +**Distinguish phase advancement vs automatic handoff**: each sub-skill runs phase guard `--apply` before exit to advance the `.comet.yaml` `phase` field. This step **always happens** and is not controlled by `auto_transition`. After that, the sub-skill runs `comet state next ` to resolve the next action: when `auto_transition` is not `false`, output is `NEXT: auto` (auto-invoke next skill); when `auto_transition` is `false`, output is `NEXT: manual` (do not invoke next skill; return control with `HINT`). `NEXT: manual` is not a user decision point and must not ask whether to continue. Therefore `auto_transition` **only controls next skill invocation, not phase advancement**. Regardless of `auto_transition`, genuine user decision points below remain blocking. **Decision points are blocking points**: whenever reaching any of the following nodes, the current `/comet` invocation must stop, and follow the `comet/reference/decision-point.md` protocol to obtain the user's explicit choice. Only after the user explicitly chooses can the corresponding state fields be written and operations executed, then auto-advance resumes. Nodes requiring user participation (pause only at these nodes): -1. Open phase proposal/design/tasks review and confirmation -2. Confirm design approach during brainstorming -3. Plan-ready pause choice during build phase, followed by workflow configuration selection (workspace isolation + execution method + TDD mode + code review mode) -4. Decide to fix or accept deviation when verify fails (including Spec drift handling) -5. Choose branch handling method for finishing-branch +1. Workflow target selection: multiple active changes, continue an existing change versus create a new one, or choose which completed batch item starts first +2. Open-phase final proposal/design/tasks review, including the change name and scope; clear requests have no pre-artifact summary/name confirmation +3. Confirm the design approach during brainstorming +4. One joint build decision: plan-ready pause or all available workflow settings (workspace isolation + execution method + TDD mode + code review mode, plus branch name when branch is selected) +5. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or continue/stop after the 4th failure; the first 3 clearly repairable failures close automatically 6. Archive phase final confirmation before running the archive script -7. Encounter an upgrade-assessment signal (hotfix/tweak → user chooses one of two: continue preset / upgrade to full workflow) -8. Build phase scope expansion requiring redesign or new change split -9. Open phase large PRD requiring confirmation to split into multiple changes +7. Choose finishing-branch handling after exact archive changes are committed +8. Encounter an upgrade-assessment signal (hotfix/tweak → user chooses one of two: continue preset / upgrade to full workflow) +9. Build phase scope expansion requiring redesign or new change split +10. Open phase large PRD split confirmation Agents should not skip these decision points; other unambiguous phase transitions must proceed automatically, must not exit midway. At decision points, **must not skip user confirmation or choose automatically — must explicitly obtain the user's choice through the current platform's available user input/confirmation mechanism before continuing**. @@ -220,8 +222,8 @@ Agents should not skip these decision points; other unambiguous phase transition | `/comet-open` | 1. Open | OpenSpec | proposal.md, design.md, tasks.md | | `/comet-design` | 2. Deep Design | Superpowers | Design Doc, delta spec | | `/comet-build` | 3. Plan and Build | Superpowers | Implementation plan, code commits | -| `/comet-verify` | 4. Verify and Close | Both | Verification report, branch handling | -| `/comet-archive` | 5. Archive | OpenSpec | delta→main spec sync, design doc markup, archive | +| `/comet-verify` | 4. Verify | Both | Verification report | +| `/comet-archive` | 5. Archive and Close | OpenSpec | delta→main spec sync, design doc markup, archive commit, branch handling | | `/comet-hotfix` | Preset path | Both | Quick fix (skip brainstorming) | | `/comet-tweak` | Preset path | Both | OpenSpec-chained medium change (delta spec is first-class, skip brainstorming and full plan) | @@ -246,14 +248,14 @@ Agents should not skip these decision points; other unambiguous phase transition ### State Machine Hard Constraints -- Before `build → verify`, `isolation` must be `branch` or `worktree` +- Before full-workflow `build → verify`, `isolation` must be `branch` or `worktree`; hotfix/tweak may truthfully use `current` - Before `build → verify`, `build_mode` must be selected - `build_mode: subagent-driven-development` must also have `subagent_dispatch: confirmed` - Before full workflow leaves build phase, `tdd_mode` must be selected as `tdd` or `direct` - Before full workflow leaves build phase, `review_mode` must be selected as `off`, `standard`, or `thorough` - `build_mode: direct` is allowed by default only for `hotfix` / `tweak`; full workflow requires `direct_override: true` - `build_pause` is not an execution method and must not be written to `build_mode` -- These constraints are enforced by both `comet-guard.mjs build --apply` and `comet-state.mjs transition build-complete` +- These constraints are enforced by both `comet guard build --apply` and `comet state transition build-complete` ### .comet.yaml Field Reference @@ -281,11 +283,11 @@ See `comet/reference/debug-gate.md` for the complete debug gate protocol. ### Script Location -Comet scripts are distributed in `comet/scripts/`. **Do not hardcode paths** — locate once, cache in env vars. The full bootstrap block, command reference (`--apply`, `transition`, `next`, `archive`), and output formats live in `comet/reference/scripts.md`. Run that bootstrap once per session, then reuse `$COMET_GUARD`, `$COMET_STATE`, `$COMET_HANDOFF`, `$COMET_ARCHIVE`, `$COMET_RUNTIME` throughout. Key entry points: +Use the stable `comet` CLI for workflow state, guards, handoff, and archive. Locate internal launchers through `comet/reference/scripts.md` only for intent/resume probes that do not yet have a public subcommand. Key entry points: ```bash -node "$COMET_GUARD" --apply # phase guard + auto state update -node "$COMET_STATE" transition # open-complete | design-complete | build-complete | verify-pass | verify-fail -node "$COMET_STATE" next # NEXT: auto|manual|done + SKILL: ; auto_transition:false → manual, which pauses only the next skill invocation and does not block phase updates -node "$COMET_ARCHIVE" # full archive in one command +comet guard --apply # phase guard + state update +comet state transition # open-complete | design-complete | build-complete | verify-pass | verify-fail +comet state next # NEXT: auto|manual|done + SKILL: +comet archive # full archive in one command ``` diff --git a/assets/skills/comet/reference/auto-transition.md b/assets/skills/comet/reference/auto-transition.md index 1bb78041..bbd10629 100644 --- a/assets/skills/comet/reference/auto-transition.md +++ b/assets/skills/comet/reference/auto-transition.md @@ -13,7 +13,7 @@ This protocol is shared by all comet sub-skills. It defines the automatic handof After exit conditions are met and the phase guard has advanced phase, run: ```bash -node "$COMET_STATE" next +comet state next ``` The script outputs a deterministic next step based on `phase`, `workflow`, and `auto_transition`: diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index 556e3b33..3e89ff32 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -23,6 +23,7 @@ auto_transition: true isolation: branch verify_mode: light verify_result: pending +verify_failures: 0 verification_report: null branch_status: pending created_at: 2026-05-26 @@ -44,14 +45,15 @@ archived: false | `build_mode` | Selected execution mode; may be empty. Values: `subagent-driven-development` (isolated background subagents implement and review each task), `executing-plans` (main session executes sequentially by plan), `direct` (main session codes directly; allowed by default only for hotfix/tweak, full workflow requires `direct_override: true`) | | `build_pause` | Build phase internal pause point. `null` = no pause, `plan-ready` = plan generated, paused for user model switch | | `subagent_dispatch` | `null` or `confirmed`. Only when the platform's real background subagent/Task/multi-agent dispatch capability is confirmed may `build_mode: subagent-driven-development` be written and used to leave the build phase | -| `tdd_mode` | `tdd` or `direct`. Full workflow must select before leaving build. `tdd` forces write-failing-test-first per task; `direct` skips TDD enforcement. hotfix/tweak default to `direct` | +| `tdd_mode` | `tdd` or `direct`. Full workflow must select before leaving build. `tdd` forces write-failing-test-first per task; `direct` skips per-task TDD but still requires relevant tests and bug-regression evidence. hotfix/tweak default to `direct` | | `review_mode` | `off`, `standard`, or `thorough`. Full workflow must select before leaving build; hotfix/tweak default to `off` | -| `isolation` | `branch` or `worktree`, workspace isolation mode. Full init may be `null` but only until `/comet-build` Step 3; hotfix/tweak default to `branch` | +| `isolation` | `current`, `branch`, or `worktree`. Full init may be `null` but must use a real `branch` or `worktree` before leaving build; hotfix/tweak default to `current` and must not claim branch isolation before creating one | | `verify_mode` | `light` or `full`; may be empty | | `auto_transition` | `true` or `false`. Only controls whether to automatically invoke the next skill after phase guard advances phase; `false` outputs `manual` from `comet-state next`, pausing next-skill invocation but not blocking phase field updates | | `verify_result` | `pending`, `pass`, or `fail` | +| `verify_failures` | Machine-owned consecutive verification failure count. `verify-fail` increments it; `verify-pass` or `archive-reopen` resets it to `0`. At `3`, the next failure requires the retry-limit strategy decision | | `verification_report` | Verification report file path; must point to an existing file before verify passes | -| `branch_status` | `pending` or `handled`; set to `handled` after branch handling completes | +| `branch_status` | `pending` or `handled`; keep pending through verify/archive, then set handled after the archive commit and selected branch handling complete | | `created_at` | Change creation date (auto-written at init), format `YYYY-MM-DD` | | `verified_at` | Verification pass timestamp; may be empty | | `archive_confirmation` | `null`, `pending`, or `confirmed`. `verify-pass` writes `pending` when entering the archive phase; after the user selects "Confirm archive" in `/comet-archive`, the `archive-confirm` transition writes `confirmed`; `archive-reopen` clears the field so an earlier confirmation cannot be reused | @@ -65,7 +67,7 @@ archived: false ## State Machine Hard Constraints -- Before `build → verify`, `isolation` must be `branch` or `worktree` +- Before full-workflow `build → verify`, `isolation` must be `branch` or `worktree`; hotfix/tweak may use `current` - Before `build → verify`, `build_mode` must be selected - `build_mode: subagent-driven-development` requires `subagent_dispatch: confirmed` - Full workflow must select `tdd_mode` as `tdd` or `direct` before leaving build diff --git a/assets/skills/comet/reference/context-recovery.md b/assets/skills/comet/reference/context-recovery.md index 49ac4a37..b750b33d 100644 --- a/assets/skills/comet/reference/context-recovery.md +++ b/assets/skills/comet/reference/context-recovery.md @@ -9,7 +9,7 @@ This protocol is shared by all comet sub-skills that may trigger context compres The user may resume the workflow directly from `/comet-open`, `/comet-design`, `/comet-build`, `/comet-verify`, `/comet-archive`, `/comet-hotfix`, or `/comet-tweak`. On entry to any sub-skill, first locate scripts via `comet/reference/scripts.md`, then run the entry check or recovery check for that sub-skill's phase. Do not infer phase from conversation history. ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` If the check shows the actual phase, workflow, or evidence belongs to another skill, switch according to script output and `/comet` routing rules; do not keep writing state in the wrong phase. If the worktree has uncommitted changes, attribute them first via `comet/reference/dirty-worktree.md`. @@ -27,7 +27,7 @@ Only `auto_resume` should resume automatically; `ask_user` must ask one short qu ## Recovery Steps ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` The script outputs structured recovery context (phase, completed fields, pending fields, recovery action). Follow the **Recovery action** output for next steps. diff --git a/assets/skills/comet/reference/decision-point.md b/assets/skills/comet/reference/decision-point.md index c077c9b1..bacec2b2 100644 --- a/assets/skills/comet/reference/decision-point.md +++ b/assets/skills/comet/reference/decision-point.md @@ -4,6 +4,17 @@ Canonical path: `comet/reference/decision-point.md` This protocol is shared by all comet sub-skills that contain user decision points. Any step labeled as a blocking point or user decision point must follow this protocol. +## First Decide Whether User Input Is Actually Required + +Distinguish user decisions, automatic handling, and stop conditions: + +- **User decision**: two or more valid options change scope, behavior, accepted risk, or an irreversible outcome; the user must choose +- **Automatic handling**: exactly one safe next action remains within the request, such as repairing an objective failure, reconciling verifiable state, retrying an idempotent check, or following persisted configuration; execute and report it without manufacturing confirmation +- **Stop condition**: a missing dependency, corrupt state, path escape, or unavailable external command leaves no valid next action; report the blocker and recovery condition without inventing choices +- **Manual handoff**: `NEXT: manual` returns control; it is not a new user decision point. Print `HINT`, end the current invocation, and do not ask whether to continue + +Only the first category uses this protocol. Merge adjacent choices that can be answered together, and do not re-ask persisted choices that remain valid. Preflight platform capabilities and state before presenting options, and show only executable choices. If a field has only one valid value, explain why and apply it without creating a separate pause. + ## Core Rules - Decision points are blocking points. Pause and wait for an explicit user choice before continuing diff --git a/assets/skills/comet/reference/subagent-dispatch.md b/assets/skills/comet/reference/subagent-dispatch.md index d55babe5..68bf7fde 100644 --- a/assets/skills/comet/reference/subagent-dispatch.md +++ b/assets/skills/comet/reference/subagent-dispatch.md @@ -11,9 +11,10 @@ This document provides Comet-specific extensions applied **on top of** the Super > Only stop and wait for user input when: > - A task is **BLOCKED** (review-fix rounds exhausted: `review_mode: standard` — 1 round of risk-task review-fix or final lightweight review not passed; `review_mode: thorough` — 2 rounds of task-level/final review-fix not passed) > - There is irreducible ambiguity that cannot be resolved from the repository, plan, or existing context -> - The platform lacks real background agent dispatch capability and the user must choose `executing-plans` > - The user **explicitly** asks to pause > +> Background dispatch capability disappearing during execution is a runtime stop condition, not automatically a new user decision point. Exit the dispatch loop and return to the same `/comet-build` Step 2 joint decision with `subagent-driven-development` removed. If only one execution method remains, explain why and apply it directly; wait for the user only when multiple valid methods remain. +> > This rule applies to the ENTIRE dispatch loop, not just individual tasks. ## Before Starting @@ -34,14 +35,14 @@ The main session is the **coordinator only** and must NOT execute tasks directly - **Claude Code**: Use the `Agent` tool with `run_in_background: true` for each implementer, task reviewer, fix agent, and final reviewer. Never execute tasks inline and do not accidentally enter team mode, which requires a pre-created team. - **Other platforms**: Use the platform's equivalent background agent / Task / multi-agent dispatch mechanism. - **Never** reuse implementers, reviewers, or fix agents across tasks or roles. Each agent gets a fresh, isolated context containing only the single task and role-specific context it needs. -- If the platform has no real background dispatch capability, do not proceed; pause and wait for the user to choose `build_mode: executing-plans`. +- If real background dispatch capability disappears during execution, stop dispatching and do not let the main session implement the task. Return to the same `/comet-build` Step 2 joint decision with the unavailable mode removed. Do not create a separate "switch to executing-plans" pause; apply the only valid mode directly when just one remains. ### 1. Dispatch Prompt and Return Contract Every implementer or fix-agent prompt must include: - The full text of the single current task, architecture background, and dependency context -- `Language: Use the configured Comet artifact language from "$COMET_BASH" "$COMET_STATE" get language` +- `Language: Use the configured Comet artifact language from comet state get language` - The allowed file scope and prohibited modification scope - The required test commands and commit requirements - For a fix agent, the corresponding reviewer's complete feedback @@ -142,8 +143,8 @@ When a reviewer returns an item that cannot be verified from review material alo 4. Runs targeted verification: ```bash -node "$COMET_STATE" task-checkoff "$PLAN_FILE" "$PLAN_TASK_TEXT" -node "$COMET_STATE" task-checkoff "openspec/changes//tasks.md" "$OPENSPEC_TASK_TEXT" +comet state task-checkoff +comet state task-checkoff openspec/changes//tasks.md ``` Run the second command only when the corresponding mapping exists. The script requires the task text to appear exactly once and be checked; verification failure blocks moving to the next task. diff --git a/assets/skills/comet/rules/comet-phase-guard.en.md b/assets/skills/comet/rules/comet-phase-guard.en.md index 3d2980a7..9d6d834f 100644 --- a/assets/skills/comet/rules/comet-phase-guard.en.md +++ b/assets/skills/comet/rules/comet-phase-guard.en.md @@ -25,8 +25,8 @@ Ordinary source writes are governed only by the selected change phase. With mult | `open` | Create proposal/design/tasks, run guard | Write source code | | `design` | brainstorming, create Design Doc, run guard | Write source code | | `build` | Write source code, tests, execute plans | Skip user confirmation points | -| `verify` | Verification, branch handling | Skip failure handling | -| `archive` | Confirm archive, run archive script | Write source code | +| `verify` | Verification, record verification report | Skip failure handling, handle the branch early | +| `archive` | Confirm archive, run archive script, commit archive changes, handle the branch | Write source code | The hook hard interception allowlist includes workflow and platform workspaces such as `openspec/*`, `docs/superpowers/*`, `.superpowers/*`, `.claude/*`, and `.comet/*`; write access to these paths does not allow skipping the current phase's artifacts or confirmation requirements. @@ -44,7 +44,8 @@ Reading the `phase` field alone is not enough — you must also confirm **how** Exception: `workflow: hotfix/tweak` intentionally skips design, so an empty `design_doc` is normal and not an illegal jump. -Upgrade state note: after a preset (hotfix/tweak) hits an upgrade signal and the user confirms upgrading, `comet-state transition preset-escalate` legally converts it to `workflow: full` + `phase: design` + `design_doc: null`. At this point `phase: design` with an empty `design_doc` **is a normal upgrade pre-state**, not an illegal jump — the agent should enter `/comet-design` to supplement the Design Doc. This terminal state does not match the "skipped design" row above (that row only detects `phase: build`). +Upgrade state note: after a preset (hotfix/tweak) hits an upgrade signal and the user confirms upgrading, `comet state transition preset-escalate` legally converts it to `workflow: full` + `phase: design` + `design_doc: null` and clears preset-only build settings. At this point `phase: design` with an empty `design_doc` **is a normal upgrade pre-state**, not an illegal jump — the agent should enter `/comet-design` to supplement the Design Doc, then choose the full workflow configuration again in build. This terminal state does not match the "skipped design" row above (that row only detects `phase: build`). + ### Skill Invocation (Cannot Replace with Normal Conversation) The following operations must be loaded through the Skill tool. When Skill is unavailable, stop the workflow and prompt to install: @@ -59,50 +60,50 @@ The following operations must be loaded through the Skill tool. When Skill is un ### Script Execution (Cannot Skip) -- **Phase exit**: `comet-guard --apply` (must see ALL CHECKS PASSED) -- **Compression recovery**: `comet-state check --recover` -- **State update**: After key operations, update fields through `comet-state set`; manually editing .comet.yaml is prohibited -- **Phase advancement only via guard/transition**: directly running `comet-state set phase ` to jump phases is prohibited (it bypasses evidence checks and the script now hard-blocks it); use the `COMET_FORCE_PHASE=1` escape hatch only to repair a malformed state. A preset (hotfix/tweak) upgrade to full must use `comet-state transition preset-escalate` — this is the only channel that can legally rewind phase to design and sync workflow/classic_profile; direct `set phase design` and `set classic_profile` are both hard-blocked -- **handoff generation**: `comet-handoff design --write` (handwriting summaries is prohibited) +- **Phase exit**: `comet guard --apply` (must see ALL CHECKS PASSED) +- **Compression recovery**: `comet state check --recover` +- **State update**: After key operations, update fields through `comet state set`; manually editing .comet.yaml is prohibited +- **Phase advancement only via guard/transition**: directly running `comet state set phase ` to jump phases is prohibited. A preset (hotfix/tweak) upgrade to full must use `comet state transition preset-escalate` +- **handoff generation**: `comet handoff design --write` (handwriting summaries is prohibited) ### User Confirmation (Cannot Auto-Skip) The following decision points must pause to wait for explicit user selection; do not auto-fill based on recommendation rules: -- **open**: Requirements clarification completion confirmation, artifact review confirmation +- **open**: Final artifact review, which also confirms the change name and scope. Add an earlier decision only for unresolved target/scope alternatives or a large-PRD split - **design**: brainstorming proposal confirmation (Design Doc cannot be created before confirmation) -- **build**: plan-ready pause, four choices: `isolation` / `build_mode` / `tdd_mode` / `review_mode` (workspace isolation, execution method, TDD mode, code review mode), spec large-scale change confirmation, preset (hotfix/tweak) upgrade-assessment two-choice (when a qualitative-change signal or file-count tripwire is hit, let the user decide whether to continue the preset or upgrade to full) -- **verify**: Verification failure handling strategy, branch handling selection -- **archive**: Final confirmation before archiving +- **build**: After capability preflight, use one joint decision for a plan-ready pause or every executable `isolation` / `build_mode` / `tdd_mode` / `review_mode`; include the branch name when branch is selected, plus large spec-change and preset-upgrade decisions +- **verify**: Accepting WARNING/SUGGESTION deviations, handling Spec drift, or choosing continue/stop after the automatic repair limit +- **archive**: Final confirmation before archiving, plus branch-handling selection after the archive commit ## Design Phase Specifics -1. First script operation = `comet-handoff design --write` (loading brainstorming before generating handoff is prohibited) +1. First script operation = `comet handoff design --write` (loading brainstorming before generating handoff is prohibited) 2. brainstorming in progress: incrementally update brainstorm-summary.md (update recovery checkpoint after each clarification round or proposal iteration; unconfirmed content marked as pending/candidate) 3. After brainstorming completes, next step = brainstorm-summary.md finalization → Design Doc → guard -4. active compaction gate: after brainstorm-summary.md is finalized and before creating Design Doc, prioritize triggering host platform's native context compression; when programmatic triggering is unavailable, pause to prompt user to manually compress or confirm continuing +4. Active context compaction is optional only after the Design Doc, state evidence, and latest handoff are persisted; when programmatic triggering is unavailable, provide a non-blocking suggestion and continue 5. **Absolutely cannot start writing implementation code directly** — must first create Design Doc and pass guard ## Build Phase Specifics -1. After plan creation, must ask user to choose continue or pause (`build_pause` mechanism) +1. After plan creation, filter unavailable options, then ask one joint question: pause, or submit workspace/execution/TDD/review settings and any conditional branch name together 2. After each task acceptance, must: tasks.md checkmark → git commit (do not accumulate). `subagent-driven-development` must complete acceptance according to the current `review_mode`, then the coordinator performs targeted verification by unique task text; do not use an incomplete task summary table to replace current task verification 3. When encountering failures, must load **systematic-debugging** skill; do not propose source code fixes before root cause is located 4. spec change grading: small changes edit directly | medium changes load brainstorming | large changes pause and wait for user confirmation to split ## Verify Phase Specifics -1. First step run `comet-state scale ` to determine verification level -2. After verification fails, list failed items and wait for user selection; CRITICAL must be fixed -3. After 3 consecutive failures, must let user choose to accept deviation or continue fixing -4. When the user chooses to fix, run `comet-state transition verify-fail`: this transition rewinds `phase` back to `build` (not staying in verify), then enter `/comet-build` to fix; after fixing, re-run the build→verify guard +1. First step run `comet state scale ` to determine verification level +2. For the first 3 clearly repairable failures, automatically run `comet state transition verify-fail`, return to build, and enter `/comet-build`. CRITICAL/IMPORTANT failures cannot be accepted as deviations +3. The state machine owns `verify_failures`. After it reaches `3`, the next failure must ask whether to continue fixing or stop the workflow for an external decision +4. Ask whether to fix or accept WARNING/SUGGESTION only when repair introduces a behavior, scope, or risk tradeoff. Safe, local, tradeoff-free repairs close automatically ## Context Compression Recovery If context compression is suspected (previous conversation was summarized, previous discussion cannot be found), immediately run: ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` Decide next step according to the script's **Recovery action** output. @@ -123,17 +124,11 @@ After recovery, first re-run the "Phase-Entry Self-Consistency Check" table: if After guard `--apply` succeeds, do not hardcode the next skill in this rule. First run: ```bash -comet-state next -``` - -If `comet-env.mjs` has already located the scripts, the equivalent command is: - -```bash -node "$COMET_STATE" next +comet state next ``` Decide the next step from the script output: - `NEXT: auto` → use the Skill tool to load the skill named by `SKILL` -- `NEXT: manual` → do not load the next skill; show `HINT` so the user can continue manually +- `NEXT: manual` → do not load the next skill; return control with `HINT`, end the invocation, and do not create another confirmation point - `NEXT: done` → the workflow is complete; no further action is needed diff --git a/assets/skills/comet/rules/comet-phase-guard.md b/assets/skills/comet/rules/comet-phase-guard.md index 5c401c99..0cc854b6 100644 --- a/assets/skills/comet/rules/comet-phase-guard.md +++ b/assets/skills/comet/rules/comet-phase-guard.md @@ -25,8 +25,8 @@ comet state select | `open` | 创建 proposal/design/tasks, 运行 guard | 写源代码 | | `design` | brainstorming, 创建 Design Doc, 运行 guard | 写源代码 | | `build` | 写源代码、测试、执行计划 | 跳过用户确认点 | -| `verify` | 验证、branch handling | 跳过失败处理 | -| `archive` | 确认归档、运行归档脚本 | 写源代码 | +| `verify` | 验证、记录验证报告 | 跳过失败处理、提前处理分支 | +| `archive` | 确认归档、运行归档脚本、提交归档改动、分支处理 | 写源代码 | Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpowers/*`、`.claude/*` 和 `.comet/*` 等流程/平台工作区;这些路径可写不代表可以跳过当前阶段的产物和确认要求。 @@ -44,7 +44,7 @@ Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpower 预设例外:`workflow: hotfix/tweak` 本就跳过 design,`design_doc` 为空属正常,不算非法。 -升级态说明:预设(hotfix/tweak)命中升级信号并经用户确认升级后,通过 `comet-state transition preset-escalate` 合法地变为 `workflow: full` + `phase: design` + `design_doc: null`。此时 `phase: design` + `design_doc` 为空**属正常升级前置态**,不是非法空跳——agent 应进入 `/comet-design` 补 Design Doc。该终态不命中上表「绕过 design 空跳」行(该行仅检测 `phase: build`)。 +升级态说明:预设(hotfix/tweak)命中升级信号并经用户确认升级后,通过 `comet state transition preset-escalate` 合法地变为 `workflow: full` + `phase: design` + `design_doc: null`,同时清除预设专属 build 配置。此时 `phase: design` + `design_doc` 为空**属正常升级前置态**,不是非法空跳——agent 应进入 `/comet-design` 补 Design Doc,并在 build 重新完成联合工作方式选择。该终态不命中上表「绕过 design 空跳」行(该行仅检测 `phase: build`)。 ### Skill 调用(不可用普通对话替代) @@ -60,50 +60,50 @@ Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpower ### 脚本执行(不可跳过) -- **阶段退出**: `comet-guard --apply`(必须看到 ALL CHECKS PASSED) -- **压缩恢复**: `comet-state check --recover` -- **状态更新**: 关键操作后通过 `comet-state set` 更新字段,禁止手工编辑 .comet.yaml -- **阶段推进只能经 guard/transition**: 禁止用 `comet-state set phase <值>` 手动跳阶段(会绕过证据校验,脚本已硬拦截);确需修复畸形状态时才用 `COMET_FORCE_PHASE=1` 逃生阀。预设(hotfix/tweak)升级到 full 必须用 `comet-state transition preset-escalate`——这是唯一能合法回退 phase 到 design 并同步 workflow/classic_profile 的通道,直接 `set phase design` 和 `set classic_profile` 都会被硬拦截 -- **handoff 生成**: `comet-handoff design --write`(禁止手写摘要) +- **阶段退出**: `comet guard --apply`(必须看到 ALL CHECKS PASSED) +- **压缩恢复**: `comet state check --recover` +- **状态更新**: 关键操作后通过 `comet state set` 更新字段,禁止手工编辑 .comet.yaml +- **阶段推进只能经 guard/transition**: 禁止用 `comet state set phase <值>` 手动跳阶段;预设升级到 full 必须用 `comet state transition preset-escalate` +- **handoff 生成**: `comet handoff design --write`(禁止手写摘要) ### 用户确认(不可自动跳过) 以下决策点必须暂停等待用户明确选择,不得根据推荐规则自动填写: -- **open**: 需求澄清完成确认、artifact 评审确认 +- **open**: artifact 最终审视(同时确认 change 名称与范围);只有目标/范围仍有互斥选择或大型 PRD 拆分时增加前置决策 - **design**: brainstorming 方案确认(确认前不得创建 Design Doc) -- **build**: plan-ready 暂停、`isolation` / `build_mode` / `tdd_mode` / `review_mode` 四项选择(工作区隔离、执行方式、TDD 模式、代码审查模式)、spec 大规模变更确认、预设(hotfix/tweak)升级判定二选一(命中质变信号或文件数 tripwire 时,交用户决定继续预设流程还是升级 full) -- **verify**: 验证失败处理策略、branch handling 选择 -- **archive**: 归档前最终确认 +- **build**: 能力预检后,在一个联合决策中选择 plan-ready 暂停或所有可执行的 `isolation` / `build_mode` / `tdd_mode` / `review_mode`;选择 branch 时同时确认分支名,另含 spec 大规模变更确认、预设升级判定 +- **verify**: 接受 WARNING/SUGGESTION 偏差、Spec 漂移处理,或超过自动修复上限后的继续/停止策略 +- **archive**: 归档前最终确认,以及归档提交后的 branch handling 选择 ## Design 阶段专项 -1. 第一个脚本操作 = `comet-handoff design --write`(未生成 handoff 禁止加载 brainstorming) +1. 第一个脚本操作 = `comet handoff design --write`(未生成 handoff 禁止加载 brainstorming) 2. brainstorming in progress: incrementally update brainstorm-summary.md(每轮澄清或方案迭代后增量更新恢复检查点,未确认内容标注为待确认/候选) 3. brainstorming 完成后下一步 = brainstorm-summary.md 定稿 → Design Doc → guard -4. active compaction gate: brainstorm-summary.md 定稿后、创建 Design Doc 前,优先触发宿主平台原生上下文压缩;无法程序化触发时暂停提示用户手动压缩或确认继续 +4. 主动式上下文压缩只能在 Design Doc、状态和最新 handoff 落盘后按需执行;无法程序化触发时给出非阻塞建议并继续 5. **绝对不能直接开始写实现代码** — 必须先创建 Design Doc 并通过 guard ## Build 阶段专项 -1. plan 创建后必须询问用户选择继续或暂停(`build_pause` 机制) +1. plan 创建后先过滤不可执行选项,再只发起一个联合决策:暂停,或一次性提交工作区/执行/TDD/审查配置及条件性的分支名 2. 每个 task 验收后必须: tasks.md 打勾 → git commit(不得积攒)。`subagent-driven-development` 必须按当前 `review_mode` 完成验收,再由协调者按任务唯一文本定向勾选和验证;不得用未完成任务总表代替当前任务验证 3. 遇到失败必须加载 **systematic-debugging** skill,根因未定位前不得提出源码修复 4. spec 变更分级: 小改直接编辑 | 中改加载 brainstorming | 大改暂停等用户确认拆分 ## Verify 阶段专项 -1. 第一步运行 `comet-state scale ` 确定验证级别 -2. 验证失败后列出失败项等用户选择,CRITICAL 必须修 -3. 连续 3 次失败后必须让用户选择接受偏差或继续修 -4. 用户选择修复时运行 `comet-state transition verify-fail`:该转移会把 `phase` 回退到 `build`(不是停在 verify),随后进入 `/comet-build` 修复,修完重新过 build→verify guard +1. 第一步运行 `comet state scale ` 确定验证级别 +2. 前 3 次明确可修复失败自动运行 `comet state transition verify-fail` 回到 build,并进入 `/comet-build`;CRITICAL/IMPORTANT 不得接受偏差 +3. `verify_failures` 由状态机维护。达到 `3` 后的下一次失败必须让用户选择继续修复或停止 workflow 寻求外部决策 +4. WARNING/SUGGESTION 只有在修复涉及行为、范围或风险取舍时才询问修复/接受;安全、局部、无取舍的修复自动闭环 ## 上下文压缩恢复 如果怀疑发生上下文压缩(之前对话被摘要、找不到之前讨论的内容),立即运行: ```bash -node "$COMET_STATE" check --recover +comet state check --recover ``` 按脚本输出的 **Recovery action** 决定下一步。 @@ -124,17 +124,11 @@ node "$COMET_STATE" check --recover guard `--apply` 成功后,不得在本规则中硬编码下一阶段 skill。必须先运行: ```bash -comet-state next -``` - -若已通过 `comet-env.mjs` 定位脚本,等价运行: - -```bash -node "$COMET_STATE" next +comet state next ``` 按脚本输出决定下一步: - `NEXT: auto` → 使用 Skill 工具加载 `SKILL` 指向的 skill -- `NEXT: manual` → 不加载下一 skill,按 `HINT` 提示用户手动继续 +- `NEXT: manual` → 不加载下一 skill,按 `HINT` 交还控制权并结束当前调用;不再创建确认点 - `NEXT: done` → 流程已完成,无需继续 diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 83c9b5cb..32dbd252 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7739,7 +7739,7 @@ function fullBuildConfigured(classic) { } function presetBuildConfigured(classic) { return Boolean( - classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation === "branch" && classic.verifyMode === "light" + classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation !== null && classic.verifyMode === "light" ); } function resolveBuild(profile, classic, evidence) { @@ -7808,7 +7808,7 @@ var BUILD_PAUSES = ["plan-ready"]; var SUBAGENT_DISPATCH = ["confirmed"]; var TDD_MODES = ["tdd", "direct"]; var REVIEW_MODES = ["off", "standard", "thorough"]; -var ISOLATIONS = ["branch", "worktree"]; +var ISOLATIONS = ["current", "branch", "worktree"]; var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; @@ -7830,6 +7830,7 @@ var CLASSIC_WIRE_KEYS = [ "design_doc", "plan", "verify_result", + "verify_failures", "verification_report", "branch_status", "created_at", @@ -7891,6 +7892,14 @@ function booleanValue(doc, key, nullable = true) { } return value; } +function nonNegativeInteger(doc, key, fallback = 0) { + const value = doc[key]; + if (value === null || value === void 0 || value === "") return fallback; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`Invalid Classic state: ${key} must be a non-negative integer`); + } + return value; +} function relativePath(doc, key) { const value = nullableString(doc, key); if (value === null) return null; @@ -7939,6 +7948,7 @@ function classicStateFromDocument(doc) { designDoc: relativePath(doc, "design_doc"), plan: relativePath(doc, "plan"), verifyResult: enumValue(doc, "verify_result", VERIFY_RESULTS, false), + verifyFailures: nonNegativeInteger(doc, "verify_failures"), verificationReport: relativePath(doc, "verification_report"), branchStatus: enumValue(doc, "branch_status", BRANCH_STATUSES), createdAt: nullableString(doc, "created_at"), @@ -8003,6 +8013,7 @@ function classicStateToDocument(state) { design_doc: state.designDoc, plan: state.plan, verify_result: state.verifyResult, + verify_failures: state.verifyFailures, verification_report: state.verificationReport, branch_status: state.branchStatus, created_at: state.createdAt, @@ -9390,7 +9401,7 @@ var CLASSIC_TRANSITION_TABLE = { "verify-pass": { event: "verify-pass", from: "verify", - guardRefs: ["verification-report-present", "branch-status-handled"] + guardRefs: ["verification-report-present"] }, "verify-fail": { event: "verify-fail", @@ -9450,18 +9461,22 @@ function applyClassicTransition(current, event, options = {}) { const preserveEvidence = classic.verifyResult === "fail"; setField(classic, effects, "phase", "verify"); setField(classic, effects, "verifyResult", "pending"); + setField(classic, effects, "branchStatus", "pending"); if (!preserveEvidence) { setField(classic, effects, "verificationReport", null); - setField(classic, effects, "branchStatus", "pending"); } } else if (event === "verify-pass") { setField(classic, effects, "verifyResult", "pass"); + setField(classic, effects, "verifyFailures", 0); setField(classic, effects, "phase", "archive"); setField(classic, effects, "verifiedAt", dateOnly(now)); setField(classic, effects, "archiveConfirmation", "pending"); + setField(classic, effects, "branchStatus", "pending"); } else if (event === "verify-fail") { setField(classic, effects, "verifyResult", "fail"); + setField(classic, effects, "verifyFailures", classic.verifyFailures + 1); setField(classic, effects, "phase", "build"); + setField(classic, effects, "branchStatus", "pending"); } else if (event === "preset-escalate") { if (classic.workflow !== "hotfix" && classic.workflow !== "tweak") { throw new Error( @@ -9472,6 +9487,14 @@ function applyClassicTransition(current, event, options = {}) { setField(classic, effects, "classicProfile", "full"); setField(classic, effects, "phase", "design"); setField(classic, effects, "designDoc", null); + setField(classic, effects, "buildPause", null); + setField(classic, effects, "buildMode", null); + setField(classic, effects, "subagentDispatch", null); + setField(classic, effects, "tddMode", null); + setField(classic, effects, "reviewMode", null); + setField(classic, effects, "isolation", null); + setField(classic, effects, "verifyMode", null); + setField(classic, effects, "directOverride", null); } else if (event === "archive-confirm") { if (classic.verifyResult !== "pass") { throw new Error(`Cannot apply ${event}: verifyResult must be pass`); @@ -9481,9 +9504,11 @@ function applyClassicTransition(current, event, options = {}) { } else if (event === "archive-reopen") { if (classic.archived) throw new Error(`Cannot apply ${event}: already archived`); setField(classic, effects, "verifyResult", "pending"); + setField(classic, effects, "verifyFailures", 0); setField(classic, effects, "phase", "verify"); setField(classic, effects, "verifiedAt", null); setField(classic, effects, "archiveConfirmation", null); + setField(classic, effects, "branchStatus", "pending"); } else { if (classic.verifyResult !== "pass") { throw new Error(`Cannot apply ${event}: verifyResult must be pass`); @@ -10095,7 +10120,7 @@ var ENUMS = { subagent_dispatch: ["confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["branch", "worktree"], + isolation: ["current", "branch", "worktree"], verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], @@ -10188,6 +10213,12 @@ var classicValidateCommand = async (args) => { fail3(`${field2}='${value}' is not valid. Expected: ${values.join(" ")}`); } } + if (Object.prototype.hasOwnProperty.call(record, "verify_failures")) { + const value = record.verify_failures; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + fail3(`verify_failures='${text(value)}' is not a non-negative integer`); + } + } for (const field2 of ["design_doc", "plan", "handoff_context"]) { const value = text(record[field2]); if (value && !await exists3(path14.resolve(value))) { @@ -10224,8 +10255,9 @@ async function fileExists3(filePath) { try { await fs13.access(filePath); return true; - } catch { - return false; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; } } async function readDir(dirPath) { @@ -10239,6 +10271,9 @@ async function readDir(dirPath) { throw error; } } +function isNotFoundError(error) { + return error?.code === "ENOENT"; +} // domains/comet-classic/classic-project-config.ts function configCandidates(options = {}) { @@ -10644,11 +10679,14 @@ Next: check off corresponding completed plan tasks, then commit the plan update. } async function isolationSelected(changeDir, change) { const isolation = await readField(changeDir, "isolation"); + const workflow = await readField(changeDir, "workflow"); if (isolation === "branch" || isolation === "worktree") return pass(); + if (isolation === "current" && (workflow === "hotfix" || workflow === "tweak")) return pass(); + const allowedValues = workflow === "full" ? "" : ""; return fail( - `isolation must be branch or worktree, got '${isolation || "null"}' -Next: ask the user to choose branch or worktree, create the chosen isolation, then run: - node "$COMET_STATE" set ${change} isolation ` + `isolation must be ${workflow === "full" ? "branch or worktree" : "current, branch, or worktree"}, got '${isolation || "null"}' +Next: choose a valid workspace mode, prepare it when needed, then run: + comet state set ${change} isolation ${allowedValues}` ); } async function buildModeSelected(changeDir, change) { @@ -10658,7 +10696,7 @@ async function buildModeSelected(changeDir, change) { return fail( `build_mode must be selected before leaving build, got '${buildMode || "null"}' Next: ask the user to choose an execution mode, then run: - node "$COMET_STATE" set ${change} build_mode ` + comet state set ${change} build_mode ` ); } async function buildModeAllowedForWorkflow(changeDir) { @@ -10680,9 +10718,9 @@ async function subagentDispatchConfirmed(changeDir, change) { return fail( `subagent_dispatch must be confirmed before using build_mode=subagent-driven-development Next: confirm the current platform has a real background subagent/Task/multi-agent dispatcher, then run: - node "$COMET_STATE" set ${change} subagent_dispatch confirmed -Or ask the user to switch to executing-plans and run: - node "$COMET_STATE" set ${change} build_mode executing-plans` + comet state set ${change} subagent_dispatch confirmed +If dispatch is unavailable, return to /comet-build Step 2 with subagent-driven-development removed. When executing-plans is the only valid mode, run: + comet state set ${change} build_mode executing-plans` ); } async function tddModeSelected(changeDir, change) { @@ -10693,7 +10731,7 @@ async function tddModeSelected(changeDir, change) { return fail( `tdd_mode must be tdd or direct for full workflow, got '${tddMode || "null"}' Next: ask the user to choose TDD enforcement level, then run: - node "$COMET_STATE" set ${change} tdd_mode ` + comet state set ${change} tdd_mode ` ); } async function reviewModeSelected(changeDir, change) { @@ -10706,7 +10744,7 @@ async function reviewModeSelected(changeDir, change) { return fail( `review_mode must be off, standard, or thorough before leaving build, got '${reviewMode || "null"}' Next: ask the user to choose review strength, then run: - node "$COMET_STATE" set ${change} review_mode ` + comet state set ${change} review_mode ` ); } async function verificationReportExists(changeDir) { @@ -10737,7 +10775,7 @@ async function designDocRecorded(changeDir, change) { if (designDoc && designDoc !== "null" && existsSync(designDoc)) return pass(); return fail( `design_doc must point to an existing Superpowers Design Doc for full workflow before leaving design. -Next: create the Design Doc and run: node "$COMET_STATE" set ${change} design_doc ` +Next: create the Design Doc and run: comet state set ${change} design_doc ` ); } async function designHandoffContextValid(changeDir, change) { @@ -10752,13 +10790,13 @@ Next: run node "$COMET_HANDOFF" ${change} design --write before invoking Superpo if (!await nonempty(context)) { return fail( `handoff_context does not point to a non-empty file: ${context} -Next: regenerate the design handoff with comet-handoff.mjs.` +Next: regenerate the design handoff with comet handoff ${change} design --write.` ); } if (!/^[a-f0-9]{64}$/u.test(recordedHash)) { return fail( `handoff_hash is missing or invalid: ${recordedHash || "null"} -Next: regenerate the design handoff with comet-handoff.mjs.` +Next: regenerate the design handoff with comet handoff ${change} design --write.` ); } const actualHash = await computeHandoffHash(changeDir); @@ -10767,14 +10805,14 @@ Next: regenerate the design handoff with comet-handoff.mjs.` `OpenSpec artifacts changed after handoff was generated. Expected handoff_hash: ${recordedHash} Actual handoff_hash: ${actualHash} -Next: rerun comet-handoff.mjs so Superpowers receives the current OpenSpec context.` +Next: run comet handoff ${change} design --write so Superpowers receives the current OpenSpec context.` ); } const markdown = `${context.replace(/\.json$/u, "")}.md`; if (!await nonempty(markdown)) { return fail( `design handoff markdown is missing or empty: ${markdown} -Next: regenerate the design handoff with comet-handoff.mjs.` +Next: regenerate the design handoff with comet handoff ${change} design --write.` ); } return pass(); @@ -11007,11 +11045,7 @@ async function guardVerifyChecks(output, changeDir, change, run) { const report = await readField(changeDir, "verification_report"); if (!report || report === "null" || !await exists4(report)) return pass(); return documentLanguageMatchesConfigured(changeDir, report); - }), - check( - "branch_status=handled", - async () => await branchStatusHandled(changeDir) ? pass() : fail("") - ) + }) ]); } async function guardArchiveChecks(output, changeDir) { @@ -11025,7 +11059,11 @@ async function guardArchiveChecks(output, changeDir) { "design.md exists", async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") ), - check("tasks.md all tasks checked", () => tasksAllDone(changeDir)) + check("tasks.md all tasks checked", () => tasksAllDone(changeDir)), + check( + "branch_status=handled", + async () => await branchStatusHandled(changeDir) ? pass() : fail("") + ) ]); } async function applyStateUpdate(output, change, changeDir, phase, context) { @@ -11736,6 +11774,10 @@ function inputTarget() { function normalized(value) { return value.replaceAll("\\", "/").replace(/\/+/gu, "/"); } +function comparisonKey(value) { + const normalizedValue = normalized(value); + return process.platform === "win32" ? normalizedValue.toLowerCase() : normalizedValue; +} function parseProjectRoot(args) { const index = args.indexOf("--project-root"); const value = index >= 0 ? args[index + 1] : void 0; @@ -11790,12 +11832,17 @@ async function projectRelative(target, projectRoot2) { } async function loadGoverningChange(changeDir) { try { - const runtime = await ensureStrictClassicRuntimeRun(changeDir); + const projection = await readClassicState(changeDir, { migrate: false }); + const unknownKeys = Array.from(new Set(projection.unknownKeys)).sort(); + if (unknownKeys.length > 0) { + throw new Error(`Invalid Classic state: unknown field(s): ${unknownKeys.join(", ")}`); + } + if (!projection.classic) throw new Error("Classic state projection is unavailable"); return { changeDir, - phase: runtime.classic.phase, - classic: runtime.classic, - archived: runtime.classic.archived + phase: projection.classic.phase, + classic: projection.classic, + archived: projection.classic.archived }; } catch { const legacy = await readLegacyState(changeDir); @@ -11825,7 +11872,41 @@ async function activeChanges(projectRoot2) { return governingChanges; } function isSuperpowersArtifactPath(relativePath2) { - return relativePath2.startsWith("docs/superpowers/"); + return comparisonKey(relativePath2).startsWith("docs/superpowers/"); +} +var SUPERPOWERS_ARTIFACT_SLOTS = [ + { + prefix: "docs/superpowers/specs/", + field: "designDoc", + wireField: "design_doc", + phase: "design" + }, + { + prefix: "docs/superpowers/plans/", + field: "plan", + wireField: "plan", + phase: "build" + }, + { + prefix: "docs/superpowers/reports/", + field: "verificationReport", + wireField: "verification_report", + phase: "verify" + } +]; +function standardSuperpowersArtifactSlot(relativePath2) { + const key = comparisonKey(relativePath2); + const slot = SUPERPOWERS_ARTIFACT_SLOTS.find((candidate) => key.startsWith(candidate.prefix)); + if (!slot) return null; + const fileName = key.slice(slot.prefix.length); + if (!fileName || fileName.includes("/") || !fileName.endsWith(".md")) return null; + return slot; +} +function superpowersArtifactValue(governing, slot) { + return governing.classic?.[slot.field] ?? null; +} +function allowsFirstSuperpowersArtifactWrite(governing, slot) { + return governing.classic !== null && governing.phase === slot.phase && !superpowersArtifactValue(governing, slot); } function allowsSuperpowersArtifacts(governing) { return governing.phase === "design" || governing.phase === "build" || governing.phase === "verify"; @@ -11851,7 +11932,7 @@ function matchesRecordedSuperpowersArtifact(relativePath2, governing) { governing.classic?.verificationReport ]; return artifactPaths.some( - (artifactPath) => artifactPath && normalized(artifactPath) === relativePath2 + (artifactPath) => artifactPath && comparisonKey(artifactPath) === comparisonKey(relativePath2) ); } function matchesSuperpowersArtifactName(relativePath2, changeName) { @@ -11867,7 +11948,7 @@ async function superpowersArtifactGoverningChange(relativePath2, projectRoot2) { const recorded = active.find( (governing) => matchesRecordedSuperpowersArtifact(relativePath2, governing) ); - if (recorded) return recorded; + if (recorded) return { governing: recorded, match: "recorded" }; const eligible = active.filter(allowsSuperpowersArtifacts); const named = eligible.filter((governing) => { const name = governingChangeName(governing); @@ -11875,7 +11956,7 @@ async function superpowersArtifactGoverningChange(relativePath2, projectRoot2) { }).sort( (a, b) => (governingChangeName(b)?.length ?? 0) - (governingChangeName(a)?.length ?? 0) )[0]; - if (named) return named; + if (named) return { governing: named, match: "named" }; return null; } async function repoSourceGoverningChange(projectRoot2, relativePath2) { @@ -11923,7 +12004,26 @@ async function governingChange(relativePath2, projectRoot2) { } if (isSuperpowersArtifactPath(relativePath2)) { const superpowers = await superpowersArtifactGoverningChange(relativePath2, projectRoot2); - if (superpowers) return { ...superpowers, superpowersArtifact: "matched" }; + if (superpowers?.match === "recorded") { + return { ...superpowers.governing, superpowersArtifact: "matched" }; + } + const slot = standardSuperpowersArtifactSlot(relativePath2); + if (superpowers) { + return slot ? { + ...superpowers.governing, + superpowersArtifact: allowsFirstSuperpowersArtifactWrite(superpowers.governing, slot) ? "matched" : "unmatched", + superpowersSlot: slot + } : { ...superpowers.governing, superpowersArtifact: "matched" }; + } + if (slot) { + const candidate = await repoSourceGoverningChange(projectRoot2, relativePath2); + if (!candidate || "blockedResult" in candidate) return candidate; + return { + ...candidate, + superpowersArtifact: allowsFirstSuperpowersArtifactWrite(candidate, slot) ? "matched" : "unmatched", + superpowersSlot: slot + }; + } const fallback = (await activeChanges(projectRoot2))[0] ?? null; return fallback ? { ...fallback, superpowersArtifact: "unmatched" } : null; } @@ -11971,7 +12071,7 @@ function blocked(relativePath2, phase) { " BLOCKED: source writes are not allowed during design", " This phase does not allow source writes", " ALLOWED: run brainstorming, create the Design Doc, and run guard", - " NEXT: finish the Design Doc, then run comet-guard design --apply to enter build" + " NEXT: finish the Design Doc, then run comet guard design --apply to enter build" ] : [ " BLOCKED: source writes are not allowed during archive", " This phase does not allow source writes", @@ -12012,7 +12112,25 @@ function blockedMissingDesignDoc(relativePath2) { ].join("\n") ); } -function blockedUnmatchedSuperpowersArtifact(relativePath2, phase) { +function blockedUnmatchedSuperpowersArtifact(relativePath2, governing) { + const slot = governing.superpowersSlot; + const recorded = slot ? superpowersArtifactValue(governing, slot) : null; + const details = slot ? governing.phase !== slot.phase ? [ + ` BLOCKED: ${slot.wireField} cannot be first-written in phase ${governing.phase}`, + ` Expected phase: ${slot.phase}`, + " NEXT: resume the matching Comet phase or use an already recorded artifact path" + ] : recorded ? [ + ` BLOCKED: ${slot.wireField} is already recorded for this change`, + ` Recorded path: ${recorded}`, + " NEXT: write the recorded artifact or explicitly correct the state path" + ] : [ + " BLOCKED: standard Superpowers artifact state is incomplete", + " NEXT: validate the active change state, then retry the matching phase" + ] : [ + " BLOCKED: unmatched Superpowers artifact", + " This docs/superpowers/ path does not match any active change artifact", + " NEXT: use a recorded artifact path or a standard phase artifact directory" + ]; return result( 2, [ @@ -12021,12 +12139,10 @@ function blockedUnmatchedSuperpowersArtifact(relativePath2, phase) { "║ COMET PHASE GUARD — WRITE BLOCKED ║", "╚══════════════════════════════════════════╝", "", - ` Current phase: ${phase}`, + ` Current phase: ${governing.phase}`, ` Target file: ${relativePath2}`, "", - " BLOCKED: unmatched Superpowers artifact", - " This docs/superpowers/ path does not match any active change artifact", - " NEXT: record the artifact path in .comet.yaml or include the change name in the artifact filename", + ...details, "" ].join("\n") ); @@ -12104,7 +12220,7 @@ var classicHookGuardCommand = async (args) => { return allowed(`${relativePath2} (phase: ${phase}, superpowers)`); } if (governing.superpowersArtifact === "unmatched") { - return blockedUnmatchedSuperpowersArtifact(relativePath2, phase); + return blockedUnmatchedSuperpowersArtifact(relativePath2, governing); } } if (phase === "build" && governing.classic?.workflow === "full" && !governing.classic.designDoc) { @@ -12928,6 +13044,7 @@ var EVENTS = CLASSIC_TRANSITION_EVENTS; var MACHINE_OWNED_FIELDS = /* @__PURE__ */ new Set([ ...RUN_WIRE_KEYS, "archive_confirmation", + "verify_failures", "classic_profile", "classic_migration" ]); @@ -12943,7 +13060,7 @@ var FIELD_ENUMS = { subagent_dispatch: ["null", "confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["branch", "worktree"], + isolation: ["current", "branch", "worktree"], verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], @@ -12966,6 +13083,7 @@ var CLASSIC_FIELD_WIRE_NAMES2 = { verifiedAt: "verified_at", archiveConfirmation: "archive_confirmation", verifyResult: "verify_result", + verifyFailures: "verify_failures", workflow: "workflow" }; var CommandFailure = class extends Error { @@ -13100,6 +13218,10 @@ function nullableRecordBoolean(record, field2) { if (value === "false") return false; return null; } +function nonNegativeRecordInteger(record, field2, fallback = 0) { + const value = record[field2]; + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback; +} function sparseClassicState(record) { const workflow = enumRecordValue(record, "workflow", PROFILES, "full"); return { @@ -13127,7 +13249,12 @@ function sparseClassicState(record) { ["off", "standard", "thorough"], null ), - isolation: enumRecordValue(record, "isolation", ["branch", "worktree"], null), + isolation: enumRecordValue( + record, + "isolation", + ["current", "branch", "worktree"], + null + ), verifyMode: enumRecordValue(record, "verify_mode", ["light", "full"], null), autoTransition: nullableRecordBoolean(record, "auto_transition"), baseRef: nullableRecordString(record, "base_ref"), @@ -13139,6 +13266,7 @@ function sparseClassicState(record) { ["pending", "pass", "fail"], "pending" ), + verifyFailures: nonNegativeRecordInteger(record, "verify_failures"), verificationReport: nullableRecordString(record, "verification_report"), branchStatus: enumRecordValue(record, "branch_status", ["pending", "handled"], null), createdAt: nullableRecordString(record, "created_at"), @@ -13315,13 +13443,14 @@ async function init(output, name, workflow) { subagent_dispatch: null, tdd_mode: preset ? "direct" : null, review_mode: reviewMode, - isolation: preset ? "branch" : null, + isolation: preset ? "current" : null, verify_mode: preset ? "light" : null, auto_transition: await autoTransition() === "true", base_ref: gitOutput(["rev-parse", "--verify", "HEAD"]), design_doc: null, plan: null, verify_result: "pending", + verify_failures: 0, verification_report: null, branch_status: "pending", created_at: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), @@ -13346,9 +13475,10 @@ async function requireBuildDecisions(name) { const subagentDispatch = await readField3(name, "subagent_dispatch"); const tddMode = await readField3(name, "tdd_mode"); const reviewMode = await readField3(name, "review_mode"); - if (!["branch", "worktree"].includes(isolation)) { + const allowedIsolation = workflow === "full" ? ["branch", "worktree"] : ["current", "branch", "worktree"]; + if (!allowedIsolation.includes(isolation)) { fail2( - `ERROR: Cannot transition '${name}': isolation must be branch or worktree, got '${isolation || "null"}'` + `ERROR: Cannot transition '${name}': isolation must be ${workflow === "full" ? "branch or worktree" : "current, branch, or worktree"}, got '${isolation || "null"}'` ); } if (!["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) { @@ -13469,9 +13599,6 @@ async function transition(output, name, event) { `ERROR: Cannot transition '${name}': verification_report must point to an existing report file` ); } - if (await readField3(name, "branch_status") !== "handled") { - fail2(`ERROR: Cannot transition '${name}': branch_status must be handled`); - } } else if (event === "verify-fail") { await requirePhase(name, "verify"); } else if (event === "archive-confirm") { @@ -13731,7 +13858,7 @@ function resolveBuildRecoveryAction(workflow, isolation, buildMode, pause, subag } if (pause === "plan-ready") { if (buildMode === "subagent-driven-development" && (pending > 0 || planPending > 0)) { - return subagentDispatch === "confirmed" ? "Recovery action: Plan-ready pause is stale because build decisions are already selected. Clear build_pause to null, then inspect the first unchecked task (OpenSpec or plan additions) against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Plan-ready pause is stale and subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing."; + return subagentDispatch === "confirmed" ? "Recovery action: Plan-ready pause is stale because build decisions are already selected. Clear build_pause to null, then inspect the first unchecked task (OpenSpec or plan additions) against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Plan-ready pause is stale and subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing."; } if (pending > 0 || planPending > 0) { return "Recovery action: Plan-ready pause is stale because build decisions are already selected. Clear build_pause to null, then continue from the first unchecked task."; @@ -13752,13 +13879,13 @@ function resolveBuildRecoveryAction(workflow, isolation, buildMode, pause, subag } if (pending > 0) { if (buildMode === "subagent-driven-development") { - return subagentDispatch === "confirmed" ? "Recovery action: Read tasks.md and the Superpowers plan (which may include additions beyond OpenSpec), then inspect the first unchecked task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing."; + return subagentDispatch === "confirmed" ? "Recovery action: Read tasks.md and the Superpowers plan (which may include additions beyond OpenSpec), then inspect the first unchecked task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing."; } return "Recovery action: Read tasks.md and continue from first unchecked task."; } if (planPending > 0) { if (buildMode === "subagent-driven-development") { - return subagentDispatch === "confirmed" ? "Recovery action: Read the Superpowers plan, then inspect the first unchecked Superpowers plan task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing."; + return subagentDispatch === "confirmed" ? "Recovery action: Read the Superpowers plan, then inspect the first unchecked Superpowers plan task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window." : "Recovery action: Subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing."; } return "Recovery action: Read the Superpowers plan and continue from the first unchecked plan task."; } @@ -13766,17 +13893,19 @@ function resolveBuildRecoveryAction(workflow, isolation, buildMode, pause, subag } async function recoverVerify(output, name) { const result5 = await readField3(name, "verify_result"); + const failures = await readField3(name, "verify_failures"); const mode = await readField3(name, "verify_mode"); const report = await readField3(name, "verification_report"); const branch = await readField3(name, "branch_status"); output.stdout.push( " Verification:", fieldStatus("verify_result", result5), + ` - verify_failures: ${failures || "0"}`, fieldStatus("verify_mode", mode), fieldStatus("verification_report", report, report), - fieldStatus("branch_status", branch), + branch === "handled" ? " - branch_status: LEGACY (handled before archive; archive still owns final closure)" : " - branch_status: DEFERRED (handled after the archive commit)", "", - result5 === "pass" && branch === "handled" ? "Recovery action: Verification complete. Run guard to transition to archive." : result5 === "fail" ? "Recovery action: Verification failed and rolled back to build. Resume from /comet-build." : "Recovery action: Verification not yet started or in progress. Run scale assessment then verify." + result5 === "pass" ? "Recovery action: Verification complete. Continue to archive; branch handling happens after archive changes are committed." : result5 === "fail" ? "Recovery action: Verification failed and rolled back to build. Resume from /comet-build." : "Recovery action: Verification not yet started or in progress. Run scale assessment then verify." ); } async function recoverArchive(output, name) { diff --git a/docs/superpowers/plans/2026-07-14-skill-hook-rule-lifecycle-hardening.md b/docs/superpowers/plans/2026-07-14-skill-hook-rule-lifecycle-hardening.md new file mode 100644 index 00000000..3cb765cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-skill-hook-rule-lifecycle-hardening.md @@ -0,0 +1,331 @@ +# Skill, Hook, and Rule Lifecycle Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Skill, Hook, and Rule installation, diagnosis, update, and uninstall fail safely and report one truthful component-level result. + +**Architecture:** Keep existing platform-specific Hook encoders, but consolidate their shared JSON safety and outcome contracts. Propagate component results through init/update/uninstall, then reuse manifest and managed-command knowledge to let Doctor detect partial Rule and Hook installations. + +**Tech Stack:** TypeScript 5.9, Node.js 20+, Vitest 4, Commander CLI, JSON platform configuration. + +## Global Constraints + +- Do not modify original Superpowers or OpenSpec Skills. +- Preserve the Codex split: Skills under `.agents/skills`; configuration, Rules, and Hooks under `.codex`. +- Never overwrite malformed or unreadable user-owned Hook JSON. +- Dedicated Comet-owned Hook files may be replaced; shared platform settings must be merged. +- A target is successful only when all required Skill, Rule, and Hook components succeed. +- Do not install a Hook when its managed Skill scripts were not copied successfully. +- Codex historical Hook files remain best-effort after canonical cleanup; canonical configuration failures are blocking. +- Use TDD and capture RED before production changes for every behavior. +- Preserve unrelated changes and stage only task files. +- Current `package.json` is `0.4.0-beta.5` and `origin/master` is `0.4.0-beta.4`; append to beta.5 without bumping the version. + +--- + +### Task 1: Fail-Closed Hook Configuration and Explicit Outcomes + +**Files:** + +- Modify: `domains/skill/platform-install.ts` +- Modify: `domains/skill/uninstall.ts` +- Modify: `app/commands/init.ts` +- Modify: `app/commands/update.ts` +- Modify: `test/domains/skill/skills.test.ts` +- Modify: `test/domains/skill/uninstall.test.ts` +- Modify: `test/app/uninstall.test.ts` + +**Interfaces:** + +- Produce and export `HookInstallResult` as `{ status: 'installed' | 'skipped' | 'failed'; reason?: string }`. +- Preserve `RemovalResult` as `{ removed: number; failed: number }`. +- Reuse `readSettingsJsonObject(settingsPath, hookFormat)` for every shared JSON Hook installer. + +- [ ] **Step 1: Add failing install regressions** + +Add table-driven tests proving malformed Claude Code, Amazon Q, Gemini, and Windsurf JSON remains byte-for-byte unchanged and returns `status: 'failed'`. Keep the existing invalid CodeBuddy case and update all successful Hook assertions to `status: 'installed'`. + +- [ ] **Step 2: Run install regressions and verify RED** + +Run: + +```bash +npx vitest run test/domains/skill/skills.test.ts -t "invalid|malformed|Hook" +``` + +Expected: Claude/Amazon Q/Gemini/Windsurf invalid JSON cases fail because current code rewrites the files; result-shape assertions fail because the current API uses `installed: boolean`. + +- [ ] **Step 3: Add failing canonical uninstall regressions** + +For Qwen, Gemini, and Windsurf, write malformed canonical Hook JSON, call `removeCometHooksForPlatform()`, and expect `{ removed: 0, failed: 1 }` with unchanged bytes. + +- [ ] **Step 4: Run uninstall regressions and verify RED** + +Run: + +```bash +npx vitest run test/domains/skill/uninstall.test.ts test/app/uninstall.test.ts -t "malformed|canonical" +``` + +Expected: each new platform case returns `failed: 0` before the fix. + +- [ ] **Step 5: Implement the minimal Hook contract** + +In `platform-install.ts`: + +```ts +type HookInstallStatus = 'installed' | 'skipped' | 'failed'; + +interface HookInstallResult { + status: HookInstallStatus; + reason?: string; +} +``` + +Use `skipped` only for a platform without Hook support or a manifest without Hooks. Use `failed` for unsupported declared formats, invalid shared configuration, or any read/write error. Use `readSettingsJsonObject()` for Claude-shaped, Gemini, and Windsurf shared files; preserve the platform name in the error reason. Update init/update callers to consume `status` without yet changing their aggregate result models. + +In `uninstall.ts`, canonical Qwen/Gemini/Windsurf read or parse errors return `{ removed: 0, failed: 1 }`. Keep Codex legacy files best-effort. + +- [ ] **Step 6: Verify GREEN and idempotency** + +Run: + +```bash +npx vitest run test/domains/skill/skills.test.ts test/domains/skill/uninstall.test.ts test/app/uninstall.test.ts +``` + +Expected: all tests pass and existing user-Hook merge/idempotency cases remain green. + +- [ ] **Step 7: Commit** + +```bash +git add domains/skill/platform-install.ts domains/skill/uninstall.ts app/commands/init.ts app/commands/update.ts test/domains/skill/skills.test.ts test/domains/skill/uninstall.test.ts test/app/uninstall.test.ts +git commit -m "fix(skill): fail closed on invalid Hook configuration" +``` + +--- + +### Task 2: Truthful Rule Copy and Filesystem Removal Results + +**Files:** + +- Modify: `platform/fs/file-system.ts` +- Modify: `domains/skill/platform-install.ts` +- Modify: `domains/skill/uninstall.ts` +- Regenerate: `assets/skills/comet/scripts/comet-runtime.mjs` +- Modify: `test/platform/file-system.test.ts` +- Modify: `test/domains/skill/skills.test.ts` +- Modify: `test/domains/skill/uninstall.test.ts` +- Modify: `test/app/uninstall.test.ts` + +**Interfaces:** + +- `copyCometRulesForPlatform()` returns `{ copied, skipped, failed }`. +- `removeFile()` and `removeDir()` return `false` only for `ENOENT`, return `true` only after removal, and throw other filesystem errors. +- Domain removal functions catch errors per managed artifact and add them to `failed`. + +- [ ] **Step 1: Add failing filesystem and Rule tests** + +Mock `fs.unlink`, `fs.rm`, and Rule source/destination operations to prove permission errors are not reported as missing. Add a missing Rule source assertion that expects `failed: 1` rather than only a console message. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +npx vitest run test/platform/file-system.test.ts test/domains/skill/skills.test.ts test/app/uninstall.test.ts -t "permission|missing Rule|removal failure" +``` + +Expected: `removeFile()` masks the mocked error, `removeDir()` reports missing as removed, and Rule copy has no failure field. + +- [ ] **Step 3: Implement strict removal semantics and Rule counters** + +Make `removeFile()`/`removeDir()` inspect `ENOENT` and rethrow every other error. Count missing manifest Rule sources and copy/write exceptions in `failed`. Wrap independent Skill, Rule, Hook-file, and empty-directory removals so one error increments the relevant component counter without deleting unrelated user data. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +npx vitest run test/platform/file-system.test.ts test/domains/skill/skills.test.ts test/domains/skill/uninstall.test.ts test/app/uninstall.test.ts +npx vitest run test/domains/comet-classic/comet-scripts.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add platform/fs/file-system.ts domains/skill/platform-install.ts domains/skill/uninstall.ts assets/skills/comet/scripts/comet-runtime.mjs test/platform/file-system.test.ts test/domains/skill/skills.test.ts test/domains/skill/uninstall.test.ts test/app/uninstall.test.ts +git commit -m "fix(skill): propagate Rule and removal failures" +``` + +--- + +### Task 3: End-to-End Init and Update Failure Propagation + +**Files:** + +- Modify: `app/commands/init.ts` +- Modify: `app/commands/update.ts` +- Modify: `test/app/init-e2e.test.ts` +- Modify: `test/app/update.test.ts` + +**Interfaces:** + +- Init platform results expose one `comet` status derived from all required Comet components. +- Update result adds `skills.totalFailed`, `rules.totalFailed`, and `hooks.totalFailed`, plus per-target failure counts/reasons. +- Registry updates occur only when the target has zero component failures. + +- [ ] **Step 1: Add failing init dependency tests** + +Force Skill copying to fail and assert that Rule/Hook installation does not run, the platform summary reports `Comet failed`, and the project is not registered as a complete target. + +- [ ] **Step 2: Add failing update result tests** + +Cover Skill copy failure, Rule copy failure, and Hook install failure independently. Assert text and JSON outputs say `incomplete`, all-project updates use `status: 'failed'`, and registry targets are not refreshed. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +npx vitest run test/app/init-e2e.test.ts test/app/update.test.ts -t "Skill failure|Rule failure|Hook failure|incomplete" +``` + +- [ ] **Step 4: Implement target-level aggregation** + +After Skill copying, skip dependent Rule/Hook work if `failed > 0`. Otherwise collect Rule `failed` and Hook `status`. Derive init `comet: 'failed'` if any required component failed. Add explicit totals and target details to update results, JSON, text summaries, all-project status, and registry gates. + +- [ ] **Step 5: Verify GREEN** + +Run: + +```bash +npx vitest run test/app/init-e2e.test.ts test/app/update.test.ts test/app/uninstall.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/commands/init.ts app/commands/update.ts test/app/init-e2e.test.ts test/app/update.test.ts +git commit -m "fix(skill): surface incomplete Comet lifecycle updates" +``` + +--- + +### Task 4: Doctor Checks Rule and Hook Completeness + +**Files:** + +- Create: `domains/skill/platform-inspect.ts` +- Modify: `app/commands/doctor.ts` +- Modify: `test/app/doctor.test.ts` +- Create: `test/domains/skill/platform-inspect.test.ts` + +**Interfaces:** + +- Export `getPlatformRuleDestinations(baseDir, platform, scope)` for normalized, language-independent Rule destinations. +- Export `inspectCometHooksForPlatform(baseDir, platform, scope): Promise<{ present: boolean; error?: string }>` from the focused inspection module. +- Doctor emits `rules: ()` and `hooks: ()` checks only when a Comet Skill installation is detected and the platform supports that component. + +- [ ] **Step 1: Add failing Doctor component tests** + +Create complete managed Skill files but omit the Rule and Hook. Expect Rule/Hook warnings. Install both and expect pass. Corrupt shared Hook JSON and expect a Hook warning containing the parse failure. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +npx vitest run test/app/doctor.test.ts -t "Rule|Hook|partial" +``` + +Expected: Doctor currently reports Skill completeness only. + +- [ ] **Step 3: Implement read-only component inspection** + +Reuse manifest Rule destination logic instead of duplicating filenames. Because every language variant normalizes to the same installed filename, do not add a language parameter. Inspect Hook formats with the existing managed-command parser: Claude/Qwen/Gemini groups, Windsurf command arrays, Copilot’s dedicated file, and Kiro’s dedicated Hook file. Inspection must never create or rewrite configuration. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +npx vitest run test/app/doctor.test.ts test/domains/skill/platform-inspect.test.ts test/platform/detect.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add domains/skill/platform-inspect.ts app/commands/doctor.ts test/app/doctor.test.ts test/domains/skill/platform-inspect.test.ts +git commit -m "feat(doctor): detect incomplete Rule and Hook installs" +``` + +--- + +### Task 5: Release Note and Repository Verification + +**Files:** + +- Modify: `CHANGELOG.md` +- Verify: all files changed by Tasks 1–4 + +- [ ] **Step 1: Reconfirm release baseline** + +Run: + +```bash +node -p "require('./package.json').version" +git show origin/master:package.json | rg '"version"' +git describe --tags --abbrev=0 +git log "$(git describe --tags --abbrev=0)..HEAD" --oneline +``` + +Expected: current beta.5, master beta.4, existing beta.5 changelog section. + +- [ ] **Step 2: Add one final-state Fixed bullet** + +Under beta.5 `### Fixed`, add: + +```markdown +- **Skill lifecycle integrity**: Comet now preserves malformed user Hook configuration, reports Skill, Rule, and Hook failures consistently across init, update, Doctor, and uninstall, and avoids registering partial installations as complete. +``` + +- [ ] **Step 3: Run targeted formatting and lifecycle suites** + +```bash +npx prettier --check platform/fs/file-system.ts domains/skill/platform-install.ts domains/skill/platform-inspect.ts domains/skill/uninstall.ts app/commands/init.ts app/commands/update.ts app/commands/doctor.ts test/platform/file-system.test.ts test/domains/skill/skills.test.ts test/domains/skill/platform-inspect.test.ts test/domains/skill/uninstall.test.ts test/app/init-e2e.test.ts test/app/update.test.ts test/app/uninstall.test.ts test/app/doctor.test.ts CHANGELOG.md +npx vitest run test/platform/file-system.test.ts test/domains/skill/skills.test.ts test/domains/skill/platform-inspect.test.ts test/domains/skill/uninstall.test.ts test/app/init-e2e.test.ts test/app/update.test.ts test/app/uninstall.test.ts test/app/doctor.test.ts test/platform/detect.test.ts +``` + +- [ ] **Step 4: Run repository-required verification** + +```bash +pnpm format:check +pnpm lint +pnpm build +npx vitest run +git diff --check +``` + +If repository formatting reports untouched CRLF or unrelated files, prove the changed-file formatter set is clean and do not rewrite unrelated files. + +- [ ] **Step 5: Final lifecycle audit** + +Confirm from current code and tests: + +```text +[ ] Skill, Rule, and Hook required components share truthful failure semantics. +[ ] Shared user configuration is never overwritten after invalid JSON. +[ ] A failed Skill copy cannot leave a newly installed dangling Hook. +[ ] Init/update do not report or register partial targets as complete. +[ ] Canonical uninstall failures block follow-on cleanup. +[ ] Doctor detects missing or malformed Rule/Hook components. +[ ] Bilingual Rule semantics and Classic Hook behavior remain unchanged. +``` + +- [ ] **Step 6: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: document Skill lifecycle integrity fixes" +``` diff --git a/docs/superpowers/specs/2026-07-14-skill-hook-rule-lifecycle-hardening-design.md b/docs/superpowers/specs/2026-07-14-skill-hook-rule-lifecycle-hardening-design.md new file mode 100644 index 00000000..be69a323 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-skill-hook-rule-lifecycle-hardening-design.md @@ -0,0 +1,67 @@ +# Skill, Hook, and Rule Lifecycle Hardening Design + +## Goal + +Make Comet Skill, Hook, and Rule installation behave as one reliable lifecycle: user configuration is never overwritten after a parse failure, every component failure reaches the CLI result, dependent Hook installation never points at missing Skill scripts, uninstall distinguishes “already absent” from a real filesystem failure, and Doctor detects partial installs. + +## Findings + +1. Skill copying returns `copied`, `skipped`, and `failed`, while Rule copying drops failures after logging them. +2. Codex and Qwen-style Hook installers reject malformed shared JSON, but Claude Code, Amazon Q, Gemini, and Windsurf replace malformed content with a new object. This can destroy user configuration. +3. Qwen-style, Gemini, and Windsurf uninstall paths report malformed canonical Hook configuration as successful cleanup. +4. `removeFile()` and `removeDir()` conflate missing paths with I/O failures; `removeDir()` also reports a missing directory as removed. +5. `init` derives Comet status only from Skill copying. `update` drops Skill copy failures and Rule/Hook failures. Both can record an apparently successful installation with missing files or a Hook command that targets a missing runtime script. +6. Doctor verifies managed Skill files but does not verify the Rule or Hook that enforce the documented workflow. +7. The Chinese and English phase Rules and the Classic Hook phase matrix are already covered by behavioral alignment tests. The intended Codex split remains valid: Skills under `.agents/skills`, configuration, Rules, and Hooks under `.codex`. + +## Chosen Approach + +Use contract consolidation without replacing the existing per-format Hook implementations. + +- Rule copy returns the same counter shape as Skill copy: `{ copied, skipped, failed }`. +- Hook install returns an explicit discriminated status: `installed`, `skipped`, or `failed`, with a reason for non-installed outcomes. +- Every Hook format that merges into an existing user-owned JSON file uses one strict object reader. Invalid, non-object, or unreadable JSON fails closed and remains byte-for-byte unchanged. Dedicated Comet-owned Hook files may still be replaced. +- Canonical Hook uninstall parse/read failures count as failures on every platform. Codex historical compatibility files remain best-effort after canonical cleanup, as already designed. +- Filesystem removal returns `false` only for `ENOENT` and throws other errors. Domain removal functions catch per managed artifact, count failures, and continue safe cleanup. +- `init` and `update` aggregate Skill, Rule, and Hook outcomes. If Skill copying fails, Rule and Hook installation for that target is skipped and the target is failed. A failed target is not registered as successfully updated. +- A focused `platform-inspect.ts` module gives Doctor read-only Rule and Hook component checks for each detected Comet Skill installation. Hook inspection validates the managed command, not merely the configuration file’s existence. + +## Data Flow + +```text +manifest + -> Skill copy result + -> failure: target failed; do not install Rule/Hook + -> success: Rule copy result + Hook install result + -> aggregate target status + -> init/update output and registry decision + +Doctor + -> detect canonical/legacy Skill installation + -> verify all managed Skill files + -> verify normalized Rule destination when supported + -> verify managed Hook command when supported +``` + +## Error Handling + +- Missing optional destinations are `skipped` or already absent, not failures. +- Missing manifest-owned source files, permission errors, invalid shared JSON, and unsupported declared Hook formats are failures. +- User-owned malformed JSON is never rewritten. +- Cleanup continues across independent artifacts, but follow-on project state removal and registry cleanup remain blocked when any canonical component fails. +- User-facing logs and JSON output include component failure counts and reasons; “complete” is emitted only when all required components succeeded. + +## Testing + +Use TDD for each behavior: + +1. malformed Claude/Amazon Q/Gemini/Windsurf JSON remains unchanged and Hook install fails; +2. malformed Qwen/Gemini/Windsurf canonical Hook JSON makes uninstall fail; +3. Rule copy and filesystem removal failures are counted; +4. init/update fail the target and avoid Hook installation/registry success after Skill failure; +5. Doctor warns on missing Rule/Hook and passes after a complete lifecycle install; +6. existing cross-platform Hook merge/idempotency, bilingual Rule alignment, Classic runtime, and full repository suites remain green. + +## Release Scope + +The defects also exist on `origin/master`, so the final user-visible behavior belongs under the existing `0.4.0-beta.5` changelog entry. No version bump is needed. diff --git a/domains/bundle/bundle-platform.ts b/domains/bundle/bundle-platform.ts index f655cb41..d6064920 100644 --- a/domains/bundle/bundle-platform.ts +++ b/domains/bundle/bundle-platform.ts @@ -9,6 +9,7 @@ import type { import { copyFile, ensureDir, fileExists, writeFile } from '../../platform/fs/file-system.js'; import { computeRuleDestPath, formatRuleContent } from '../skill/platform-install.js'; import { + getPlatformConfigDir, getPlatformSkillsDir, PLATFORMS, type Platform, @@ -18,6 +19,7 @@ export interface PlatformBundleLayout { platform: string; scope: 'project' | 'global'; baseDir: string; + configRoot: string; skillsRoot: string; rulesRoot: string | null; hooksSupported: boolean; @@ -68,6 +70,7 @@ export function listBundlePlatformTargets(options: { platform: platform.id, scope: options.scope, baseDir, + configRoot: path.join(baseDir, getPlatformConfigDir(platform, options.scope)), skillsRoot: path.join(platformRoot, 'skills'), rulesRoot: rulesRoot(platform, baseDir, options.scope), hooksSupported: capabilities.has('hooks'), @@ -102,7 +105,10 @@ export function planBundleRule( } function hookDestination(target: BundlePlatformTarget, hookId: string): string | null { - const platformRoot = path.dirname(target.layout.skillsRoot); + const platformRoot = target.layout.configRoot; + if (target.platform.hookConfigFile) { + return path.join(platformRoot, target.platform.hookConfigFile); + } switch (target.platform.hookFormat) { case 'claude-code': return path.join(platformRoot, 'settings.local.json'); diff --git a/domains/comet-classic/classic-guard.ts b/domains/comet-classic/classic-guard.ts index 021aba0a..bc68c8a9 100644 --- a/domains/comet-classic/classic-guard.ts +++ b/domains/comet-classic/classic-guard.ts @@ -487,9 +487,12 @@ async function planTasksAllDone(changeDir: string): Promise { async function isolationSelected(changeDir: string, change: string): Promise { const isolation = await readField(changeDir, 'isolation'); + const workflow = await readField(changeDir, 'workflow'); if (isolation === 'branch' || isolation === 'worktree') return pass(); + if (isolation === 'current' && (workflow === 'hotfix' || workflow === 'tweak')) return pass(); + const allowedValues = workflow === 'full' ? '' : ''; return fail( - `isolation must be branch or worktree, got '${isolation || 'null'}'\nNext: ask the user to choose branch or worktree, create the chosen isolation, then run:\n node "$COMET_STATE" set ${change} isolation `, + `isolation must be ${workflow === 'full' ? 'branch or worktree' : 'current, branch, or worktree'}, got '${isolation || 'null'}'\nNext: choose a valid workspace mode, prepare it when needed, then run:\n comet state set ${change} isolation ${allowedValues}`, ); } @@ -498,7 +501,7 @@ async function buildModeSelected(changeDir: string, change: string): Promise`, + `build_mode must be selected before leaving build, got '${buildMode || 'null'}'\nNext: ask the user to choose an execution mode, then run:\n comet state set ${change} build_mode `, ); } @@ -520,7 +523,7 @@ async function subagentDispatchConfirmed(changeDir: string, change: string): Pro if (buildMode !== 'subagent-driven-development') return pass(); if (subagentDispatch === 'confirmed') return pass(); return fail( - `subagent_dispatch must be confirmed before using build_mode=subagent-driven-development\nNext: confirm the current platform has a real background subagent/Task/multi-agent dispatcher, then run:\n node "$COMET_STATE" set ${change} subagent_dispatch confirmed\nOr ask the user to switch to executing-plans and run:\n node "$COMET_STATE" set ${change} build_mode executing-plans`, + `subagent_dispatch must be confirmed before using build_mode=subagent-driven-development\nNext: confirm the current platform has a real background subagent/Task/multi-agent dispatcher, then run:\n comet state set ${change} subagent_dispatch confirmed\nIf dispatch is unavailable, return to /comet-build Step 2 with subagent-driven-development removed. When executing-plans is the only valid mode, run:\n comet state set ${change} build_mode executing-plans`, ); } @@ -530,7 +533,7 @@ async function tddModeSelected(changeDir: string, change: string): Promise`, + `tdd_mode must be tdd or direct for full workflow, got '${tddMode || 'null'}'\nNext: ask the user to choose TDD enforcement level, then run:\n comet state set ${change} tdd_mode `, ); } @@ -542,7 +545,7 @@ async function reviewModeSelected(changeDir: string, change: string): Promise`, + `review_mode must be off, standard, or thorough before leaving build, got '${reviewMode || 'null'}'\nNext: ask the user to choose review strength, then run:\n comet state set ${change} review_mode `, ); } @@ -581,7 +584,7 @@ async function designDocRecorded(changeDir: string, change: string): Promise`, + `design_doc must point to an existing Superpowers Design Doc for full workflow before leaving design.\nNext: create the Design Doc and run: comet state set ${change} design_doc `, ); } @@ -595,24 +598,24 @@ async function designHandoffContextValid(changeDir: string, change: string): Pro } if (!(await nonempty(context))) { return fail( - `handoff_context does not point to a non-empty file: ${context}\nNext: regenerate the design handoff with comet-handoff.mjs.`, + `handoff_context does not point to a non-empty file: ${context}\nNext: regenerate the design handoff with comet handoff ${change} design --write.`, ); } if (!/^[a-f0-9]{64}$/u.test(recordedHash)) { return fail( - `handoff_hash is missing or invalid: ${recordedHash || 'null'}\nNext: regenerate the design handoff with comet-handoff.mjs.`, + `handoff_hash is missing or invalid: ${recordedHash || 'null'}\nNext: regenerate the design handoff with comet handoff ${change} design --write.`, ); } const actualHash = await computeHandoffHash(changeDir); if (actualHash !== recordedHash) { return fail( - `OpenSpec artifacts changed after handoff was generated.\nExpected handoff_hash: ${recordedHash}\nActual handoff_hash: ${actualHash}\nNext: rerun comet-handoff.mjs so Superpowers receives the current OpenSpec context.`, + `OpenSpec artifacts changed after handoff was generated.\nExpected handoff_hash: ${recordedHash}\nActual handoff_hash: ${actualHash}\nNext: run comet handoff ${change} design --write so Superpowers receives the current OpenSpec context.`, ); } const markdown = `${context.replace(/\.json$/u, '')}.md`; if (!(await nonempty(markdown))) { return fail( - `design handoff markdown is missing or empty: ${markdown}\nNext: regenerate the design handoff with comet-handoff.mjs.`, + `design handoff markdown is missing or empty: ${markdown}\nNext: regenerate the design handoff with comet handoff ${change} design --write.`, ); } return pass(); @@ -858,9 +861,6 @@ async function guardVerifyChecks( if (!report || report === 'null' || !(await exists(report))) return pass(); return documentLanguageMatchesConfigured(changeDir, report); }), - check('branch_status=handled', async () => - (await branchStatusHandled(changeDir)) ? pass() : fail(''), - ), ]); } @@ -874,6 +874,9 @@ async function guardArchiveChecks(output: GuardOutput, changeDir: string): Promi (await nonempty(path.join(changeDir, 'design.md'))) ? pass() : fail(''), ), check('tasks.md all tasks checked', () => tasksAllDone(changeDir)), + check('branch_status=handled', async () => + (await branchStatusHandled(changeDir)) ? pass() : fail(''), + ), ]); } diff --git a/domains/comet-classic/classic-hook-guard.ts b/domains/comet-classic/classic-hook-guard.ts index c83c40c3..fb6fd291 100644 --- a/domains/comet-classic/classic-hook-guard.ts +++ b/domains/comet-classic/classic-hook-guard.ts @@ -2,8 +2,7 @@ import { existsSync, promises as fs, readFileSync } from 'fs'; import path from 'path'; import type { ClassicCommandHandler, ClassicCommandResult } from './classic-cli.js'; import { resolveCurrentChange } from './classic-current-change.js'; -import { ensureStrictClassicRuntimeRun } from './classic-runtime-run.js'; -import { readLegacyState } from './classic-store.js'; +import { readClassicState, readLegacyState } from './classic-store.js'; import type { ClassicPhase, ClassicState } from './classic-state.js'; function result(exitCode: number, message: string): ClassicCommandResult { @@ -31,6 +30,11 @@ function normalized(value: string): string { return value.replaceAll('\\', '/').replace(/\/+/gu, '/'); } +function comparisonKey(value: string): string { + const normalizedValue = normalized(value); + return process.platform === 'win32' ? normalizedValue.toLowerCase() : normalizedValue; +} + function parseProjectRoot(args: string[]): string { const index = args.indexOf('--project-root'); const value = index >= 0 ? args[index + 1] : undefined; @@ -96,6 +100,7 @@ interface GoverningChange { classic: ClassicState | null; archived: boolean; superpowersArtifact?: 'matched' | 'unmatched'; + superpowersSlot?: SuperpowersArtifactSlot; } interface GoverningBlock { @@ -106,12 +111,17 @@ type GoverningResolution = GoverningChange | GoverningBlock | null; async function loadGoverningChange(changeDir: string): Promise { try { - const runtime = await ensureStrictClassicRuntimeRun(changeDir); + const projection = await readClassicState(changeDir, { migrate: false }); + const unknownKeys = Array.from(new Set(projection.unknownKeys)).sort(); + if (unknownKeys.length > 0) { + throw new Error(`Invalid Classic state: unknown field(s): ${unknownKeys.join(', ')}`); + } + if (!projection.classic) throw new Error('Classic state projection is unavailable'); return { changeDir, - phase: runtime.classic.phase, - classic: runtime.classic, - archived: runtime.classic.archived, + phase: projection.classic.phase, + classic: projection.classic, + archived: projection.classic.archived, }; } catch { // Legacy/partial state without the required Classic fields: fall back to a @@ -146,7 +156,64 @@ async function activeChanges(projectRoot: string): Promise { } function isSuperpowersArtifactPath(relativePath: string): boolean { - return relativePath.startsWith('docs/superpowers/'); + return comparisonKey(relativePath).startsWith('docs/superpowers/'); +} + +type SuperpowersArtifactField = 'designDoc' | 'plan' | 'verificationReport'; + +interface SuperpowersArtifactSlot { + prefix: string; + field: SuperpowersArtifactField; + wireField: 'design_doc' | 'plan' | 'verification_report'; + phase: 'design' | 'build' | 'verify'; +} + +const SUPERPOWERS_ARTIFACT_SLOTS: readonly SuperpowersArtifactSlot[] = [ + { + prefix: 'docs/superpowers/specs/', + field: 'designDoc', + wireField: 'design_doc', + phase: 'design', + }, + { + prefix: 'docs/superpowers/plans/', + field: 'plan', + wireField: 'plan', + phase: 'build', + }, + { + prefix: 'docs/superpowers/reports/', + field: 'verificationReport', + wireField: 'verification_report', + phase: 'verify', + }, +]; + +function standardSuperpowersArtifactSlot(relativePath: string): SuperpowersArtifactSlot | null { + const key = comparisonKey(relativePath); + const slot = SUPERPOWERS_ARTIFACT_SLOTS.find((candidate) => key.startsWith(candidate.prefix)); + if (!slot) return null; + const fileName = key.slice(slot.prefix.length); + if (!fileName || fileName.includes('/') || !fileName.endsWith('.md')) return null; + return slot; +} + +function superpowersArtifactValue( + governing: GoverningChange, + slot: SuperpowersArtifactSlot, +): string | null { + return governing.classic?.[slot.field] ?? null; +} + +function allowsFirstSuperpowersArtifactWrite( + governing: GoverningChange, + slot: SuperpowersArtifactSlot, +): boolean { + return ( + governing.classic !== null && + governing.phase === slot.phase && + !superpowersArtifactValue(governing, slot) + ); } function allowsSuperpowersArtifacts(governing: GoverningChange): boolean { @@ -182,7 +249,7 @@ function matchesRecordedSuperpowersArtifact( governing.classic?.verificationReport, ]; return artifactPaths.some( - (artifactPath) => artifactPath && normalized(artifactPath) === relativePath, + (artifactPath) => artifactPath && comparisonKey(artifactPath) === comparisonKey(relativePath), ); } @@ -199,12 +266,12 @@ function matchesSuperpowersArtifactName(relativePath: string, changeName: string async function superpowersArtifactGoverningChange( relativePath: string, projectRoot: string, -): Promise { +): Promise<{ governing: GoverningChange; match: 'recorded' | 'named' } | null> { const active = await activeChanges(projectRoot); const recorded = active.find((governing) => matchesRecordedSuperpowersArtifact(relativePath, governing), ); - if (recorded) return recorded; + if (recorded) return { governing: recorded, match: 'recorded' }; const eligible = active.filter(allowsSuperpowersArtifacts); const named = eligible @@ -215,7 +282,7 @@ async function superpowersArtifactGoverningChange( .sort( (a, b) => (governingChangeName(b)?.length ?? 0) - (governingChangeName(a)?.length ?? 0), )[0]; - if (named) return named; + if (named) return { governing: named, match: 'named' }; return null; } @@ -273,7 +340,34 @@ async function governingChange( } if (isSuperpowersArtifactPath(relativePath)) { const superpowers = await superpowersArtifactGoverningChange(relativePath, projectRoot); - if (superpowers) return { ...superpowers, superpowersArtifact: 'matched' }; + if (superpowers?.match === 'recorded') { + return { ...superpowers.governing, superpowersArtifact: 'matched' }; + } + + const slot = standardSuperpowersArtifactSlot(relativePath); + if (superpowers) { + return slot + ? { + ...superpowers.governing, + superpowersArtifact: allowsFirstSuperpowersArtifactWrite(superpowers.governing, slot) + ? 'matched' + : 'unmatched', + superpowersSlot: slot, + } + : { ...superpowers.governing, superpowersArtifact: 'matched' }; + } + if (slot) { + const candidate = await repoSourceGoverningChange(projectRoot, relativePath); + if (!candidate || 'blockedResult' in candidate) return candidate; + return { + ...candidate, + superpowersArtifact: allowsFirstSuperpowersArtifactWrite(candidate, slot) + ? 'matched' + : 'unmatched', + superpowersSlot: slot, + }; + } + const fallback = (await activeChanges(projectRoot))[0] ?? null; return fallback ? { ...fallback, superpowersArtifact: 'unmatched' } : null; } @@ -335,7 +429,7 @@ function blocked(relativePath: string, phase: ClassicPhase): ClassicCommandResul ' BLOCKED: source writes are not allowed during design', ' This phase does not allow source writes', ' ALLOWED: run brainstorming, create the Design Doc, and run guard', - ' NEXT: finish the Design Doc, then run comet-guard design --apply to enter build', + ' NEXT: finish the Design Doc, then run comet guard design --apply to enter build', ] : [ ' BLOCKED: source writes are not allowed during archive', @@ -381,8 +475,33 @@ function blockedMissingDesignDoc(relativePath: string): ClassicCommandResult { function blockedUnmatchedSuperpowersArtifact( relativePath: string, - phase: ClassicPhase, + governing: GoverningChange, ): ClassicCommandResult { + const slot = governing.superpowersSlot; + const recorded = slot ? superpowersArtifactValue(governing, slot) : null; + const details = slot + ? governing.phase !== slot.phase + ? [ + ` BLOCKED: ${slot.wireField} cannot be first-written in phase ${governing.phase}`, + ` Expected phase: ${slot.phase}`, + ' NEXT: resume the matching Comet phase or use an already recorded artifact path', + ] + : recorded + ? [ + ` BLOCKED: ${slot.wireField} is already recorded for this change`, + ` Recorded path: ${recorded}`, + ' NEXT: write the recorded artifact or explicitly correct the state path', + ] + : [ + ' BLOCKED: standard Superpowers artifact state is incomplete', + ' NEXT: validate the active change state, then retry the matching phase', + ] + : [ + ' BLOCKED: unmatched Superpowers artifact', + ' This docs/superpowers/ path does not match any active change artifact', + ' NEXT: use a recorded artifact path or a standard phase artifact directory', + ]; + return result( 2, [ @@ -391,12 +510,10 @@ function blockedUnmatchedSuperpowersArtifact( '║ COMET PHASE GUARD — WRITE BLOCKED ║', '╚══════════════════════════════════════════╝', '', - ` Current phase: ${phase}`, + ` Current phase: ${governing.phase}`, ` Target file: ${relativePath}`, '', - ' BLOCKED: unmatched Superpowers artifact', - ' This docs/superpowers/ path does not match any active change artifact', - ' NEXT: record the artifact path in .comet.yaml or include the change name in the artifact filename', + ...details, '', ].join('\n'), ); @@ -486,7 +603,7 @@ export const classicHookGuardCommand: ClassicCommandHandler = async (args) => { return allowed(`${relativePath} (phase: ${phase}, superpowers)`); } if (governing.superpowersArtifact === 'unmatched') { - return blockedUnmatchedSuperpowersArtifact(relativePath, phase); + return blockedUnmatchedSuperpowersArtifact(relativePath, governing); } } if (phase === 'build' && governing.classic?.workflow === 'full' && !governing.classic.designDoc) { diff --git a/domains/comet-classic/classic-resolver.ts b/domains/comet-classic/classic-resolver.ts index 07a4e046..7996d88e 100644 --- a/domains/comet-classic/classic-resolver.ts +++ b/domains/comet-classic/classic-resolver.ts @@ -27,7 +27,7 @@ function presetBuildConfigured(classic: ClassicState): boolean { return Boolean( classic.buildMode === 'direct' && classic.tddMode === 'direct' && - classic.isolation === 'branch' && + classic.isolation !== null && classic.verifyMode === 'light', ); } diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 15717b4d..073199ad 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -42,6 +42,7 @@ const EVENTS = CLASSIC_TRANSITION_EVENTS; const MACHINE_OWNED_FIELDS = new Set([ ...RUN_WIRE_KEYS, 'archive_confirmation', + 'verify_failures', 'classic_profile', 'classic_migration', ]); @@ -58,7 +59,7 @@ const FIELD_ENUMS: Record = { subagent_dispatch: ['null', 'confirmed'], tdd_mode: ['tdd', 'direct'], review_mode: ['off', 'standard', 'thorough'], - isolation: ['branch', 'worktree'], + isolation: ['current', 'branch', 'worktree'], verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], @@ -82,6 +83,7 @@ const CLASSIC_FIELD_WIRE_NAMES: Partial> = { verifiedAt: 'verified_at', archiveConfirmation: 'archive_confirmation', verifyResult: 'verify_result', + verifyFailures: 'verify_failures', workflow: 'workflow', }; @@ -245,6 +247,15 @@ function nullableRecordBoolean(record: Record, field: string): return null; } +function nonNegativeRecordInteger( + record: Record, + field: string, + fallback = 0, +): number { + const value = record[field]; + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback; +} + function sparseClassicState(record: Record): ClassicState { const workflow = enumRecordValue(record, 'workflow', PROFILES, 'full')!; return { @@ -272,7 +283,12 @@ function sparseClassicState(record: Record): ClassicState { ['off', 'standard', 'thorough'] as const, null, ), - isolation: enumRecordValue(record, 'isolation', ['branch', 'worktree'] as const, null), + isolation: enumRecordValue( + record, + 'isolation', + ['current', 'branch', 'worktree'] as const, + null, + ), verifyMode: enumRecordValue(record, 'verify_mode', ['light', 'full'] as const, null), autoTransition: nullableRecordBoolean(record, 'auto_transition'), baseRef: nullableRecordString(record, 'base_ref'), @@ -284,6 +300,7 @@ function sparseClassicState(record: Record): ClassicState { ['pending', 'pass', 'fail'] as const, 'pending', )!, + verifyFailures: nonNegativeRecordInteger(record, 'verify_failures'), verificationReport: nullableRecordString(record, 'verification_report'), branchStatus: enumRecordValue(record, 'branch_status', ['pending', 'handled'] as const, null), createdAt: nullableRecordString(record, 'created_at'), @@ -491,13 +508,14 @@ async function init(output: CommandOutput, name: string, workflow: string): Prom subagent_dispatch: null, tdd_mode: preset ? 'direct' : null, review_mode: reviewMode, - isolation: preset ? 'branch' : null, + isolation: preset ? 'current' : null, verify_mode: preset ? 'light' : null, auto_transition: (await autoTransition()) === 'true', base_ref: gitOutput(['rev-parse', '--verify', 'HEAD']), design_doc: null, plan: null, verify_result: 'pending', + verify_failures: 0, verification_report: null, branch_status: 'pending', created_at: new Date().toISOString().slice(0, 10), @@ -524,9 +542,11 @@ async function requireBuildDecisions(name: string): Promise { const subagentDispatch = await readField(name, 'subagent_dispatch'); const tddMode = await readField(name, 'tdd_mode'); const reviewMode = await readField(name, 'review_mode'); - if (!['branch', 'worktree'].includes(isolation)) { + const allowedIsolation = + workflow === 'full' ? ['branch', 'worktree'] : ['current', 'branch', 'worktree']; + if (!allowedIsolation.includes(isolation)) { fail( - `ERROR: Cannot transition '${name}': isolation must be branch or worktree, got '${isolation || 'null'}'`, + `ERROR: Cannot transition '${name}': isolation must be ${workflow === 'full' ? 'branch or worktree' : 'current, branch, or worktree'}, got '${isolation || 'null'}'`, ); } if (!['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) { @@ -665,9 +685,6 @@ async function transition(output: CommandOutput, name: string, event: string): P `ERROR: Cannot transition '${name}': verification_report must point to an existing report file`, ); } - if ((await readField(name, 'branch_status')) !== 'handled') { - fail(`ERROR: Cannot transition '${name}': branch_status must be handled`); - } } else if (event === 'verify-fail') { await requirePhase(name, 'verify'); } else if (event === 'archive-confirm') { @@ -1000,7 +1017,7 @@ function resolveBuildRecoveryAction( if (buildMode === 'subagent-driven-development' && (pending > 0 || planPending > 0)) { return subagentDispatch === 'confirmed' ? 'Recovery action: Plan-ready pause is stale because build decisions are already selected. Clear build_pause to null, then inspect the first unchecked task (OpenSpec or plan additions) against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window.' - : 'Recovery action: Plan-ready pause is stale and subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing.'; + : 'Recovery action: Plan-ready pause is stale and subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing.'; } if (pending > 0 || planPending > 0) { return 'Recovery action: Plan-ready pause is stale because build decisions are already selected. Clear build_pause to null, then continue from the first unchecked task.'; @@ -1023,7 +1040,7 @@ function resolveBuildRecoveryAction( if (buildMode === 'subagent-driven-development') { return subagentDispatch === 'confirmed' ? 'Recovery action: Read tasks.md and the Superpowers plan (which may include additions beyond OpenSpec), then inspect the first unchecked task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window.' - : 'Recovery action: Subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing.'; + : 'Recovery action: Subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing.'; } return 'Recovery action: Read tasks.md and continue from first unchecked task.'; } @@ -1031,7 +1048,7 @@ function resolveBuildRecoveryAction( if (buildMode === 'subagent-driven-development') { return subagentDispatch === 'confirmed' ? 'Recovery action: Read the Superpowers plan, then inspect the first unchecked Superpowers plan task against recent git history/diff. If implemented, check it off; otherwise dispatch a real background subagent. Do not execute the pending task directly in the main window.' - : 'Recovery action: Subagent dispatch is not confirmed. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or set build_mode to executing-plans before continuing.'; + : 'Recovery action: Subagent dispatch is not confirmed. Return to /comet-build Step 2 capability preflight. Confirm a real background subagent/Task/multi-agent dispatcher and set subagent_dispatch to confirmed, or remove the unavailable mode and set build_mode to executing-plans before continuing.'; } return 'Recovery action: Read the Superpowers plan and continue from the first unchecked plan task.'; } @@ -1040,18 +1057,22 @@ function resolveBuildRecoveryAction( async function recoverVerify(output: CommandOutput, name: string): Promise { const result = await readField(name, 'verify_result'); + const failures = await readField(name, 'verify_failures'); const mode = await readField(name, 'verify_mode'); const report = await readField(name, 'verification_report'); const branch = await readField(name, 'branch_status'); output.stdout.push( ' Verification:', fieldStatus('verify_result', result), + ` - verify_failures: ${failures || '0'}`, fieldStatus('verify_mode', mode), fieldStatus('verification_report', report, report), - fieldStatus('branch_status', branch), + branch === 'handled' + ? ' - branch_status: LEGACY (handled before archive; archive still owns final closure)' + : ' - branch_status: DEFERRED (handled after the archive commit)', '', - result === 'pass' && branch === 'handled' - ? 'Recovery action: Verification complete. Run guard to transition to archive.' + result === 'pass' + ? 'Recovery action: Verification complete. Continue to archive; branch handling happens after archive changes are committed.' : result === 'fail' ? 'Recovery action: Verification failed and rolled back to build. Resume from /comet-build.' : 'Recovery action: Verification not yet started or in progress. Run scale assessment then verify.', diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index c4eb546f..cad6b70f 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -12,7 +12,7 @@ const BUILD_PAUSES = ['plan-ready'] as const; const SUBAGENT_DISPATCH = ['confirmed'] as const; const TDD_MODES = ['tdd', 'direct'] as const; const REVIEW_MODES = ['off', 'standard', 'thorough'] as const; -const ISOLATIONS = ['branch', 'worktree'] as const; +const ISOLATIONS = ['current', 'branch', 'worktree'] as const; const VERIFY_MODES = ['light', 'full'] as const; const VERIFY_RESULTS = ['pending', 'pass', 'fail'] as const; const BRANCH_STATUSES = ['pending', 'handled'] as const; @@ -39,6 +39,7 @@ export interface ClassicState { designDoc: string | null; plan: string | null; verifyResult: (typeof VERIFY_RESULTS)[number]; + verifyFailures: number; verificationReport: string | null; branchStatus: (typeof BRANCH_STATUSES)[number] | null; createdAt: string | null; @@ -75,6 +76,7 @@ export const CLASSIC_WIRE_KEYS = [ 'design_doc', 'plan', 'verify_result', + 'verify_failures', 'verification_report', 'branch_status', 'created_at', @@ -153,6 +155,15 @@ function booleanValue(doc: StateDocument, key: string, nullable = true): boolean return value; } +function nonNegativeInteger(doc: StateDocument, key: string, fallback = 0): number { + const value = doc[key]; + if (value === null || value === undefined || value === '') return fallback; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`Invalid Classic state: ${key} must be a non-negative integer`); + } + return value; +} + function relativePath(doc: StateDocument, key: string): string | null { const value = nullableString(doc, key); if (value === null) return null; @@ -206,6 +217,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { designDoc: relativePath(doc, 'design_doc'), plan: relativePath(doc, 'plan'), verifyResult: enumValue(doc, 'verify_result', VERIFY_RESULTS, false)!, + verifyFailures: nonNegativeInteger(doc, 'verify_failures'), verificationReport: relativePath(doc, 'verification_report'), branchStatus: enumValue(doc, 'branch_status', BRANCH_STATUSES), createdAt: nullableString(doc, 'created_at'), @@ -294,6 +306,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { design_doc: state.designDoc, plan: state.plan, verify_result: state.verifyResult, + verify_failures: state.verifyFailures, verification_report: state.verificationReport, branch_status: state.branchStatus, created_at: state.createdAt, diff --git a/domains/comet-classic/classic-transitions.ts b/domains/comet-classic/classic-transitions.ts index bb2e7053..33738bbe 100644 --- a/domains/comet-classic/classic-transitions.ts +++ b/domains/comet-classic/classic-transitions.ts @@ -52,7 +52,7 @@ export const CLASSIC_TRANSITION_TABLE: Record = { subagent_dispatch: ['confirmed'], tdd_mode: ['tdd', 'direct'], review_mode: ['off', 'standard', 'thorough'], - isolation: ['branch', 'worktree'], + isolation: ['current', 'branch', 'worktree'], verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], @@ -131,6 +131,12 @@ export const classicValidateCommand: ClassicCommandHandler = async (args) => { fail(`${field}='${value}' is not valid. Expected: ${values.join(' ')}`); } } + if (Object.prototype.hasOwnProperty.call(record, 'verify_failures')) { + const value = record.verify_failures; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + fail(`verify_failures='${text(value)}' is not a non-negative integer`); + } + } for (const field of ['design_doc', 'plan', 'handoff_context'] as const) { const value = text(record[field]); if (value && !(await exists(path.resolve(value)))) { diff --git a/domains/factory/package.ts b/domains/factory/package.ts index 4de917da..de14d8aa 100644 --- a/domains/factory/package.ts +++ b/domains/factory/package.ts @@ -15,8 +15,33 @@ import { } from './artifacts.js'; import type { WorkflowNodeProtocol, WorkflowProtocol } from '../workflow-contract/index.js'; +function compactDescription(value: string, maxLength = 240): string { + const normalized = value + .replace(/\s+/gu, ' ') + .trim() + .replace(/[.!?。!?]+$/u, ''); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + function factoryEntryDescription(plan: FactorySkillPackagePlan): string { - return plan.description || `Use when running the generated ${plan.name} workflow.`; + const purpose = compactDescription( + plan.description || plan.goal || `run the generated ${plan.name} workflow`, + ); + return `Use when the user wants the ${plan.name} managed workflow for ${purpose}, explicitly invokes /${plan.name}, or persisted workflow state identifies one unambiguous active run. Route through this entry Skill; do not invoke its internal Node Skills directly.`; +} + +function factoryNodeDescription( + plan: FactorySkillPackagePlan, + protocol: WorkflowProtocol, + node: WorkflowNodeProtocol, + skillName: string, +): string { + return `Use only when explicitly invoked as /${skillName} or routed by the ${plan.name} entry/runtime to the ${node.id} Node; complete ${node.label} for ${protocol.name}. Do not use for ordinary standalone tasks or as the workflow entry.`; +} + +function yamlString(value: string): string { + return JSON.stringify(value); } function runtimeEvals(): Record { @@ -213,7 +238,7 @@ function workflowContractEntryMarkdown( 3. Run \`node ${plan.name}/scripts/workflow-state.mjs next\` and load **only** the returned Skill. Do not load multiple Skills at once.`; return `--- name: ${plan.name} -description: ${factoryEntryDescription(plan)} +description: ${yamlString(factoryEntryDescription(plan))} --- # ${protocol.name} @@ -333,7 +358,7 @@ function workflowContractNodeMarkdown( .join('\n'); return `--- name: ${skillName} -description: Run the ${node.label} Node for ${protocol.name}. +description: ${yamlString(factoryNodeDescription(plan, protocol, node, skillName))} --- # ${node.label} @@ -1520,12 +1545,7 @@ function workflowContractArtifacts(plan: FactorySkillPackagePlan): FactoryPackag 'reference/decision-points.md', 'reference', plan.contentDrafts?.['reference/decision-points.md'] ?? - `# Workflow Decision Points\n\n${workflowContractRoute(protocol) - .map( - (node) => - `- \`${node.id}\`: confirm Output Schemas ${node.outputSchemas.join(', ') || 'none'}.`, - ) - .join('\n')}\n`, + `# Workflow Decision Classification\n\nNo decision points have been authored. Do not infer that every Node or Output Schema requires confirmation. Classify each candidate as a genuine user decision, automatic handling, a stop condition, or a manual handoff; pause only when at least two valid choices change scope, behavior, accepted risk, or an irreversible outcome.\n`, ), artifact( 'reference/recovery.md', diff --git a/domains/integrations/openspec.ts b/domains/integrations/openspec.ts index 3c5d0fe5..910f55d0 100644 --- a/domains/integrations/openspec.ts +++ b/domains/integrations/openspec.ts @@ -9,6 +9,7 @@ import { quoteArgsForShell } from '../../platform/process/shell-quote.js'; import type { InstallScope } from '../../platform/install/types.js'; const VALID_TOOL_IDS = new Set(PLATFORMS.map((p) => p.openspecToolId)); +const MINIMUM_OPENSPEC_VERSION = '1.5.0'; const ALL_OPENSPEC_WORKFLOWS = [ 'propose', 'explore', @@ -156,13 +157,63 @@ function isCommandAvailable(command: string): boolean { } } +interface SemanticVersion { + major: number; + minor: number; + patch: number; + prerelease: string | null; +} + +function parseSemanticVersion(value: string): SemanticVersion | null { + const match = value.match(/(?:^|[^0-9])v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/u); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; +} + +function isOpenSpecVersionCompatible(versionOutput: string): boolean { + const actual = parseSemanticVersion(versionOutput); + const minimum = parseSemanticVersion(MINIMUM_OPENSPEC_VERSION); + if (!actual || !minimum) return false; + for (const field of ['major', 'minor', 'patch'] as const) { + if (actual[field] > minimum[field]) return true; + if (actual[field] < minimum[field]) return false; + } + return actual.prerelease === null; +} + +function getOpenSpecVersion(): string | null { + try { + return execFileSync('openspec', ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + shell: process.platform === 'win32', + }) + .toString() + .trim(); + } catch { + return null; + } +} + async function ensureOpenSpecCli( projectPath: string, shouldInstall = true, ): Promise<'ready' | 'missing' | 'failed'> { const alreadyInstalled = isCommandAvailable('openspec'); if (!shouldInstall) { - return alreadyInstalled ? 'ready' : 'missing'; + if (!alreadyInstalled) return 'missing'; + const version = getOpenSpecVersion(); + if (version && isOpenSpecVersionCompatible(version)) return 'ready'; + console.error( + ` OpenSpec ${version || 'version unknown'} is incompatible; Comet requires >= ${MINIMUM_OPENSPEC_VERSION}.`, + ); + return 'failed'; } const label = alreadyInstalled ? 'Upgrading' : 'Installing'; console.warn(` ${label} OpenSpec CLI...`); @@ -179,10 +230,18 @@ async function ensureOpenSpecCli( return isCommandAvailable('openspec') ? 'ready' : 'failed'; } catch (error) { if (alreadyInstalled) { - console.warn( - ` OpenSpec upgrade failed, using existing version: ${(error as Error).message}`, + const version = getOpenSpecVersion(); + if (version && isOpenSpecVersionCompatible(version)) { + console.warn( + ` OpenSpec upgrade failed, using compatible existing version ${version}: ${(error as Error).message}`, + ); + return 'ready'; + } + console.error( + ` OpenSpec upgrade failed and existing ${version || 'version could not be read'} is incompatible; Comet requires >= ${MINIMUM_OPENSPEC_VERSION}.`, ); - return 'ready'; + printCommandErrorDetails(error); + return 'failed'; } console.error(` Failed to install OpenSpec CLI: ${(error as Error).message}`); printCommandErrorDetails(error); @@ -406,8 +465,11 @@ async function installOpenSpec( } export { + MINIMUM_OPENSPEC_VERSION, installOpenSpec, isCommandAvailable, + isOpenSpecVersionCompatible, + getOpenSpecVersion, buildOpenSpecInitInvocation, getNpmExecutable, migrateOpenCodeOpenSpecPaths, diff --git a/domains/skill/json-object.ts b/domains/skill/json-object.ts new file mode 100644 index 00000000..2a84f435 --- /dev/null +++ b/domains/skill/json-object.ts @@ -0,0 +1,27 @@ +import { readFile } from 'fs/promises'; + +export type JsonObjectReadResult = + | { status: 'missing' } + | { status: 'error'; kind: 'read' | 'invalid'; error: Error } + | { status: 'present'; value: Record }; + +/** Read a JSON object without ever creating or rewriting the source file. */ +export async function readJsonObjectFile(filePath: string): Promise { + let source: string; + try { + source = await readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }; + return { status: 'error', kind: 'read', error: error as Error }; + } + + try { + const parsed = JSON.parse(source) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected a JSON object'); + } + return { status: 'present', value: parsed as Record }; + } catch (error) { + return { status: 'error', kind: 'invalid', error: error as Error }; + } +} diff --git a/domains/skill/platform-inspect.ts b/domains/skill/platform-inspect.ts new file mode 100644 index 00000000..e87829f1 --- /dev/null +++ b/domains/skill/platform-inspect.ts @@ -0,0 +1,206 @@ +import path from 'path'; + +import { + getPlatformConfigDir, + getPlatformSkillsDir, + type Platform, +} from '../../platform/install/platforms.js'; +import type { InstallScope } from '../../platform/install/types.js'; +import { fileExists } from '../../platform/fs/file-system.js'; +import { buildHookCommand, computeRuleDestPath, readManifest } from './platform-install.js'; +import { readJsonObjectFile } from './json-object.js'; + +export interface HookInspectionResult { + present: boolean; + error?: string; +} + +type JsonReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'present'; value: Record }; + +function getRulesBaseDir(baseDir: string, platform: Platform, scope: InstallScope): string { + if (platform.rulesBaseDir === '') return baseDir; + if (platform.rulesBaseDir !== undefined) { + return path.join(baseDir, platform.rulesBaseDir); + } + return path.join(baseDir, getPlatformSkillsDir(platform, scope)); +} + +export async function getPlatformRuleDestinations( + baseDir: string, + platform: Platform, + scope: InstallScope, +): Promise { + if (!platform.rulesDir || !platform.rulesFormat) return []; + + const manifest = await readManifest(); + const rulesDestDir = path.join(getRulesBaseDir(baseDir, platform, scope), platform.rulesDir); + const destinations = new Set(); + + for (const ruleRelPath of manifest.rules ?? []) { + const installedName = path.basename(ruleRelPath).replace(/\.en\.md$/u, '.md'); + destinations.add(computeRuleDestPath(rulesDestDir, installedName, platform.rulesFormat)); + } + + return [...destinations]; +} + +async function readHookJson(filePath: string): Promise { + const result = await readJsonObjectFile(filePath); + if (result.status !== 'error') return result; + return { + status: 'error', + error: `${result.kind === 'invalid' ? 'Invalid' : 'Unable to read'} Hook JSON at ${filePath}: ${result.error.message}`, + }; +} + +function collectGroupedCommands(config: Record, groupName: string): unknown[] { + const hooks = config.hooks; + if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return []; + const groups = (hooks as Record)[groupName]; + if (!Array.isArray(groups)) return []; + + return groups.flatMap((group) => { + if (!group || typeof group !== 'object' || Array.isArray(group)) return []; + const handlers = (group as Record).hooks; + if (!Array.isArray(handlers)) return []; + return handlers.map((handler) => + handler && typeof handler === 'object' && !Array.isArray(handler) + ? (handler as Record).command + : undefined, + ); + }); +} + +function collectCommandArray(config: Record, groupName: string): unknown[] { + const hooks = config.hooks; + if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return []; + const entries = (hooks as Record)[groupName]; + if (!Array.isArray(entries)) return []; + + return entries.flatMap((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; + const record = entry as Record; + return [record.command, record.bash, record.powershell]; + }); +} + +function containsAllManagedCommands(commands: unknown[], expectedCommands: string[]): boolean { + return expectedCommands.every((expected) => commands.some((command) => command === expected)); +} + +async function inspectSingleHookJson( + configPath: string, + expectedCommands: string[], + collectCommands: (config: Record) => unknown[], +): Promise { + const result = await readHookJson(configPath); + if (result.status === 'missing') return { present: false }; + if (result.status === 'error') return { present: false, error: result.error }; + return { + present: containsAllManagedCommands(collectCommands(result.value), expectedCommands), + }; +} + +async function inspectKiroHooks( + platformBase: string, + scriptRelPaths: string[], + expectedCommands: string[], +): Promise { + for (const [index, scriptRelPath] of scriptRelPaths.entries()) { + const fileName = path.basename(scriptRelPath).replace(/\.mjs$/u, '.kiro.hook'); + const configPath = path.join(platformBase, 'hooks', fileName); + const result = await readHookJson(configPath); + if (result.status === 'missing') return { present: false }; + if (result.status === 'error') return { present: false, error: result.error }; + + const then = result.value.then; + const command = + then && typeof then === 'object' && !Array.isArray(then) + ? (then as Record).command + : undefined; + if (command !== expectedCommands[index]) return { present: false }; + } + + return { present: scriptRelPaths.length > 0 }; +} + +export async function inspectCometHooksForPlatform( + baseDir: string, + platform: Platform, + scope: InstallScope, +): Promise { + if (!platform.supportsHooks || !platform.hookFormat) return { present: false }; + + const manifest = await readManifest(); + const scriptRelPaths = Object.keys(manifest.hooks ?? {}); + if (scriptRelPaths.length === 0) return { present: false }; + + const skillsDir = getPlatformSkillsDir(platform, scope); + const expectedCommands = scriptRelPaths.map((scriptRelPath) => + buildHookCommand(baseDir, skillsDir, scriptRelPath), + ); + + const platformBase = path.join(baseDir, getPlatformConfigDir(platform, scope)); + let inspection: HookInspectionResult; + switch (platform.hookFormat) { + case 'claude-code': + inspection = await inspectSingleHookJson( + path.join(platformBase, platform.hookConfigFile ?? 'settings.local.json'), + expectedCommands, + (config) => collectGroupedCommands(config, 'PreToolUse'), + ); + break; + case 'qwen': + case 'qoder': + case 'codebuddy': + inspection = await inspectSingleHookJson( + path.join(platformBase, 'settings.json'), + expectedCommands, + (config) => collectGroupedCommands(config, 'PreToolUse'), + ); + break; + case 'gemini': + inspection = await inspectSingleHookJson( + path.join(platformBase, 'settings.json'), + expectedCommands, + (config) => collectGroupedCommands(config, 'BeforeTool'), + ); + break; + case 'windsurf': + inspection = await inspectSingleHookJson( + path.join(platformBase, 'hooks.json'), + expectedCommands, + (config) => collectCommandArray(config, 'pre_write_code'), + ); + break; + case 'copilot': + inspection = await inspectSingleHookJson( + path.join(platformBase, 'hooks', 'comet-guard.json'), + expectedCommands, + (config) => collectCommandArray(config, 'preToolUse'), + ); + break; + case 'kiro': + inspection = await inspectKiroHooks(platformBase, scriptRelPaths, expectedCommands); + break; + } + + if (!inspection.present) return inspection; + for (const scriptRelPath of scriptRelPaths) { + const scriptPath = path.join(baseDir, skillsDir, 'skills', ...scriptRelPath.split('/')); + try { + if (!(await fileExists(scriptPath))) { + return { present: false, error: `managed Hook script missing at ${scriptPath}` }; + } + } catch (error) { + return { + present: false, + error: `Unable to inspect managed Hook script at ${scriptPath}: ${(error as Error).message}`, + }; + } + } + return inspection; +} diff --git a/domains/skill/platform-install.ts b/domains/skill/platform-install.ts index 79252ba3..b1c5c25c 100644 --- a/domains/skill/platform-install.ts +++ b/domains/skill/platform-install.ts @@ -14,6 +14,7 @@ import type { InstallScope, InstallMode } from '../../platform/install/types.js' import { formatSupportedArtifactLanguages, resolveArtifactLanguage } from './languages.js'; import type { LanguageConfig, SkillLanguageId } from './languages.js'; import { installCometProjectInstructions } from './project-instructions.js'; +import { readJsonObjectFile } from './json-object.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -32,6 +33,13 @@ type Manifest = { languages?: LanguageConfig[]; }; +type HookInstallStatus = 'installed' | 'skipped' | 'failed'; + +export interface HookInstallResult { + status: HookInstallStatus; + reason?: string; +} + interface PlannedSkillSourceFile { relativePath: string; source: string; @@ -283,12 +291,11 @@ async function installSkillsAsSymlink( const src = path.join(assetsDir, sourceDir, skillRelPath); const centralDest = path.join(centralDir, 'skills', skillRelPath); - if (!overwrite && (await fileExists(centralDest))) { - skippedCount++; - continue; - } - try { + if (!overwrite && (await fileExists(centralDest))) { + skippedCount++; + continue; + } await copyFile(src, centralDest); copied++; } catch (err) { @@ -329,6 +336,7 @@ async function installSkillsAsSymlink( ); copied += result.copied; skippedCount += result.skipped; + failedCount += result.failed; } // Handle Pi platform command extension @@ -342,6 +350,7 @@ async function installSkillsAsSymlink( ); copied += result.copied; skippedCount += result.skipped; + failedCount += result.failed; } return { copied, skipped: skippedCount, failed: failedCount }; @@ -381,12 +390,11 @@ async function copyCometSkillsForPlatform( const src = path.join(assetsDir, sourceDir, skillRelPath); const dest = path.join(baseDir, getPlatformSkillsDir(platform, scope), 'skills', skillRelPath); - if (!overwrite && (await fileExists(dest))) { - skippedCount++; - continue; - } - try { + if (!overwrite && (await fileExists(dest))) { + skippedCount++; + continue; + } await copyFile(src, dest); copied++; } catch (err) { @@ -410,6 +418,7 @@ async function copyCometSkillsForPlatform( ); copied += result.copied; skippedCount += result.skipped; + failedCount += result.failed; } if (platform.id === 'pi') { @@ -422,6 +431,7 @@ async function copyCometSkillsForPlatform( ); copied += result.copied; skippedCount += result.skipped; + failedCount += result.failed; } return { copied, skipped: skippedCount, failed: failedCount }; @@ -458,50 +468,58 @@ async function createPiCommandExtension( skillPaths: string[], overwrite: boolean, scope: InstallScope, -): Promise<{ copied: number; skipped: number }> { +): Promise<{ copied: number; skipped: number; failed: number }> { const platformBase = path.join(baseDir, getPlatformSkillsDir(platform, scope)); const settingsPath = path.join(platformBase, 'settings.json'); const extensionPath = path.join(platformBase, 'extensions', PI_COMMAND_EXTENSION_FILE); - let settings: Record = {}; - if (await fileExists(settingsPath)) { - try { + let copied = 0; + let skipped = 0; + let failed = 0; + + try { + let settings: Record = {}; + if (await fileExists(settingsPath)) { const parsed = JSON.parse(await readFile(settingsPath, 'utf-8')) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('expected a JSON object'); } settings = parsed as Record; - } catch (err) { - throw new Error(`Invalid Pi settings at ${settingsPath}: ${(err as Error).message}`, { - cause: err, - }); } - } - - let copied = 0; - let skipped = 0; - if (settings.enableSkillCommands !== true) { - settings.enableSkillCommands = true; - await ensureDir(path.dirname(settingsPath)); - await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - copied++; + if (settings.enableSkillCommands !== true) { + settings.enableSkillCommands = true; + await ensureDir(path.dirname(settingsPath)); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + copied++; + } + } catch (err) { + failed++; + console.error(` Failed to update Pi settings at ${settingsPath}: ${(err as Error).message}`); } - if (!overwrite && (await fileExists(extensionPath))) { - skipped++; - return { copied, skipped }; - } + if (failed > 0) return { copied, skipped, failed }; - await ensureDir(path.dirname(extensionPath)); - await writeFile( - extensionPath, - renderPiCommandExtension(getTopLevelSkillNames(skillPaths)), - 'utf-8', - ); - copied++; + try { + if (!overwrite && (await fileExists(extensionPath))) { + skipped++; + } else { + await ensureDir(path.dirname(extensionPath)); + await writeFile( + extensionPath, + renderPiCommandExtension(getTopLevelSkillNames(skillPaths)), + 'utf-8', + ); + copied++; + } + } catch (err) { + failed++; + console.error( + ` Failed to write Pi command extension at ${extensionPath}: ${(err as Error).message}`, + ); + } - return { copied, skipped }; + return { copied, skipped, failed }; } function stripFrontmatter(content: string): string { @@ -523,9 +541,10 @@ async function createOpenCodeCommands( overwrite: boolean, scope: InstallScope, languageSkillsDir: string, -): Promise<{ copied: number; skipped: number }> { +): Promise<{ copied: number; skipped: number; failed: number }> { let copied = 0; let skipped = 0; + let failed = 0; const assetsDir = getAssetsDir(); const commandsDir = path.join(baseDir, getPlatformSkillsDir(platform, scope), 'commands'); @@ -536,18 +555,19 @@ async function createOpenCodeCommands( const skillName = parts[0]; const dest = path.join(commandsDir, `${skillName}.md`); - if (!overwrite && (await fileExists(dest))) { - skipped++; - continue; - } + try { + if (!overwrite && (await fileExists(dest))) { + skipped++; + continue; + } - await ensureDir(path.dirname(dest)); - let skillSourcePath = path.join(assetsDir, languageSkillsDir, skillPath); - if (!(await fileExists(skillSourcePath))) { - skillSourcePath = path.join(assetsDir, 'skills', skillPath); - } - const skillBody = stripFrontmatter(await readFile(skillSourcePath, 'utf-8')); - const content = `${OPENCODE_COMMAND_HEADER.replace('{skillName}', skillName)} + await ensureDir(path.dirname(dest)); + let skillSourcePath = path.join(assetsDir, languageSkillsDir, skillPath); + if (!(await fileExists(skillSourcePath))) { + skillSourcePath = path.join(assetsDir, 'skills', skillPath); + } + const skillBody = stripFrontmatter(await readFile(skillSourcePath, 'utf-8')); + const content = `${OPENCODE_COMMAND_HEADER.replace('{skillName}', skillName)} Equivalent Comet skill: \`${skillName}\` Command name: \`/${skillName}\` @@ -559,11 +579,15 @@ $ARGUMENTS ${skillBody} `; - await writeFile(dest, content, 'utf-8'); - copied++; + await writeFile(dest, content, 'utf-8'); + copied++; + } catch (err) { + failed++; + console.error(` Failed to create OpenCode command ${dest}: ${(err as Error).message}`); + } } - return { copied, skipped }; + return { copied, skipped, failed }; } async function readManifest(): Promise { @@ -624,15 +648,15 @@ async function copyCometRulesForPlatform( overwrite: boolean, languageId: SkillLanguageId, scope: InstallScope = 'project', -): Promise<{ copied: number; skipped: number }> { +): Promise<{ copied: number; skipped: number; failed: number }> { if (!platform.rulesDir || !platform.rulesFormat) { - return { copied: 0, skipped: 0 }; + return { copied: 0, skipped: 0, failed: 0 }; } const manifest = await readManifest(); const rulePaths = selectRulePathsForLanguage(manifest.rules ?? [], languageId); if (!rulePaths || rulePaths.length === 0) { - return { copied: 0, skipped: 0 }; + return { copied: 0, skipped: 0, failed: 0 }; } const assetsDir = getAssetsDir(); @@ -646,26 +670,28 @@ async function copyCometRulesForPlatform( : path.join(baseDir, getPlatformSkillsDir(platform, scope)); let copied = 0; let skippedCount = 0; + let failed = 0; for (const ruleRelPath of rulePaths) { const src = path.join(assetsDir, 'skills', ruleRelPath); - if (!(await fileExists(src))) { - console.error(` Rule source not found: ${ruleRelPath}`); - continue; - } + try { + if (!(await fileExists(src))) { + console.error(` Rule source not found: ${ruleRelPath}`); + failed++; + continue; + } - // Normalize the `.en` infix away so the installed file name is the same - // regardless of which language variant was selected. - const ruleFileName = toRuleBaseName(path.basename(ruleRelPath)); - const rulesDestDir = path.join(rulesBase, platform.rulesDir); - const dest = computeRuleDestPath(rulesDestDir, ruleFileName, platform.rulesFormat); + // Normalize the `.en` infix away so the installed file name is the same + // regardless of which language variant was selected. + const ruleFileName = toRuleBaseName(path.basename(ruleRelPath)); + const rulesDestDir = path.join(rulesBase, platform.rulesDir); + const dest = computeRuleDestPath(rulesDestDir, ruleFileName, platform.rulesFormat); - if (!overwrite && (await fileExists(dest))) { - skippedCount++; - continue; - } + if (!overwrite && (await fileExists(dest))) { + skippedCount++; + continue; + } - try { const content = await readFile(src, 'utf-8'); await ensureDir(path.dirname(dest)); const formatted = formatRuleContent(content, ruleFileName, platform.rulesFormat); @@ -673,10 +699,11 @@ async function copyCometRulesForPlatform( copied++; } catch (err) { console.error(` Failed to copy rule ${ruleRelPath}: ${(err as Error).message}`); + failed++; } } - return { copied, skipped: skippedCount }; + return { copied, skipped: skippedCount, failed }; } function computeRuleDestPath( @@ -720,7 +747,8 @@ ${content}`; /** * Install Comet hooks for platforms that support them. * Supports multiple hook formats: - * 'claude-code' — settings.local.json with PreToolUse array (Claude Code, Codex, Amazon Q) + * 'claude-code' — Claude-shaped JSON with PreToolUse array; defaults to settings.local.json, + * with platform metadata able to override the filename * 'qwen' — settings.json with PreToolUse/hooks array (Qwen Code) * 'qoder' — settings.json with PreToolUse/hooks array (Qoder) * 'codebuddy' — settings.json with PreToolUse/hooks array (CodeBuddy Code) @@ -733,25 +761,52 @@ async function installCometHooksForPlatform( baseDir: string, platform: Platform, scope: InstallScope = 'project', -): Promise<{ installed: boolean; reason?: string }> { - if (!platform.supportsHooks || !platform.hookFormat) { - return { installed: false, reason: 'platform does not support hooks' }; +): Promise { + if (!platform.supportsHooks) { + return { status: 'skipped', reason: 'platform does not support hooks' }; } - - const manifest = await readManifest(); - const hooksConfig = manifest.hooks; - if (!hooksConfig || Object.keys(hooksConfig).length === 0) { - return { installed: false, reason: 'no hooks defined in manifest' }; + if (!platform.hookFormat) { + return { + status: 'failed', + reason: 'hook-capable platform does not declare a hook format', + }; } - const hookFormat = platform.hookFormat; - const skillsDir = getPlatformSkillsDir(platform, scope); - const platformBase = path.join(baseDir, getPlatformConfigDir(platform, scope)); - try { + const manifest = await readManifest(); + const hooksConfig = manifest.hooks; + if (!hooksConfig || Object.keys(hooksConfig).length === 0) { + return { status: 'skipped', reason: 'no hooks defined in manifest' }; + } + + const hookFormat = platform.hookFormat; + const skillsDir = getPlatformSkillsDir(platform, scope); + const platformBase = path.join(baseDir, getPlatformConfigDir(platform, scope)); + switch (hookFormat) { - case 'claude-code': - return await installClaudeCodeHooks(baseDir, platformBase, skillsDir, hooksConfig); + case 'claude-code': { + const result = await installClaudeCodeHooks( + baseDir, + platformBase, + skillsDir, + hooksConfig, + platform.hookConfigFile ?? 'settings.local.json', + platform.name, + ); + if (result.status === 'installed') { + for (const legacyFile of platform.legacyHookConfigFiles ?? []) { + try { + await removeManagedHooksFromJsonFile( + path.join(platformBase, legacyFile), + Object.keys(hooksConfig), + ); + } catch { + // Historical Hook cleanup is best-effort after canonical install succeeds. + } + } + } + return result; + } case 'qwen': case 'qoder': case 'codebuddy': @@ -760,21 +815,33 @@ async function installCometHooksForPlatform( platformBase, skillsDir, hooksConfig, - hookFormat, + platform.name, ); case 'gemini': - return await installGeminiHooks(baseDir, platformBase, skillsDir, hooksConfig); + return await installGeminiHooks( + baseDir, + platformBase, + skillsDir, + hooksConfig, + platform.name, + ); case 'windsurf': - return await installWindsurfHooks(baseDir, platformBase, skillsDir, hooksConfig); + return await installWindsurfHooks( + baseDir, + platformBase, + skillsDir, + hooksConfig, + platform.name, + ); case 'copilot': return await installCopilotHooks(baseDir, platformBase, skillsDir, hooksConfig); case 'kiro': return await installKiroHooks(baseDir, platformBase, skillsDir, hooksConfig); default: - return { installed: false, reason: `unsupported hook format: ${hookFormat}` }; + return { status: 'failed', reason: `unsupported hook format: ${hookFormat}` }; } } catch (err) { - return { installed: false, reason: (err as Error).message }; + return { status: 'failed', reason: (err as Error).message }; } } @@ -789,17 +856,65 @@ function buildHookCommand(baseDir: string, skillsDir: string, scriptRelPath: str return `node ${quoteCommandArg(scriptPath)} --project-root ${quoteCommandArg(projectRoot)}`; } +function parseCommandTokens(command: string): string[] | undefined { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + let tokenStarted = false; + let quoteClosed = false; + + for (let index = 0; index < command.length; index++) { + const character = command[index]; + if (quote) { + if (character === quote) { + quote = undefined; + quoteClosed = true; + } else if (character === '\\' && command[index + 1] === quote) { + current += quote; + index++; + } else { + current += character; + } + continue; + } + + if (character === '\r' || character === '\n' || ';|&<>`'.includes(character)) { + return undefined; + } + if (/\s/u.test(character)) { + if (tokenStarted) { + tokens.push(current); + current = ''; + tokenStarted = false; + quoteClosed = false; + } + continue; + } + if (quoteClosed) return undefined; + if (character === '"' || character === "'") { + if (tokenStarted) return undefined; + quote = character; + tokenStarted = true; + continue; + } + current += character; + tokenStarted = true; + } + + if (quote) return undefined; + if (tokenStarted) tokens.push(current); + return tokens; +} + function isManagedHookCommand(command: unknown, scriptRelPaths: string[]): boolean { if (typeof command !== 'string') return false; // Match both the current `node .../comet-hook-guard.mjs` form and the legacy // `bash .../comet-hook-guard.sh` form so uninstall also cleans up hooks // written by older Comet releases. Compare basenames without extension. - const commandPath = command - .trim() - .match(/^(?:node|bash|sh)\s+["']?([^"'\s]+)["']?(?:\s|$)/)?.[1] - ?.replace(/\\/g, '/'); - if (!commandPath) return false; + const tokens = parseCommandTokens(command.trim()); + if (!tokens || tokens.length < 2 || !['node', 'bash', 'sh'].includes(tokens[0])) return false; + const commandPath = tokens[1].replace(/\\/g, '/'); const normalize = (value: string): string => value.replace(/\.(?:sh|mjs)$/u, ''); return scriptRelPaths.some((scriptRelPath) => @@ -808,27 +923,39 @@ function isManagedHookCommand(command: unknown, scriptRelPaths: string[]): boole } function mergeHookGroups( - existingGroups: Array>, + existingGroups: unknown[], newGroups: Array<{ matcher: string; hooks: T[] }>, scriptRelPaths: string[], -): Array> { - const mergedGroups = existingGroups.flatMap((group) => { - if (!Array.isArray(group.hooks)) return [group]; - - const hooks = group.hooks.filter( - (hook) => !isManagedHookCommand((hook as Record).command, scriptRelPaths), - ); - if (hooks.length === 0 && group.hooks.length > 0) return []; +): unknown[] { + const mergedGroups = existingGroups.map((group) => { + if (!group || typeof group !== 'object' || Array.isArray(group)) return group; + const record = group as Record; + if (!Array.isArray(record.hooks)) return record; + + const hooks = record.hooks.filter((hook) => { + const command = + hook && typeof hook === 'object' ? (hook as Record).command : undefined; + return !isManagedHookCommand(command, scriptRelPaths); + }); - return [{ ...group, hooks }]; + return { ...record, hooks }; }); for (const newGroup of newGroups) { - const existingGroup = mergedGroups.find( - (group) => group.matcher === newGroup.matcher && Array.isArray(group.hooks), + const existingGroupIndex = mergedGroups.findIndex( + (group) => + Boolean(group) && + typeof group === 'object' && + !Array.isArray(group) && + (group as Record).matcher === newGroup.matcher && + Array.isArray((group as Record).hooks), ); - if (existingGroup) { - existingGroup.hooks = [...(existingGroup.hooks as unknown[]), ...newGroup.hooks]; + if (existingGroupIndex >= 0) { + const existingGroup = mergedGroups[existingGroupIndex] as Record; + mergedGroups[existingGroupIndex] = { + ...existingGroup, + hooks: [...(existingGroup.hooks as unknown[]), ...newGroup.hooks], + }; } else { mergedGroups.push(newGroup); } @@ -842,43 +969,92 @@ function mergeHookGroups( * store a group as an object or scalar; treat anything non-array as empty so * downstream merge/filter logic cannot throw on malformed input. */ -function asHookGroup(value: unknown): Array> { - return Array.isArray(value) ? (value as Array>) : []; +function asHookGroup(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; } -async function readSettingsJsonObject( +async function removeManagedHooksFromJsonFile( settingsPath: string, - hookFormat: string, -): Promise> { - if (!(await fileExists(settingsPath))) return {}; + scriptRelPaths: string[], +): Promise<{ removed: number; failed: number }> { + if (!(await fileExists(settingsPath))) return { removed: 0, failed: 0 }; + let source: string; try { - const parsed = JSON.parse(await readFile(settingsPath, 'utf-8')) as unknown; + source = await readFile(settingsPath, 'utf-8'); + } catch { + return { removed: 0, failed: 1 }; + } + + let settings: Record; + try { + const parsed = JSON.parse(source) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('expected a JSON object'); + return { removed: 0, failed: 1 }; } - return parsed as Record; - } catch (error) { - throw new Error( - `Invalid ${hookFormat} settings at ${settingsPath}: ${(error as Error).message}`, - { - cause: error, - }, - ); + settings = parsed as Record; + } catch { + return { removed: 0, failed: 1 }; + } + + const existingHooks = settings.hooks as Record | undefined; + const existingPreToolUse = existingHooks?.PreToolUse; + if (!existingHooks || !Array.isArray(existingPreToolUse)) { + return { removed: 0, failed: 0 }; + } + + let removed = 0; + const filtered = existingPreToolUse.map((group) => { + if (!group || typeof group !== 'object') return group; + const record = group as Record; + if (!Array.isArray(record.hooks)) return record; + const handlers = record.hooks.filter((handler) => { + const command = + handler && typeof handler === 'object' + ? (handler as Record).command + : undefined; + const managed = isManagedHookCommand(command, scriptRelPaths); + if (managed) removed++; + return !managed; + }); + return { ...record, hooks: handlers }; + }); + + if (removed === 0) return { removed: 0, failed: 0 }; + existingHooks.PreToolUse = filtered; + try { + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + } catch { + return { removed: 0, failed: 1 }; } + return { removed, failed: 0 }; +} + +async function readSettingsJsonObject( + settingsPath: string, + platformName: string, +): Promise> { + const result = await readJsonObjectFile(settingsPath); + if (result.status === 'missing') return {}; + if (result.status === 'present') return result.value; + throw new Error(`Invalid ${platformName} settings at ${settingsPath}: ${result.error.message}`, { + cause: result.error, + }); } /** - * Claude Code, Codex, Amazon Q format: - * Writes to settings.local.json with { hooks: { PreToolUse: [...] } } + * Claude-shaped JSON format used by Claude Code, Codex, and Amazon Q. + * Defaults to settings.local.json; platform metadata may override the filename. */ async function installClaudeCodeHooks( baseDir: string, platformBase: string, skillsDir: string, hooksConfig: Record, -): Promise<{ installed: boolean; reason?: string }> { - const settingsPath = path.join(platformBase, 'settings.local.json'); + configFile: string, + platformName: string, +): Promise { + const settingsPath = path.join(platformBase, configFile); // Claude Code format: { matcher, hooks: [{ type: "command", command }] } interface ClaudeCodeHookEntry { @@ -900,14 +1076,7 @@ async function installClaudeCodeHooks( ([matcher, hooks]) => ({ matcher, hooks }), ); - let settings: Record = {}; - if (await fileExists(settingsPath)) { - try { - settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; - } catch { - settings = {}; - } - } + const settings = await readSettingsJsonObject(settingsPath, platformName); const existingHooks = (settings.hooks as Record) ?? {}; const existingPreToolUse = asHookGroup(existingHooks.PreToolUse); @@ -916,7 +1085,7 @@ async function installClaudeCodeHooks( settings.hooks = { ...existingHooks, PreToolUse: merged }; await ensureDir(path.dirname(settingsPath)); await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { installed: true }; + return { status: 'installed' }; } /** @@ -928,8 +1097,8 @@ async function installQwenStyleHooks( platformBase: string, skillsDir: string, hooksConfig: Record, - hookFormat: string, -): Promise<{ installed: boolean; reason?: string }> { + platformName: string, +): Promise { const settingsPath = path.join(platformBase, 'settings.json'); // Group by matcher @@ -953,7 +1122,7 @@ async function installQwenStyleHooks( hooks, })); - const settings = await readSettingsJsonObject(settingsPath, hookFormat); + const settings = await readSettingsJsonObject(settingsPath, platformName); const existingHooks = (settings.hooks as Record) ?? {}; const existingPreToolUse = asHookGroup(existingHooks.PreToolUse); @@ -962,7 +1131,7 @@ async function installQwenStyleHooks( settings.hooks = { ...existingHooks, PreToolUse: merged }; await ensureDir(path.dirname(settingsPath)); await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { installed: true }; + return { status: 'installed' }; } /** @@ -974,7 +1143,8 @@ async function installGeminiHooks( platformBase: string, skillsDir: string, hooksConfig: Record, -): Promise<{ installed: boolean; reason?: string }> { + platformName: string, +): Promise { const settingsPath = path.join(platformBase, 'settings.json'); const entries: Array<{ @@ -994,14 +1164,7 @@ async function installGeminiHooks( }); } - let settings: Record = {}; - if (await fileExists(settingsPath)) { - try { - settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; - } catch { - settings = {}; - } - } + const settings = await readSettingsJsonObject(settingsPath, platformName); const existingHooks = (settings.hooks as Record) ?? {}; const existingBeforeTool = asHookGroup(existingHooks.BeforeTool); @@ -1010,7 +1173,7 @@ async function installGeminiHooks( settings.hooks = { ...existingHooks, BeforeTool: merged }; await ensureDir(path.dirname(settingsPath)); await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { installed: true }; + return { status: 'installed' }; } /** @@ -1022,7 +1185,8 @@ async function installWindsurfHooks( platformBase: string, skillsDir: string, hooksConfig: Record, -): Promise<{ installed: boolean; reason?: string }> { + platformName: string, +): Promise { const hooksPath = path.join(platformBase, 'hooks.json'); const entries: Array<{ command: string; show_output: boolean }> = []; @@ -1033,26 +1197,21 @@ async function installWindsurfHooks( }); } - let hooksFile: Record = {}; - if (await fileExists(hooksPath)) { - try { - hooksFile = JSON.parse(await readFile(hooksPath, 'utf-8')) as Record; - } catch { - hooksFile = {}; - } - } + const hooksFile = await readSettingsJsonObject(hooksPath, platformName); const existingHooks = (hooksFile.hooks as Record) ?? {}; const existingPreWrite = asHookGroup(existingHooks.pre_write_code); - const merged = existingPreWrite.filter( - (entry) => !isManagedHookCommand(entry.command, Object.keys(hooksConfig)), - ); + const merged = existingPreWrite.filter((entry) => { + const command = + entry && typeof entry === 'object' ? (entry as Record).command : undefined; + return !isManagedHookCommand(command, Object.keys(hooksConfig)); + }); merged.push(...entries); hooksFile.hooks = { ...existingHooks, pre_write_code: merged }; await ensureDir(path.dirname(hooksPath)); await writeFile(hooksPath, JSON.stringify(hooksFile, null, 2) + '\n', 'utf-8'); - return { installed: true }; + return { status: 'installed' }; } /** @@ -1064,7 +1223,7 @@ async function installCopilotHooks( platformBase: string, skillsDir: string, hooksConfig: Record, -): Promise<{ installed: boolean; reason?: string }> { +): Promise { const hooksDir = path.join(platformBase, 'hooks'); const hookFilePath = path.join(hooksDir, 'comet-guard.json'); @@ -1084,7 +1243,7 @@ async function installCopilotHooks( await ensureDir(hooksDir); await writeFile(hookFilePath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8'); - return { installed: true }; + return { status: 'installed' }; } /** @@ -1096,7 +1255,7 @@ async function installKiroHooks( platformBase: string, skillsDir: string, hooksConfig: Record, -): Promise<{ installed: boolean; reason?: string }> { +): Promise { const hooksDir = path.join(platformBase, 'hooks'); for (const [scriptRelPath, config] of Object.entries(hooksConfig)) { @@ -1125,7 +1284,7 @@ async function installKiroHooks( await writeFile(hookFilePath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8'); } - return { installed: true }; + return { status: 'installed' }; } function managedConfigFields(language: string = 'en') { @@ -1232,6 +1391,8 @@ export { computeRuleDestPath, formatRuleContent, isManagedHookCommand, + buildHookCommand, + removeManagedHooksFromJsonFile, planSkillDirectoryCopy, mergeProjectConfig, parseProjectConfigOverrides, diff --git a/domains/skill/uninstall.ts b/domains/skill/uninstall.ts index cd395603..4248cc57 100644 --- a/domains/skill/uninstall.ts +++ b/domains/skill/uninstall.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { lstat, readFile, writeFile } from 'fs/promises'; +import { lstat, writeFile } from 'fs/promises'; import { fileExists, @@ -20,8 +20,10 @@ import { getManagedSkillPaths, computeRuleDestPath, isManagedHookCommand, + removeManagedHooksFromJsonFile, } from './platform-install.js'; import { removeCometProjectInstructions } from './project-instructions.js'; +import { readJsonObjectFile } from './json-object.js'; interface RemovalResult { removed: number; @@ -50,40 +52,43 @@ async function removeManagedSkillsFromDirs( break; } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + failed++; + sharedBoundary = true; + break; + } } } if (sharedBoundary) continue; for (const skillRelPath of managedSkills) { - const parts = skillRelPath.split('/'); - let current = baseDir; - let linkedAncestor = false; - const ancestorParts = [ - ...skillsDir.split(/[\\/]/u).filter(Boolean), - 'skills', - ...parts.slice(0, -1), - ]; - for (const part of ancestorParts) { - current = path.join(current, part); - try { + try { + const parts = skillRelPath.split('/'); + let current = baseDir; + let linkedAncestor = false; + const ancestorParts = [ + ...skillsDir.split(/[\\/]/u).filter(Boolean), + 'skills', + ...parts.slice(0, -1), + ]; + for (const part of ancestorParts) { + current = path.join(current, part); if ((await lstat(current)).isSymbolicLink()) { if (await removeFile(current)) removed++; linkedAncestor = true; break; } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') break; - throw error; } - } - if (linkedAncestor) continue; + if (linkedAncestor) continue; - if (await removeFile(path.join(skillsRoot, ...parts))) removed++; - current = skillsRoot; - for (const part of parts.slice(0, -1)) { - current = path.join(current, part); - parentDirs.add(current); + if (await removeFile(path.join(skillsRoot, ...parts))) removed++; + current = skillsRoot; + for (const part of parts.slice(0, -1)) { + current = path.join(current, part); + parentDirs.add(current); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') failed++; } } } @@ -91,7 +96,11 @@ async function removeManagedSkillsFromDirs( for (const dir of [...parentDirs].sort( (left, right) => right.split(path.sep).length - left.split(path.sep).length, )) { - if (await isDirEmpty(dir)) await removeDir(dir); + try { + if (await isDirEmpty(dir)) await removeDir(dir); + } catch { + failed++; + } } return { removed, failed }; } @@ -125,7 +134,7 @@ async function removeCometSkillsForPlatform( ]; const skillsRemoval = await removeManagedSkillsFromDirs(baseDir, uniqueSkillsDirs, managedSkills); let removed = skillsRemoval.removed; - const failed = skillsRemoval.failed; + let failed = skillsRemoval.failed; if (OPENCODE_STYLE_PLATFORM_IDS.has(platform.id)) { const commandsDir = path.join(baseDir, skillsDir, 'commands'); @@ -135,20 +144,32 @@ async function removeCometSkillsForPlatform( const skillName = parts[0]; const commandFile = path.join(commandsDir, `${skillName}.md`); - const result = await removeFile(commandFile); - if (result) { - removed++; + try { + const result = await removeFile(commandFile); + if (result) { + removed++; + } + } catch { + failed++; } } } if (platform.id === 'pi') { const extensionsDir = path.join(baseDir, skillsDir, 'extensions'); - if (await removeFile(path.join(extensionsDir, 'comet-commands.ts'))) { - removed++; + try { + if (await removeFile(path.join(extensionsDir, 'comet-commands.ts'))) { + removed++; + } + } catch { + failed++; } - if (await isDirEmpty(extensionsDir)) { - await removeDir(extensionsDir); + try { + if (await isDirEmpty(extensionsDir)) { + await removeDir(extensionsDir); + } + } catch { + failed++; } } @@ -179,22 +200,30 @@ async function removeCometRulesForPlatform( : path.join(baseDir, skillsDir); let removed = 0; - const failed = 0; + let failed = 0; for (const ruleRelPath of rulePaths) { const ruleFileName = path.basename(ruleRelPath); const rulesDestDir = path.join(rulesBase, platform.rulesDir); const dest = computeRuleDestPath(rulesDestDir, ruleFileName, platform.rulesFormat); - const result = await removeFile(dest); - if (result) { - removed++; + try { + const result = await removeFile(dest); + if (result) { + removed++; + } + } catch { + failed++; } } const rulesDestDir = path.join(rulesBase, platform.rulesDir); - if (await isDirEmpty(rulesDestDir)) { - await removeDir(rulesDestDir); + try { + if (await isDirEmpty(rulesDestDir)) { + await removeDir(rulesDestDir); + } + } catch { + failed++; } return { removed, failed }; @@ -221,20 +250,39 @@ async function removeCometHooksForPlatform( try { switch (hookFormat) { - case 'claude-code': - return removeClaudeCodeHooks(platformBase, scriptRelPaths); + case 'claude-code': { + const canonicalFile = platform.hookConfigFile ?? 'settings.local.json'; + const files = [canonicalFile, ...(platform.legacyHookConfigFiles ?? [])]; + let removed = 0; + let failed = 0; + for (const file of new Set(files)) { + let result: RemovalResult; + try { + result = await removeManagedHooksFromJsonFile( + path.join(platformBase, file), + scriptRelPaths, + ); + } catch { + if (file === canonicalFile) failed++; + continue; + } + removed += result.removed; + if (file === canonicalFile) failed += result.failed; + } + return { removed, failed }; + } case 'qwen': case 'qoder': case 'codebuddy': - return removeQwenStyleHooks(platformBase, scriptRelPaths); + return await removeQwenStyleHooks(platformBase, scriptRelPaths); case 'gemini': - return removeGeminiHooks(platformBase, scriptRelPaths); + return await removeGeminiHooks(platformBase, scriptRelPaths); case 'windsurf': - return removeWindsurfHooks(platformBase, scriptRelPaths); + return await removeWindsurfHooks(platformBase, scriptRelPaths); case 'copilot': - return removeCopilotHooks(platformBase); + return await removeCopilotHooks(platformBase); case 'kiro': - return removeKiroHooks(platformBase, scriptRelPaths); + return await removeKiroHooks(platformBase, scriptRelPaths); default: return { removed: 0, failed: 0 }; } @@ -243,76 +291,17 @@ async function removeCometHooksForPlatform( } } -async function removeClaudeCodeHooks( - platformBase: string, - scriptRelPaths: string[], -): Promise { - const settingsPath = path.join(platformBase, 'settings.local.json'); - if (!(await fileExists(settingsPath))) { - return { removed: 0, failed: 0 }; - } - - let removed = 0; - let settings: Record; - try { - settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; - } catch { - return { removed: 0, failed: 0 }; - } - - const existingHooks = settings.hooks as Record | undefined; - if (!existingHooks) { - return { removed: 0, failed: 0 }; - } - - const existingPreToolUse = existingHooks.PreToolUse as Array> | undefined; - if (!existingPreToolUse || !Array.isArray(existingPreToolUse)) { - return { removed: 0, failed: 0 }; - } - - const filtered = existingPreToolUse.flatMap((group) => { - if (!Array.isArray(group.hooks)) return [group]; - - const hooksBefore = (group.hooks as Array>).length; - const hooks = (group.hooks as Array>).filter( - (hook) => !isManagedHookCommand(hook.command, scriptRelPaths), - ); - removed += hooksBefore - hooks.length; - - if (hooks.length === 0) return []; - return [{ ...group, hooks }]; - }); - - if (filtered.length === 0) { - delete existingHooks.PreToolUse; - } else { - existingHooks.PreToolUse = filtered; - } - - if (Object.keys(existingHooks).length === 0) { - delete settings.hooks; - } - - await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { removed, failed: 0 }; -} - async function removeQwenStyleHooks( platformBase: string, scriptRelPaths: string[], ): Promise { const settingsPath = path.join(platformBase, 'settings.json'); - if (!(await fileExists(settingsPath))) { - return { removed: 0, failed: 0 }; - } - + if (!(await fileExists(settingsPath))) return { removed: 0, failed: 0 }; let removed = 0; - let settings: Record; - try { - settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; - } catch { - return { removed: 0, failed: 0 }; - } + const readResult = await readJsonObjectFile(settingsPath); + if (readResult.status === 'missing') return { removed: 0, failed: 0 }; + if (readResult.status === 'error') return { removed: 0, failed: 1 }; + const settings = readResult.value; const existingHooks = settings.hooks as Record | undefined; if (!existingHooks) { @@ -333,7 +322,10 @@ async function removeQwenStyleHooks( ); removed += hooksBefore - hooks.length; - if (hooks.length === 0) return []; + const hasUnknownMetadata = Object.keys(group).some( + (key) => key !== 'matcher' && key !== 'hooks', + ); + if (hooks.length === 0) return hasUnknownMetadata ? [{ ...group, hooks: [] }] : []; return [{ ...group, hooks }]; }); @@ -356,17 +348,12 @@ async function removeGeminiHooks( scriptRelPaths: string[], ): Promise { const settingsPath = path.join(platformBase, 'settings.json'); - if (!(await fileExists(settingsPath))) { - return { removed: 0, failed: 0 }; - } - + if (!(await fileExists(settingsPath))) return { removed: 0, failed: 0 }; let removed = 0; - let settings: Record; - try { - settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; - } catch { - return { removed: 0, failed: 0 }; - } + const readResult = await readJsonObjectFile(settingsPath); + if (readResult.status === 'missing') return { removed: 0, failed: 0 }; + if (readResult.status === 'error') return { removed: 0, failed: 1 }; + const settings = readResult.value; const existingHooks = settings.hooks as Record | undefined; if (!existingHooks) { @@ -387,7 +374,10 @@ async function removeGeminiHooks( ); removed += hooksBefore - hooks.length; - if (hooks.length === 0) return []; + const hasUnknownMetadata = Object.keys(group).some( + (key) => key !== 'matcher' && key !== 'hooks', + ); + if (hooks.length === 0) return hasUnknownMetadata ? [{ ...group, hooks: [] }] : []; return [{ ...group, hooks }]; }); @@ -410,17 +400,12 @@ async function removeWindsurfHooks( scriptRelPaths: string[], ): Promise { const hooksPath = path.join(platformBase, 'hooks.json'); - if (!(await fileExists(hooksPath))) { - return { removed: 0, failed: 0 }; - } - + if (!(await fileExists(hooksPath))) return { removed: 0, failed: 0 }; let removed = 0; - let hooksFile: Record; - try { - hooksFile = JSON.parse(await readFile(hooksPath, 'utf-8')) as Record; - } catch { - return { removed: 0, failed: 0 }; - } + const readResult = await readJsonObjectFile(hooksPath); + if (readResult.status === 'missing') return { removed: 0, failed: 0 }; + if (readResult.status === 'error') return { removed: 0, failed: 1 }; + const hooksFile = readResult.value; const existingHooks = hooksFile.hooks as Record | undefined; if (!existingHooks) { @@ -458,14 +443,24 @@ async function removeWindsurfHooks( async function removeCopilotHooks(platformBase: string): Promise { const hookFilePath = path.join(platformBase, 'hooks', 'comet-guard.json'); - const removed = (await removeFile(hookFilePath)) ? 1 : 0; + let removed = 0; + let failed = 0; + try { + if (await removeFile(hookFilePath)) removed++; + } catch { + failed++; + } const hooksDir = path.join(platformBase, 'hooks'); - if (await isDirEmpty(hooksDir)) { - await removeDir(hooksDir); + try { + if (await isDirEmpty(hooksDir)) { + await removeDir(hooksDir); + } + } catch { + failed++; } - return { removed, failed: 0 }; + return { removed, failed }; } async function removeKiroHooks( @@ -473,11 +468,19 @@ async function removeKiroHooks( scriptRelPaths: string[], ): Promise { const hooksDir = path.join(platformBase, 'hooks'); - if (!(await fileExists(hooksDir))) { - return { removed: 0, failed: 0 }; + try { + if (!(await lstat(hooksDir)).isDirectory()) { + return { removed: 0, failed: 1 }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { removed: 0, failed: 0 }; + } + return { removed: 0, failed: 1 }; } let removed = 0; + let failed = 0; const entries = await readDir(hooksDir); for (const entry of entries) { @@ -490,48 +493,77 @@ async function removeKiroHooks( if (isCometHook) { const hookPath = path.join(hooksDir, entry); - if (await removeFile(hookPath)) { - removed++; + try { + if (await removeFile(hookPath)) { + removed++; + } + } catch { + failed++; } } } - if (await isDirEmpty(hooksDir)) { - await removeDir(hooksDir); + try { + if (await isDirEmpty(hooksDir)) { + await removeDir(hooksDir); + } + } catch { + failed++; } - return { removed, failed: 0 }; + return { removed, failed }; } async function removeWorkingDirs(projectPath: string): Promise { let removed = 0; + let failed = 0; const cometDir = path.join(projectPath, '.comet'); - if (await removeDir(cometDir)) { - removed++; + try { + if (await removeDir(cometDir)) { + removed++; + } + } catch { + failed++; } const specsDir = path.join(projectPath, 'docs', 'superpowers', 'specs'); - if (await isDirEmpty(specsDir)) { - await removeDir(specsDir); + try { + if (await isDirEmpty(specsDir)) { + await removeDir(specsDir); + } + } catch { + failed++; } const plansDir = path.join(projectPath, 'docs', 'superpowers', 'plans'); - if (await isDirEmpty(plansDir)) { - await removeDir(plansDir); + try { + if (await isDirEmpty(plansDir)) { + await removeDir(plansDir); + } + } catch { + failed++; } const superpowersDir = path.join(projectPath, 'docs', 'superpowers'); - if (await isDirEmpty(superpowersDir)) { - await removeDir(superpowersDir); + try { + if (await isDirEmpty(superpowersDir)) { + await removeDir(superpowersDir); + } + } catch { + failed++; } const docsDir = path.join(projectPath, 'docs'); - if (await isDirEmpty(docsDir)) { - await removeDir(docsDir); + try { + if (await isDirEmpty(docsDir)) { + await removeDir(docsDir); + } + } catch { + failed++; } - return { removed, failed: 0 }; + return { removed, failed }; } export { diff --git a/package-lock.json b/package-lock.json index 72b0c9c8..9916db76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ad9ecd89..916eacc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/platform/fs/file-system.ts b/platform/fs/file-system.ts index 3ea35939..b7517427 100644 --- a/platform/fs/file-system.ts +++ b/platform/fs/file-system.ts @@ -59,8 +59,9 @@ export async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; - } catch { - return false; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; } } @@ -117,9 +118,9 @@ export async function removeFile(filePath: string): Promise { try { await fs.unlink(filePath); return true; - } catch { - // Not found or failed (permissions/IO): nothing was removed. - return false; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; } } @@ -136,23 +137,27 @@ export async function removeDir(dirPath: string): Promise { await fs.unlink(dirPath); return true; } - await fs.rm(dirPath, { recursive: true, force: true }); + await fs.rm(dirPath, { recursive: true, force: false }); return true; } catch (error) { - return isNotFoundError(error); + if (isNotFoundError(error)) return false; + throw error; } } /** - * Check if a directory is empty. A missing directory is treated as empty; - * unreadable directories (permissions/IO) return false so callers never delete - * a directory they could not inspect. + * Check if a directory is empty. A missing directory is treated as empty and + * a non-directory path as non-empty; inspection failures are propagated so + * callers can report that cleanup was incomplete. */ export async function isDirEmpty(dirPath: string): Promise { try { const entries = await fs.readdir(dirPath); return entries.length === 0; } catch (error) { - return isNotFoundError(error); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') return true; + if (code === 'ENOTDIR') return false; + throw error; } } diff --git a/platform/install/platforms.ts b/platform/install/platforms.ts index f2141dca..c30369b7 100644 --- a/platform/install/platforms.ts +++ b/platform/install/platforms.ts @@ -35,6 +35,10 @@ export interface Platform { | 'kiro' | 'qoder' | 'codebuddy'; + /** Hook config filename relative to the platform config root when it differs from the format default. */ + hookConfigFile?: string; + /** Historical hook config filenames checked during migration and uninstall. */ + legacyHookConfigFiles?: string[]; } export function getPlatformSkillsDir(platform: Platform, scope: InstallScope): string { @@ -89,6 +93,8 @@ export const PLATFORMS: Platform[] = [ rulesFormat: 'md', supportsHooks: true, hookFormat: 'claude-code', + hookConfigFile: 'hooks.json', + legacyHookConfigFiles: ['settings.local.json'], }, { id: 'opencode', diff --git a/platform/install/project-registry.ts b/platform/install/project-registry.ts index f1ea2a87..3a775c03 100644 --- a/platform/install/project-registry.ts +++ b/platform/install/project-registry.ts @@ -83,6 +83,14 @@ function canonicalKey(canonicalPath: string): string { return process.platform === 'win32' ? canonicalPath.toLowerCase() : canonicalPath; } +function findProjectRegistryEntryByCanonicalPath( + projects: ProjectRegistryEntry[], + canonicalPath: string, +): ProjectRegistryEntry | undefined { + const key = canonicalKey(canonicalPath); + return projects.find((entry) => canonicalKey(entry.canonicalPath) === key); +} + function isProjectRegistrySource(value: unknown): value is ProjectRegistrySource { return value === 'init' || value === 'update' || value === 'repair'; } @@ -195,6 +203,14 @@ async function resolveProjectPath(projectPath: string): Promise<{ } } +export async function findProjectRegistryEntry( + projectPath: string, + projects: ProjectRegistryEntry[], +): Promise { + const resolved = await resolveProjectPath(projectPath); + return findProjectRegistryEntryByCanonicalPath(projects, resolved.canonicalPath); +} + async function writeProjectRegistry( registry: ProjectRegistry, registryPath: string, @@ -258,8 +274,11 @@ export async function upsertProjectInstallation( const timestamp = nowIso(options); const registry = await readProjectRegistry({ ...options, strict: false }); const resolved = await resolveProjectPath(projectPath); + const existing = findProjectRegistryEntryByCanonicalPath( + registry.projects, + resolved.canonicalPath, + ); const key = canonicalKey(resolved.canonicalPath); - const existing = registry.projects.find((entry) => canonicalKey(entry.canonicalPath) === key); const entry: ProjectRegistryEntry = { path: resolved.path, canonicalPath: resolved.canonicalPath, diff --git a/platform/install/skill-root-owner.ts b/platform/install/skill-root-owner.ts new file mode 100644 index 00000000..e77f486c --- /dev/null +++ b/platform/install/skill-root-owner.ts @@ -0,0 +1,99 @@ +import path from 'path'; + +import { fileExists } from '../fs/file-system.js'; +import { hasPlatformDetectionPath } from './detect.js'; +import { + PLATFORMS, + getPlatformConfigDir, + getPlatformSkillsDir, + type Platform, +} from './platforms.js'; +import type { InstallScope } from './types.js'; + +export interface CanonicalSkillRootOwner { + platform: Platform; + canonicalSkillsDir: string; + hasOwnershipEvidence: boolean; + sharedCanonicalRoot: boolean; +} + +interface ResolveCanonicalSkillRootOwnersOptions { + respectDetectionPaths?: boolean; +} + +async function hasOwnershipEvidence( + baseDir: string, + platform: Platform, + scope: InstallScope, + canonicalSkillsDir: string, +): Promise { + if (platform.detectionPaths?.length && (await hasPlatformDetectionPath(baseDir, platform))) { + return true; + } + + const configDir = getPlatformConfigDir(platform, scope); + return configDir !== canonicalSkillsDir && (await fileExists(path.join(baseDir, configDir))); +} + +/** Resolve exactly one platform owner for every canonical Skill root. */ +export async function resolveCanonicalSkillRootOwners( + baseDir: string, + scope: InstallScope, + options: ResolveCanonicalSkillRootOwnersOptions = {}, +): Promise { + const groups = new Map(); + for (const platform of PLATFORMS) { + const canonicalSkillsDir = getPlatformSkillsDir(platform, scope); + const key = path.resolve(baseDir, canonicalSkillsDir).toLowerCase(); + const group = groups.get(key) ?? { canonicalSkillsDir, platforms: [] }; + group.platforms.push(platform); + groups.set(key, group); + } + + const owners: CanonicalSkillRootOwner[] = []; + for (const { canonicalSkillsDir, platforms } of groups.values()) { + if (platforms.length === 1) { + const [platform] = platforms; + if (options.respectDetectionPaths === false) { + owners.push({ + platform, + canonicalSkillsDir, + hasOwnershipEvidence: false, + sharedCanonicalRoot: false, + }); + continue; + } + const detected = await hasPlatformDetectionPath(baseDir, platform); + if (!detected) continue; + owners.push({ + platform, + canonicalSkillsDir, + hasOwnershipEvidence: true, + sharedCanonicalRoot: false, + }); + continue; + } + + let owner: Platform | undefined; + for (const platform of platforms) { + if (await hasOwnershipEvidence(baseDir, platform, scope, canonicalSkillsDir)) { + owner = platform; + break; + } + } + + const hasEvidence = owner !== undefined; + owner ??= platforms.find((platform) => !platform.detectionPaths?.length); + if (!owner && options.respectDetectionPaths === false) owner = platforms[0]; + if (owner) { + owners.push({ + platform: owner, + canonicalSkillsDir, + hasOwnershipEvidence: hasEvidence, + sharedCanonicalRoot: true, + }); + } + } + + return owners; +} diff --git a/scripts/benchmark/classic-baseline-regression.mjs b/scripts/benchmark/classic-baseline-regression.mjs index ece95918..dae28d4a 100644 --- a/scripts/benchmark/classic-baseline-regression.mjs +++ b/scripts/benchmark/classic-baseline-regression.mjs @@ -83,6 +83,23 @@ function hookInput(filePath) { }); } +const CONTROLLED_MIGRATION_GUARD_DIAGNOSTIC = + 'BLOCKED — fix failing checks before proceeding to next phase'; + +function migrationGuardOutcome(result, phase) { + const diagnostic = result.stderr.includes(CONTROLLED_MIGRATION_GUARD_DIAGNOSTIC) + ? CONTROLLED_MIGRATION_GUARD_DIAGNOSTIC + : null; + return { + phase, + status: result.status, + signal: result.signal ?? null, + controlledOutcome: + result.status === 1 && result.signal === null && !result.error && diagnostic !== null, + diagnostic, + }; +} + async function resetScenario(workspace, name) { const directory = path.join(workspace, name); await fs.rm(directory, { recursive: true, force: true }); @@ -109,27 +126,33 @@ async function profileScenario(workspace, profile) { state(directory, 'init', name, profile); const changeDir = path.join(directory, 'openspec', 'changes', name); - const first = run(directory, ['hook-guard'], { input: hookInput('src/index.ts') }); + const migrationGuard = migrationGuardOutcome(run(directory, ['guard', name, 'open']), 'open'); const migrated = await readState(changeDir); - const firstBytes = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const beforeHooks = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const first = run(directory, ['hook-guard'], { input: hookInput('src/index.ts') }); + const afterFirst = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); const second = run(directory, ['hook-guard'], { input: hookInput('src/index.ts') }); - const secondBytes = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const afterSecond = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); return result( name, startedAt, { transitionAccuracy: - first.status === 2 && second.status === 2 && migrated.current_step === `${profile}.open`, + migrationGuard.controlledOutcome && + first.status === 2 && + second.status === 2 && + migrated.current_step === `${profile}.open`, migrationSuccess: + migrationGuard.controlledOutcome && migrated.classic_migration === 1 && migrated.classic_profile === profile && migrated.skill === 'comet-classic', - idempotent: firstBytes === secondBytes, + idempotent: beforeHooks === afterFirst && afterFirst === afterSecond, contractMatch: migrated.workflow === profile && migrated.phase === 'open' && migrated.archived === false, }, - { currentStep: migrated.current_step }, + { currentStep: migrated.current_step, migrationGuard }, ); } @@ -149,25 +172,33 @@ async function retryFixScenario(workspace) { state(directory, 'set', name, field, value); } const changeDir = path.join(directory, 'openspec', 'changes', name); - const first = run(directory, ['hook-guard'], { input: hookInput('src/fix.ts') }); + const migrationGuard = migrationGuardOutcome(run(directory, ['guard', name, 'build']), 'build'); const migrated = await readState(changeDir); - const firstBytes = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const beforeHooks = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const first = run(directory, ['hook-guard'], { input: hookInput('src/fix.ts') }); + const afterFirst = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); const second = run(directory, ['hook-guard'], { input: hookInput('src/fix.ts') }); - const secondBytes = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); + const afterSecond = await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8'); return result( name, startedAt, { - transitionAccuracy: first.status === 0 && migrated.current_step === 'hotfix.build.execute', - migrationSuccess: migrated.classic_migration === 1 && migrated.skill === 'comet-classic', - idempotent: second.status === 0 && firstBytes === secondBytes, + transitionAccuracy: + migrationGuard.controlledOutcome && + first.status === 0 && + migrated.current_step === 'hotfix.build.execute', + migrationSuccess: + migrationGuard.controlledOutcome && + migrated.classic_migration === 1 && + migrated.skill === 'comet-classic', + idempotent: second.status === 0 && beforeHooks === afterFirst && afterFirst === afterSecond, contractMatch: migrated.workflow === 'hotfix' && migrated.phase === 'build' && migrated.verify_result === 'fail', }, - { currentStep: migrated.current_step }, + { currentStep: migrated.current_step, migrationGuard }, ); } diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index 36b6c821..196927a1 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -27,14 +27,18 @@ describe('CLI help text', () => { const packageLock = JSON.parse( readFileSync(path.join(repositoryRoot, 'package-lock.json'), 'utf8'), ) as { version: string; packages: { '': { version: string } } }; + const assetsManifest = JSON.parse( + readFileSync(path.join(repositoryRoot, 'assets', 'manifest.json'), 'utf8'), + ) as { version: string }; const tagline = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; expect(help.status, help.stderr).toBe(0); expect(help.stdout).toContain(tagline); expect(packageJson.description).toBe(tagline); - expect(packageJson.version).toBe('0.4.0-beta.4'); - expect(packageLock.version).toBe('0.4.0-beta.4'); - expect(packageLock.packages[''].version).toBe('0.4.0-beta.4'); + expect(packageJson.version).toBe('0.4.0-beta.5'); + expect(packageLock.version).toBe('0.4.0-beta.5'); + expect(packageLock.packages[''].version).toBe('0.4.0-beta.5'); + expect(assetsManifest.version).toBe('0.4.0-beta.5'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/app/doctor.test.ts b/test/app/doctor.test.ts index 6edfc117..bce37559 100644 --- a/test/app/doctor.test.ts +++ b/test/app/doctor.test.ts @@ -4,6 +4,11 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { doctorCommand } from '../../app/commands/doctor.js'; +import { + copyCometRulesForPlatform, + installCometHooksForPlatform, +} from '../../domains/skill/platform-install.js'; +import { PLATFORMS } from '../../platform/install/platforms.js'; const stateScript = path.resolve('assets', 'skills', 'comet', 'scripts', 'comet-state.mjs'); @@ -22,6 +27,20 @@ async function installManagedCometSkills(baseDir: string, platformDir = '.claude } } +async function collectDoctorResults( + targetPath: string, + scope: 'project' | 'global' | 'auto' = 'project', +): Promise> { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await doctorCommand(targetPath, { json: true, scope, homeDir: targetPath }); + const output = log.mock.calls.map((call) => call.join(' ')).join('\n'); + return JSON.parse(output).results; + } finally { + log.mockRestore(); + } +} + function state(cwd: string, ...args: string[]) { const env: NodeJS.ProcessEnv = { ...process.env }; if (args[0] === 'set' && args[2] === 'phase') { @@ -158,20 +177,125 @@ describe('doctor command', () => { expect(output).not.toContain('missing 31:'); }); - it('reports an explicitly scoped canonical global Codex install without a detection path', async () => { - const fakeHome = path.join(tmpDir, 'canonical-global-home'); - await installManagedCometSkills(fakeHome, '.agents'); + it('warns when a detected complete Skill install is missing its Rule and Hook', async () => { + await installManagedCometSkills(tmpDir); + + const results = await collectDoctorResults(tmpDir); + + expect(results.find((result) => result.check === 'rules: Claude Code (project)')).toMatchObject( + { + status: 'warn', + message: expect.stringContaining('comet update --scope project'), + }, + ); + expect(results.find((result) => result.check === 'hooks: Claude Code (project)')).toMatchObject( + { + status: 'warn', + message: expect.stringContaining('comet update --scope project'), + }, + ); + }); + + it('passes Rule and Hook checks when the managed components are installed', async () => { + const claude = PLATFORMS.find((platform) => platform.id === 'claude'); + expect(claude).toBeDefined(); + await installManagedCometSkills(tmpDir); + await copyCometRulesForPlatform(tmpDir, claude!, true, 'zh', 'project'); + await installCometHooksForPlatform(tmpDir, claude!, 'project'); + + const results = await collectDoctorResults(tmpDir); + + expect(results.find((result) => result.check === 'rules: Claude Code (project)')).toMatchObject( + { + status: 'pass', + }, + ); + expect(results.find((result) => result.check === 'hooks: Claude Code (project)')).toMatchObject( + { + status: 'pass', + }, + ); + }); + + it('reports a Hook JSON parse failure without rewriting the canonical config', async () => { + const hookPath = path.join(tmpDir, '.claude', 'settings.local.json'); + const malformed = '{\r\n "hooks": {\r\n'; + await installManagedCometSkills(tmpDir); + await fs.writeFile(hookPath, malformed); + + const results = await collectDoctorResults(tmpDir); + + expect(results.find((result) => result.check === 'hooks: Claude Code (project)')).toMatchObject( + { + status: 'warn', + message: expect.stringContaining('Invalid Hook JSON'), + }, + ); + expect(await fs.readFile(hookPath, 'utf8')).toBe(malformed); + }); + + it('reports a Rule destination access failure as a component warning', async () => { + await installManagedCometSkills(tmpDir); + const rulePath = path.join(tmpDir, '.claude', 'rules', 'comet-phase-guard.md'); + const access = fs.access.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockImplementation(async (filePath, mode) => { + if (path.resolve(String(filePath)) === path.resolve(rulePath)) throw permissionError; + await access(filePath, mode); + }); - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - await doctorCommand(tmpDir, { scope: 'global', homeDir: fakeHome }); - const output = log.mock.calls.map((call) => call.join(' ')).join('\n'); - expect(output).toContain('skills: Codex (global): complete'); + const results = await collectDoctorResults(tmpDir); + expect( + results.find((result) => result.check === 'rules: Claude Code (project)'), + ).toMatchObject({ + status: 'warn', + message: expect.stringContaining('permission denied'), + }); } finally { - log.mockRestore(); + accessSpy.mockRestore(); } }); + it('does not emit false Rule or Hook warnings for unsupported components', async () => { + const cursor = PLATFORMS.find((platform) => platform.id === 'cursor'); + const gemini = PLATFORMS.find((platform) => platform.id === 'gemini'); + expect(cursor).toBeDefined(); + expect(gemini).toBeDefined(); + await installManagedCometSkills(tmpDir, '.cursor'); + await copyCometRulesForPlatform(tmpDir, cursor!, true, 'zh', 'project'); + await installManagedCometSkills(tmpDir, '.gemini'); + await installCometHooksForPlatform(tmpDir, gemini!, 'project'); + + const results = await collectDoctorResults(tmpDir); + + expect(results.some((result) => result.check === 'hooks: Cursor (project)')).toBe(false); + expect(results.some((result) => result.check === 'rules: Gemini CLI (project)')).toBe(false); + expect(results.find((result) => result.check === 'rules: Cursor (project)')).toMatchObject({ + status: 'pass', + }); + expect(results.find((result) => result.check === 'hooks: Gemini CLI (project)')).toMatchObject({ + status: 'pass', + }); + }); + + it('reports an explicitly scoped canonical global Codex install without a detection path', async () => { + const fakeHome = path.join(tmpDir, 'canonical-global-home'); + await installManagedCometSkills(fakeHome, '.agents'); + + const results = await collectDoctorResults(fakeHome, 'global'); + + expect(results.find((result) => result.check === 'skills: Codex (global)')).toMatchObject({ + status: 'pass', + }); + expect(results.find((result) => result.check === 'rules: Codex (global)')).toMatchObject({ + status: 'warn', + }); + expect(results.find((result) => result.check === 'hooks: Codex (global)')).toMatchObject({ + status: 'warn', + }); + }); + it('reports legacy-only Codex skills as requiring update and canonical Codex skills as healthy', async () => { const manifest = JSON.parse( await fs.readFile(path.resolve('assets', 'manifest.json'), 'utf8'), @@ -204,18 +328,46 @@ describe('doctor command', () => { } }); - it('does not report Codex healthy from shared canonical Skills without Codex detection paths', async () => { - await installManagedCometSkills(tmpDir, '.agents'); + it.each(['project', 'auto'] as const)( + 'assigns a shared project .agents Skill root once without Codex evidence in %s scope', + async (scope) => { + await installManagedCometSkills(tmpDir, '.agents'); - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - try { - await doctorCommand(tmpDir); - const output = log.mock.calls.map((call) => call.join(' ')).join('\n'); - expect(output).not.toContain('skills: Codex (project)'); - } finally { - log.mockRestore(); - } - }); + const results = await collectDoctorResults(tmpDir, scope); + const sharedRootChecks = results.filter((result) => + /^skills: (?:Codex|Antigravity(?: 2\.0)?) \(project\)$/u.test(result.check), + ); + + expect(sharedRootChecks.map((result) => result.check)).toEqual([ + 'skills: Antigravity (project)', + ]); + expect(results.some((result) => /^rules: Codex \(project\)$/u.test(result.check))).toBe( + false, + ); + expect(results.some((result) => /^hooks: Codex \(project\)$/u.test(result.check))).toBe( + false, + ); + }, + ); + + it.each(['project', 'auto'] as const)( + 'assigns a shared project .agents Skill root to Codex once with .codex evidence in %s scope', + async (scope) => { + await installManagedCometSkills(tmpDir, '.agents'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + + const results = await collectDoctorResults(tmpDir, scope); + const sharedRootChecks = results.filter((result) => + /^skills: (?:Codex|Antigravity(?: 2\.0)?) \(project\)$/u.test(result.check), + ); + + expect(sharedRootChecks.map((result) => result.check)).toEqual(['skills: Codex (project)']); + expect(results.filter((result) => result.check === 'rules: Codex (project)')).toHaveLength(1); + expect(results.filter((result) => result.check === 'hooks: Codex (project)')).toHaveLength(1); + expect(results.some((result) => /^rules: Antigravity/u.test(result.check))).toBe(false); + expect(results.some((result) => /^hooks: Antigravity/u.test(result.check))).toBe(false); + }, + ); it('uses the shared schema and leaves invalid state untouched', async () => { const invalidChangeDir = path.join(tmpDir, 'openspec', 'changes', 'top-level-invalid'); diff --git a/test/app/init-e2e.test.ts b/test/app/init-e2e.test.ts index 73fef946..9dac2c34 100644 --- a/test/app/init-e2e.test.ts +++ b/test/app/init-e2e.test.ts @@ -65,6 +65,9 @@ function mockExternalSuccess() { if ((cmd === 'which' || cmd === 'where') && cmdArgs[0] === 'openspec') { return Buffer.from('/usr/bin/openspec'); } + if (cmd === 'openspec' && cmdArgs[0] === '--version') { + return Buffer.from('1.5.0'); + } if (cmd === 'openspec' && cmdArgs[0] === 'init') { return Buffer.from('ok'); } @@ -251,16 +254,272 @@ describe('comet init E2E', () => { fs.access(path.join(tmpDir, '.agents', 'rules', 'comet-phase-guard.md')), ).rejects.toThrow(); - const settings = JSON.parse( - await fs.readFile(path.join(tmpDir, '.codex', 'settings.local.json'), 'utf8'), + const hooks = JSON.parse( + await fs.readFile(path.join(tmpDir, '.codex', 'hooks.json'), 'utf8'), ); - const hookCommand = settings.hooks.PreToolUse[0].hooks[0].command as string; + const hookCommand = hooks.hooks.PreToolUse[0].hooks[0].command as string; expect(hookCommand.replaceAll('\\', '/')).toContain( '/.agents/skills/comet/scripts/comet-hook-guard.mjs', ); await expect( - fs.access(path.join(tmpDir, '.agents', 'settings.local.json')), - ).rejects.toThrow(); + fs.access(path.join(tmpDir, '.codex', 'settings.local.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'Skill failure skips dependent Rule and Hook installation and leaves init incomplete', + async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const centralCometDir = path.join(tmpDir, '.comet', 'skills', 'skills', 'comet'); + await fs.mkdir(centralCometDir, { recursive: true }); + await fs.writeFile(path.join(centralCometDir, 'scripts'), 'blocking file'); + + const { initCommand } = await import('../../app/commands/init.js'); + const output = await captureTextOutput(() => + initCommand(tmpDir, { yes: true, language: 'en', installMode: 'symlink' }), + ); + + expect(output).toMatch(/Codex \(Comet failed\)/u); + await expect( + fs.access(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + fs.access(getProjectRegistryPath(path.join(tmpDir, 'fake-home'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'init --yes reuses an existing managed Skill and restores missing Codex Rule and Hook components', + async () => { + mockExternalSuccess(); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true, language: 'en' })); + await fs.rm(path.join(tmpDir, '.codex', 'rules'), { recursive: true, force: true }); + await fs.rm(path.join(tmpDir, '.codex', 'hooks.json'), { force: true }); + await fs.rm(getProjectRegistryPath(fakeHome), { force: true }); + + const result = await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en' }), + ); + const codex = (result.results as Array<{ platform: string; comet: string }>).find( + (candidate) => candidate.platform === 'codex', + ); + + expect(codex?.comet).toBe('installed'); + await expect( + fs.access(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md')), + ).resolves.toBeUndefined(); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).resolves.toBeUndefined(); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: Array<{ lastTargets: Array<{ platform: string }> }>; + }; + expect(registry.projects[0].lastTargets).toContainEqual( + expect.objectContaining({ platform: 'codex' }), + ); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'init --yes project scope does not treat a global-only Skill as a complete local install', + async () => { + mockExternalSuccess(); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en', scope: 'global' }), + ); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'blocking file', 'utf8'); + + const result = await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en', scope: 'project' }), + ); + const codex = (result.results as Array<{ platform: string; comet: string }>).find( + (candidate) => candidate.platform === 'codex', + ); + + expect(codex?.comet).toBe('failed'); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(fs.access(getProjectRegistryPath(fakeHome))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'init --yes repairs a partial local Skill before restoring dependent Rule and Hook components', + async () => { + mockExternalSuccess(); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const { initCommand } = await import('../../app/commands/init.js'); + const guardScript = path.join( + tmpDir, + '.agents', + 'skills', + 'comet', + 'scripts', + 'comet-hook-guard.mjs', + ); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true, language: 'en' })); + await fs.rm(guardScript, { force: true }); + await fs.rm(path.join(tmpDir, '.codex', 'rules'), { recursive: true, force: true }); + await fs.rm(path.join(tmpDir, '.codex', 'hooks.json'), { force: true }); + await fs.rm(getProjectRegistryPath(fakeHome), { force: true }); + + const result = await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en' }), + ); + const codex = (result.results as Array<{ platform: string; comet: string }>).find( + (candidate) => candidate.platform === 'codex', + ); + + expect(codex?.comet).toBe('installed'); + await expect(fs.access(guardScript)).resolves.toBeUndefined(); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).resolves.toBeUndefined(); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: Array<{ lastTargets: Array<{ platform: string }> }>; + }; + expect(registry.projects[0].lastTargets).toContainEqual( + expect.objectContaining({ platform: 'codex' }), + ); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'init --yes does not register reused Skills when canonical Hook validation fails', + async () => { + mockExternalSuccess(); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true, language: 'en' })); + await fs.writeFile(path.join(tmpDir, '.codex', 'hooks.json'), '[]\n', 'utf8'); + await fs.rm(getProjectRegistryPath(fakeHome), { force: true }); + + const result = await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en' }), + ); + const codex = (result.results as Array<{ platform: string; comet: string }>).find( + (candidate) => candidate.platform === 'codex', + ); + + expect(codex?.comet).toBe('failed'); + let projects: unknown[] = []; + try { + projects = ( + JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + } + ).projects; + } catch (error) { + expect(error).toMatchObject({ code: 'ENOENT' }); + } + expect(projects).toEqual([]); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'an explicit skip of existing Comet Skills does not register an incomplete target', + async () => { + mockExternalSuccess(); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true, language: 'en' })); + await fs.rm(path.join(tmpDir, '.codex', 'rules'), { recursive: true, force: true }); + await fs.rm(path.join(tmpDir, '.codex', 'hooks.json'), { force: true }); + await fs.rm(getProjectRegistryPath(fakeHome), { force: true }); + + await captureJsonOutput(() => + initCommand(tmpDir, { + yes: true, + json: true, + language: 'en', + skipExisting: true, + }), + ); + + let projects: unknown[] = []; + try { + projects = ( + JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + } + ).projects; + } catch (error) { + expect(error).toMatchObject({ code: 'ENOENT' }); + } + expect(projects).toEqual([]); + }, + INIT_E2E_TIMEOUT_MS, + ); + + it.each([ + { component: 'Rule', outcome: 'returned' }, + { component: 'Rule', outcome: 'thrown' }, + { component: 'Hook', outcome: 'returned' }, + { component: 'Hook', outcome: 'thrown' }, + ] as const)( + '$component $outcome failure makes init Comet failed and prevents registry success', + async ({ component, outcome }) => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const platformInstall = await import('../../domains/skill/platform-install.js'); + + if (component === 'Rule') { + const ruleSpy = vi.spyOn(platformInstall, 'copyCometRulesForPlatform'); + if (outcome === 'returned') { + ruleSpy.mockResolvedValueOnce({ copied: 0, skipped: 0, failed: 1 }); + } else { + ruleSpy.mockRejectedValueOnce(new Error('rule install threw')); + } + } else { + const hookSpy = vi.spyOn(platformInstall, 'installCometHooksForPlatform'); + if (outcome === 'returned') { + hookSpy.mockResolvedValueOnce({ + status: 'failed', + reason: 'hook install returned failed', + }); + } else { + hookSpy.mockRejectedValueOnce(new Error('hook install threw')); + } + } + + const { initCommand } = await import('../../app/commands/init.js'); + const result = await captureJsonOutput(() => + initCommand(tmpDir, { yes: true, json: true, language: 'en' }), + ); + const codexResult = (result.results as { platform: string; comet: string }[]).find( + (candidate) => candidate.platform === 'codex', + ); + + expect(codexResult?.comet).toBe('failed'); + await expect( + fs.access(getProjectRegistryPath(path.join(tmpDir, 'fake-home'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); }, INIT_E2E_TIMEOUT_MS, ); @@ -308,7 +567,7 @@ describe('comet init E2E', () => { }); it( - 'skips already-installed Comet skills with --yes', + 'reuses already-installed Comet skills with --yes and validates lifecycle components', async () => { mockExternalSuccess(); await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); @@ -330,7 +589,7 @@ describe('comet init E2E', () => { const claude2 = (result2.results as { platform: string; comet: string }[]).find( (r) => r.platform === 'claude', ); - expect(claude2?.comet).toBe('skipped'); + expect(claude2?.comet).toBe('installed'); }, INIT_E2E_TIMEOUT_MS, ); diff --git a/test/app/init.test.ts b/test/app/init.test.ts index 4c8c6920..9e557125 100644 --- a/test/app/init.test.ts +++ b/test/app/init.test.ts @@ -155,7 +155,7 @@ describe('init command helpers', () => { } }); - it('rejects invalid Pi settings without writing a command extension', async () => { + it('counts invalid Pi settings without writing a command extension', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-init-pi-invalid-')); const piPlatform = PLATFORMS.find((platform) => platform.id === 'pi')!; const settingsPath = path.join(tmpDir, '.pi', 'settings.json'); @@ -165,9 +165,14 @@ describe('init command helpers', () => { await fs.mkdir(path.dirname(settingsPath), { recursive: true }); await fs.writeFile(settingsPath, '{ invalid', 'utf-8'); - await expect( - copyCometSkillsForPlatform(tmpDir, piPlatform, true, 'skills', 'project'), - ).rejects.toThrow(/invalid Pi settings/i); + const result = await copyCometSkillsForPlatform( + tmpDir, + piPlatform, + true, + 'skills', + 'project', + ); + expect(result.failed).toBe(1); await expect(fs.readFile(settingsPath, 'utf-8')).resolves.toBe('{ invalid'); await expect(fs.access(extensionPath)).rejects.toThrow(); } finally { diff --git a/test/app/uninstall.test.ts b/test/app/uninstall.test.ts index d95cc9d3..8cc1269d 100644 --- a/test/app/uninstall.test.ts +++ b/test/app/uninstall.test.ts @@ -3,6 +3,14 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; +const { writeFileMock } = vi.hoisted(() => ({ writeFileMock: vi.fn() })); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + writeFileMock.mockImplementation(actual.writeFile); + return { ...actual, writeFile: writeFileMock }; +}); + import { PLATFORMS, type Platform } from '../../platform/install/platforms.js'; import { removeLegacyCometSkillsForPlatform, @@ -26,6 +34,8 @@ describe('uninstall', () => { let tmpDir: string; beforeEach(async () => { + writeFileMock.mockReset(); + writeFileMock.mockImplementation(fs.writeFile); tmpDir = path.join( os.tmpdir(), `comet-uninstall-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -168,6 +178,116 @@ describe('uninstall', () => { }, ); + it('counts a Skill removal failure and continues removing independent managed Skills', async () => { + const codexPlatform = PLATFORMS.find((platform) => platform.id === 'codex')!; + await copyCometSkillsForPlatform(tmpDir, codexPlatform, true, 'skills', 'project'); + const blockedSkill = path.join(tmpDir, '.agents', 'skills', 'comet', 'SKILL.md'); + const removableSkill = path.join(tmpDir, '.agents', 'skills', 'comet-open', 'SKILL.md'); + const userSkill = path.join(tmpDir, '.agents', 'skills', 'personal', 'SKILL.md'); + await fs.mkdir(path.dirname(userSkill), { recursive: true }); + await fs.writeFile(userSkill, '# Personal\n'); + const unlink = fs.unlink.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (filePath) => { + if (path.resolve(String(filePath)) === path.resolve(blockedSkill)) throw permissionError; + await unlink(filePath); + }); + + try { + await expect( + removeCometSkillsForPlatform(tmpDir, codexPlatform, 'project'), + ).resolves.toMatchObject({ failed: 1 }); + } finally { + unlinkSpy.mockRestore(); + } + + await expect(fs.readFile(blockedSkill, 'utf8')).resolves.toContain('# Comet'); + await expect(fs.access(removableSkill)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.readFile(userSkill, 'utf8')).resolves.toBe('# Personal\n'); + }); + + it('counts a Rule removal failure and continues removing independent managed Rules', async () => { + const claudePlatform = PLATFORMS.find((platform) => platform.id === 'claude')!; + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + const blockedRule = path.join(rulesDir, 'comet-phase-guard.md'); + const removableRule = path.join(rulesDir, 'comet-phase-guard.en.md'); + const userRule = path.join(rulesDir, 'personal.md'); + await fs.mkdir(rulesDir, { recursive: true }); + await fs.writeFile(blockedRule, '# Blocked Rule\n'); + await fs.writeFile(removableRule, '# Removable Rule\n'); + await fs.writeFile(userRule, '# Personal Rule\n'); + const unlink = fs.unlink.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (filePath) => { + if (path.resolve(String(filePath)) === path.resolve(blockedRule)) throw permissionError; + await unlink(filePath); + }); + + try { + await expect(removeCometRulesForPlatform(tmpDir, claudePlatform, 'project')).resolves.toEqual( + { removed: 1, failed: 1 }, + ); + } finally { + unlinkSpy.mockRestore(); + } + + await expect(fs.readFile(blockedRule, 'utf8')).resolves.toBe('# Blocked Rule\n'); + await expect(fs.access(removableRule)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.readFile(userRule, 'utf8')).resolves.toBe('# Personal Rule\n'); + }); + + it('counts a Hook-file removal failure without deleting user Hook files', async () => { + const kiroPlatform = PLATFORMS.find((platform) => platform.id === 'kiro')!; + const hooksDir = path.join(tmpDir, '.kiro', 'hooks'); + const managedHook = path.join(hooksDir, 'comet-hook-guard.kiro.hook'); + const userHook = path.join(hooksDir, 'personal.kiro.hook'); + await fs.mkdir(hooksDir, { recursive: true }); + await fs.writeFile(managedHook, '{}\n'); + await fs.writeFile(userHook, '{}\n'); + const unlink = fs.unlink.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (filePath) => { + if (path.resolve(String(filePath)) === path.resolve(managedHook)) throw permissionError; + await unlink(filePath); + }); + + try { + await expect(removeCometHooksForPlatform(tmpDir, kiroPlatform, 'project')).resolves.toEqual({ + removed: 0, + failed: 1, + }); + } finally { + unlinkSpy.mockRestore(); + } + + await expect(fs.readFile(managedHook, 'utf8')).resolves.toBe('{}\n'); + await expect(fs.readFile(userHook, 'utf8')).resolves.toBe('{}\n'); + }); + + it('counts an empty Rule-directory removal failure after removing managed Rules', async () => { + const claudePlatform = PLATFORMS.find((platform) => platform.id === 'claude')!; + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + await fs.mkdir(rulesDir, { recursive: true }); + await fs.writeFile(path.join(rulesDir, 'comet-phase-guard.md'), '# Rule\n'); + await fs.writeFile(path.join(rulesDir, 'comet-phase-guard.en.md'), '# English Rule\n'); + const rm = fs.rm.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const rmSpy = vi.spyOn(fs, 'rm').mockImplementation(async (dirPath, options) => { + if (path.resolve(String(dirPath)) === path.resolve(rulesDir)) throw permissionError; + await rm(dirPath, options); + }); + + try { + await expect(removeCometRulesForPlatform(tmpDir, claudePlatform, 'project')).resolves.toEqual( + { removed: 2, failed: 1 }, + ); + } finally { + rmSpy.mockRestore(); + } + + await expect(fs.readdir(rulesDir)).resolves.toEqual([]); + }); + describe('file-system utilities', () => { describe('removeFile', () => { it('removes an existing file and returns true', async () => { @@ -197,10 +317,9 @@ describe('uninstall', () => { expect(await fileExists(dirPath)).toBe(false); }); - it('returns true for non-existent directory (force mode)', async () => { - // fs.rm with { force: true } succeeds even if path doesn't exist + it('returns false for non-existent directory', async () => { const result = await removeDir(path.join(tmpDir, 'nope')); - expect(result).toBe(true); + expect(result).toBe(false); }); it('removes a symlinked directory without deleting its target', async () => { @@ -374,23 +493,149 @@ describe('uninstall', () => { const result = await removeCometRulesForPlatform(tmpDir, geminiPlatform, 'project'); expect(result.removed).toBe(0); }); + + it('counts a Rule-directory inspection permission failure without deleting user Rules', async () => { + const claudePlatform: Platform = PLATFORMS.find((p) => p.id === 'claude')!; + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + const userRule = path.join(rulesDir, 'personal.md'); + await fs.mkdir(rulesDir, { recursive: true }); + await fs.writeFile(userRule, '# Personal Rule\n'); + const readdir = fs.readdir.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation(async (dirPath, options) => { + if (path.resolve(String(dirPath)) === path.resolve(rulesDir)) throw permissionError; + return readdir(dirPath, options as never); + }); + + try { + await expect( + removeCometRulesForPlatform(tmpDir, claudePlatform, 'project'), + ).resolves.toEqual({ removed: 0, failed: 1 }); + } finally { + readdirSpy.mockRestore(); + } + + await expect(fs.readFile(userRule, 'utf8')).resolves.toBe('# Personal Rule\n'); + }); }); describe('removeCometHooksForPlatform', () => { - it('removes Codex hooks from .codex without creating .agents settings', async () => { - const codexPlatform = PLATFORMS.find((platform) => platform.id === 'codex')!; - await installCometHooksForPlatform(tmpDir, codexPlatform, 'project'); + it('removes Codex hooks from canonical and historical files while preserving user config', async () => { + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const userHandler = { type: 'command', command: 'node my-user-hook.mjs' }; + + await installCometHooksForPlatform(tmpDir, codex, 'project'); + const canonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + const cometHandler = canonical.hooks.PreToolUse[0].hooks[0]; + canonical.hooks.PreToolUse[0].hooks.push(userHandler); + await fs.writeFile(canonicalPath, JSON.stringify(canonical, null, 2), 'utf8'); + await fs.writeFile( + legacyPath, + JSON.stringify( + { + model: 'gpt-5', + hooks: { + PreToolUse: [{ matcher: 'Write|Edit', hooks: [cometHandler, userHandler] }], + }, + }, + null, + 2, + ), + 'utf8', + ); - const settingsPath = path.join(tmpDir, '.codex', 'settings.local.json'); - await expect(fs.access(settingsPath)).resolves.toBeUndefined(); - const result = await removeCometHooksForPlatform(tmpDir, codexPlatform, 'project'); + const result = await removeCometHooksForPlatform(tmpDir, codex, 'project'); - expect(result.removed).toBeGreaterThan(0); - const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8')); - expect(settings.hooks).toBeUndefined(); - await expect( - fs.access(path.join(tmpDir, '.agents', 'settings.local.json')), - ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(result).toEqual({ removed: 2, failed: 0 }); + const cleanedCanonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + expect(cleanedCanonical.hooks.PreToolUse[0].hooks).toEqual([userHandler]); + const cleanedLegacy = JSON.parse(await fs.readFile(legacyPath, 'utf8')); + expect(cleanedLegacy.model).toBe('gpt-5'); + expect(cleanedLegacy.hooks.PreToolUse[0].hooks).toEqual([userHandler]); + }); + + it('removes quoted Codex hook commands whose script path contains spaces', async () => { + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const managedPath = 'C:/Users/Jane Doe/.agents/skills/comet/scripts/comet-hook-guard.mjs'; + await fs.mkdir(path.dirname(canonicalPath), { recursive: true }); + await fs.writeFile( + canonicalPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: `node "${managedPath}" --project-root "C:/Users/Jane Doe"`, + }, + ], + }, + ], + }, + }, + null, + 2, + ), + 'utf8', + ); + + await expect(removeCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + removed: 1, + failed: 0, + }); + const cleaned = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + expect(cleaned.hooks.PreToolUse[0].hooks).toEqual([]); + }); + + it('continues Codex cleanup across files and counts only canonical write failures', async () => { + const codex = { + ...PLATFORMS.find((platform) => platform.id === 'codex')!, + legacyHookConfigFiles: ['settings.local.json', 'settings.backup.json'], + }; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const backupPath = path.join(tmpDir, '.codex', 'settings.backup.json'); + const userHandler = { type: 'command', command: 'node my-user-hook.mjs' }; + + await installCometHooksForPlatform(tmpDir, codex, 'project'); + const canonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + const cometHandler = canonical.hooks.PreToolUse[0].hooks[0]; + canonical.hooks.PreToolUse[0].hooks.push(userHandler); + await fs.writeFile(canonicalPath, JSON.stringify(canonical, null, 2), 'utf8'); + await fs.writeFile( + legacyPath, + JSON.stringify( + { + hooks: { + PreToolUse: [{ matcher: 'Write|Edit', hooks: [cometHandler, userHandler] }], + }, + }, + null, + 2, + ), + 'utf8', + ); + await fs.copyFile(legacyPath, backupPath); + writeFileMock + .mockRejectedValueOnce(new Error('simulated canonical write failure')) + .mockImplementationOnce(fs.writeFile) + .mockRejectedValueOnce(new Error('simulated backup write failure')); + + const result = await removeCometHooksForPlatform(tmpDir, codex, 'project'); + + expect(result).toEqual({ removed: 1, failed: 1 }); + const unchangedCanonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + expect(unchangedCanonical.hooks.PreToolUse[0].hooks).toEqual([cometHandler, userHandler]); + const cleanedLegacy = JSON.parse(await fs.readFile(legacyPath, 'utf8')); + expect(cleanedLegacy.hooks.PreToolUse[0].hooks).toEqual([userHandler]); + const unchangedBackup = JSON.parse(await fs.readFile(backupPath, 'utf8')); + expect(unchangedBackup.hooks.PreToolUse[0].hooks).toEqual([cometHandler, userHandler]); }); it('removes Claude Code hooks while preserving non-Comet hooks', async () => { @@ -497,7 +742,7 @@ describe('uninstall', () => { expect(result.removed).toBe(0); }); - it('cleans up empty hooks section after removal', async () => { + it('preserves empty hook groups after removal', async () => { const claudePlatform: Platform = PLATFORMS.find((p) => p.id === 'claude')!; const settingsDir = path.join(tmpDir, '.claude'); await fs.mkdir(settingsDir, { recursive: true }); @@ -525,7 +770,7 @@ describe('uninstall', () => { const updatedContent = await fs.readFile(settingsPath, 'utf-8'); const updated = JSON.parse(updatedContent); - expect(updated.hooks).toBeUndefined(); + expect(updated.hooks.PreToolUse).toEqual([{ matcher: 'Write|Edit', hooks: [] }]); }); }); @@ -658,6 +903,34 @@ describe('uninstallCommand interactive selection', () => { ).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('does not apply project registry recovery targets to an explicit global uninstall', async () => { + const fakeHome = path.join(tmpDir, 'global-scope-recovery-home'); + const opencode = PLATFORMS.find((platform) => platform.id === 'opencode')!; + const commandPath = path.join(tmpDir, '.opencode', 'commands', 'comet.md'); + await copyCometSkillsForPlatform(tmpDir, opencode, true, 'skills', 'project'); + await fs.rm(path.join(tmpDir, '.opencode', 'skills'), { recursive: true, force: true }); + await upsertProjectInstallation(tmpDir, [{ platform: 'opencode', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(tmpDir, { scope: 'global', force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.targets).toEqual([]); + } finally { + log.mockRestore(); + } + + await expect(fs.access(commandPath)).resolves.toBeUndefined(); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toHaveLength(1); + }); + it('does not auto-detect Codex from a shared canonical global Skill root', async () => { const fakeHome = path.join(tmpDir, 'fake-home'); const codexPlatform = PLATFORMS.find((platform) => platform.id === 'codex')!; @@ -718,6 +991,443 @@ describe('uninstallCommand interactive selection', () => { expect(registry.projects).toEqual([]); }); + it('removes Hook then Rule but keeps the Skill retry anchor when canonical Hook cleanup fails', async () => { + const fakeHome = path.join(tmpDir, 'hook-failure-home'); + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + await copyCometSkillsForPlatform(tmpDir, codex, true, 'skills', 'project'); + await copyCometRulesForPlatform(tmpDir, codex, true, 'en', 'project'); + await fs.writeFile(path.join(tmpDir, '.codex', 'hooks.json'), '[]\n', 'utf8'); + await upsertProjectInstallation(tmpDir, [{ platform: 'codex', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await uninstallCommand(tmpDir, { force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.targets[0]).toMatchObject({ + platform: 'codex', + hooksFailed: 1, + rulesRemoved: 1, + skillsRemoved: 0, + }); + expect(result.summary.totalFailures).toBeGreaterThan(0); + } finally { + log.mockRestore(); + } + + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'comet', 'SKILL.md')), + ).resolves.toBeUndefined(); + }); + + it('keeps the Skill retry anchor when canonical Rule cleanup fails after Hook removal', async () => { + const fakeHome = path.join(tmpDir, 'rule-failure-home'); + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + await copyCometSkillsForPlatform(tmpDir, codex, true, 'skills', 'project'); + await copyCometRulesForPlatform(tmpDir, codex, true, 'en', 'project'); + await installCometHooksForPlatform(tmpDir, codex, 'project'); + const rulePath = path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md'); + const unlink = fs.unlink.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (filePath) => { + if (path.resolve(String(filePath)) === path.resolve(rulePath)) throw permissionError; + await unlink(filePath); + }); + await upsertProjectInstallation(tmpDir, [{ platform: 'codex', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await uninstallCommand(tmpDir, { force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.targets[0]).toMatchObject({ + platform: 'codex', + hooksRemoved: 1, + rulesFailed: 1, + skillsRemoved: 0, + }); + } finally { + unlinkSpy.mockRestore(); + log.mockRestore(); + } + + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'comet', 'SKILL.md')), + ).resolves.toBeUndefined(); + }); + + it('counts working-directory cleanup failure and keeps the project registry entry', async () => { + const fakeHome = path.join(tmpDir, 'working-dir-failure-home'); + const claude = PLATFORMS.find((platform) => platform.id === 'claude')!; + await copyCometSkillsForPlatform(tmpDir, claude, true, 'skills', 'project'); + await fs.mkdir(path.join(tmpDir, '.comet'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.comet', 'state'), 'keep\n', 'utf8'); + await upsertProjectInstallation(tmpDir, [{ platform: 'claude', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const rm = fs.rm.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const rmSpy = vi.spyOn(fs, 'rm').mockImplementation(async (targetPath, options) => { + if (path.resolve(String(targetPath)) === path.resolve(path.join(tmpDir, '.comet'))) { + throw permissionError; + } + await rm(targetPath, options); + }); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await uninstallCommand(tmpDir, { force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.summary.totalFailures).toBe(1); + } finally { + rmSpy.mockRestore(); + log.mockRestore(); + } + + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toHaveLength(1); + await expect(fs.readFile(path.join(tmpDir, '.comet', 'state'), 'utf8')).resolves.toBe('keep\n'); + }); + + it('retries registered project cleanup after the Skill target was removed on the first attempt', async () => { + const fakeHome = path.join(tmpDir, 'working-dir-retry-home'); + const claude = PLATFORMS.find((platform) => platform.id === 'claude')!; + await copyCometSkillsForPlatform(tmpDir, claude, true, 'skills', 'project'); + await fs.mkdir(path.join(tmpDir, '.comet'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.comet', 'state'), 'retry\n', 'utf8'); + await upsertProjectInstallation(tmpDir, [{ platform: 'claude', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const rm = fs.rm.bind(fs); + let cometRemovalAttempts = 0; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const rmSpy = vi.spyOn(fs, 'rm').mockImplementation(async (targetPath, options) => { + if (path.resolve(String(targetPath)) === path.resolve(path.join(tmpDir, '.comet'))) { + cometRemovalAttempts++; + if (cometRemovalAttempts === 1) throw permissionError; + } + await rm(targetPath, options); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(tmpDir, { force: true, json: true }); + const firstResult = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(firstResult.summary.totalFailures).toBe(1); + const retainedRegistry = JSON.parse( + await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8'), + ) as { projects: unknown[] }; + expect(retainedRegistry.projects).toHaveLength(1); + log.mockClear(); + await uninstallCommand(tmpDir, { force: true, json: true }); + const retryResult = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(retryResult.summary).toMatchObject({ targetsProcessed: 1, totalFailures: 0 }); + expect(retryResult.workingDirsRemoved).toBe(1); + } finally { + log.mockRestore(); + rmSpy.mockRestore(); + } + + expect(cometRemovalAttempts).toBe(2); + await expect(fs.access(path.join(tmpDir, '.comet'))).rejects.toMatchObject({ code: 'ENOENT' }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('matches a registered current project through its canonical symlink identity', async () => { + const fakeHome = path.join(tmpDir, 'canonical-recovery-home'); + const realProject = path.join(tmpDir, 'canonical-real-project'); + const projectAlias = path.join(tmpDir, 'canonical-project-alias'); + await fs.mkdir(path.join(realProject, '.comet'), { recursive: true }); + await fs.writeFile(path.join(realProject, '.comet', 'state'), 'recover\n', 'utf8'); + await fs.symlink(realProject, projectAlias, process.platform === 'win32' ? 'junction' : 'dir'); + await upsertProjectInstallation(realProject, [{ platform: 'claude', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(projectAlias, { currentProject: true, force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.summary).toMatchObject({ targetsProcessed: 1, totalFailures: 0 }); + expect(result.workingDirsRemoved).toBe(1); + } finally { + log.mockRestore(); + } + + await expect(fs.access(path.join(realProject, '.comet'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('uses registry lastTargets to retry an OpenCode command cleanup for current-project', async () => { + const fakeHome = path.join(tmpDir, 'opencode-recovery-home'); + const opencode = PLATFORMS.find((platform) => platform.id === 'opencode')!; + const commandPath = path.join(tmpDir, '.opencode', 'commands', 'comet.md'); + await copyCometSkillsForPlatform(tmpDir, opencode, true, 'skills', 'project'); + await upsertProjectInstallation(tmpDir, [{ platform: 'opencode', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const unlink = fs.unlink.bind(fs); + let commandAttempts = 0; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (targetPath) => { + if (path.resolve(String(targetPath)) === path.resolve(commandPath)) { + commandAttempts++; + if (commandAttempts === 1) throw permissionError; + } + await unlink(targetPath); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(tmpDir, { currentProject: true, force: true, json: true }); + const first = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(first.summary.totalFailures).toBe(1); + await expect(fs.access(commandPath)).resolves.toBeUndefined(); + log.mockClear(); + + await uninstallCommand(tmpDir, { currentProject: true, force: true, json: true }); + const second = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(second.summary).toMatchObject({ targetsProcessed: 1, totalFailures: 0 }); + } finally { + log.mockRestore(); + unlinkSpy.mockRestore(); + } + + expect(commandAttempts).toBe(2); + await expect(fs.access(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('merges detected targets with registry recovery targets before retrying cleanup', async () => { + const fakeHome = path.join(tmpDir, 'detected-recovery-union-home'); + const opencode = PLATFORMS.find((platform) => platform.id === 'opencode')!; + const claude = PLATFORMS.find((platform) => platform.id === 'claude')!; + const commandPath = path.join(tmpDir, '.opencode', 'commands', 'comet.md'); + const claudeSkillPath = path.join(tmpDir, '.claude', 'skills', 'comet', 'SKILL.md'); + await copyCometSkillsForPlatform(tmpDir, opencode, true, 'skills', 'project'); + await copyCometSkillsForPlatform(tmpDir, claude, true, 'skills', 'project'); + await upsertProjectInstallation( + tmpDir, + [ + { platform: 'opencode', language: 'en' }, + { platform: 'claude', language: 'en' }, + ], + 'init', + { homeDir: fakeHome }, + ); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + mockedCheckbox.mockResolvedValue(['opencode:project'] as never); + const unlink = fs.unlink.bind(fs); + let commandAttempts = 0; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (targetPath) => { + if (path.resolve(String(targetPath)) === path.resolve(commandPath)) { + commandAttempts++; + if (commandAttempts === 1) throw permissionError; + } + await unlink(targetPath); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(tmpDir); + expect(commandAttempts).toBe(1); + await expect(fs.access(commandPath)).resolves.toBeUndefined(); + await expect( + fs.access(path.join(tmpDir, '.opencode', 'skills', 'comet')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(claudeSkillPath)).resolves.toBeUndefined(); + const retainedRegistry = JSON.parse( + await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8'), + ) as { projects: unknown[] }; + expect(retainedRegistry.projects).toHaveLength(1); + log.mockClear(); + + await uninstallCommand(tmpDir, { currentProject: true, force: true, json: true }); + const retry = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(retry.summary).toMatchObject({ targetsProcessed: 2, totalFailures: 0 }); + expect( + retry.targets.map((target: { scope: string; platform: string }) => ({ + scope: target.scope, + platform: target.platform, + })), + ).toEqual([ + { scope: 'project', platform: 'claude' }, + { scope: 'project', platform: 'opencode' }, + ]); + } finally { + log.mockRestore(); + unlinkSpy.mockRestore(); + } + + expect(commandAttempts).toBe(2); + await expect(fs.access(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('keeps detected global and recovered project targets separate for the same platform', async () => { + const fakeHome = path.join(tmpDir, 'detected-global-recovery-project-home'); + const opencode = PLATFORMS.find((platform) => platform.id === 'opencode')!; + const projectCommandPath = path.join(tmpDir, '.opencode', 'commands', 'comet.md'); + await copyCometSkillsForPlatform(tmpDir, opencode, true, 'skills', 'project'); + await fs.rm(path.join(tmpDir, '.opencode', 'skills'), { recursive: true, force: true }); + await copyCometSkillsForPlatform(fakeHome, opencode, true, 'skills', 'global'); + await upsertProjectInstallation(tmpDir, [{ platform: 'opencode', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(tmpDir, { currentProject: true, force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.summary).toMatchObject({ targetsProcessed: 2, totalFailures: 0 }); + expect( + result.targets.map((target: { scope: string; platform: string }) => ({ + scope: target.scope, + platform: target.platform, + })), + ).toEqual([ + { scope: 'global', platform: 'opencode' }, + { scope: 'project', platform: 'opencode' }, + ]); + } finally { + log.mockRestore(); + } + + await expect(fs.access(projectCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.access(path.join(fakeHome, '.opencode', 'skills', 'comet')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('runs follow-on cleanup for an all-projects registry entry with no remaining Skill target', async () => { + const fakeHome = path.join(tmpDir, 'all-projects-stale-home'); + const project = path.join(tmpDir, 'all-projects-stale-project'); + await fs.mkdir(path.join(project, '.comet'), { recursive: true }); + await fs.writeFile(path.join(project, '.comet', 'state'), 'stale\n', 'utf8'); + await fs.writeFile( + path.join(project, 'AGENTS.md'), + '\nmanaged\n\n', + 'utf8', + ); + await upsertProjectInstallation(project, [{ platform: 'claude', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(project, { allProjects: true, force: true, json: true }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.projects[0]).toMatchObject({ + projectPath: path.resolve(project), + status: 'uninstalled', + workingDirsRemoved: 1, + projectInstructionsRemoved: 1, + }); + } finally { + log.mockRestore(); + } + + await expect(fs.access(path.join(project, '.comet'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.readFile(path.join(project, 'AGENTS.md'), 'utf8')).resolves.not.toContain( + 'comet-ambient-resume', + ); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + + it('uses registry lastTargets to retry a Pi extension cleanup for all-projects', async () => { + const fakeHome = path.join(tmpDir, 'pi-all-projects-recovery-home'); + const project = path.join(tmpDir, 'pi-all-projects-recovery-project'); + const pi = PLATFORMS.find((platform) => platform.id === 'pi')!; + const extensionPath = path.join(project, '.pi', 'extensions', 'comet-commands.ts'); + await copyCometSkillsForPlatform(project, pi, true, 'skills', 'project'); + await upsertProjectInstallation(project, [{ platform: 'pi', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + homedirSpy.mockRestore(); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const unlink = fs.unlink.bind(fs); + let extensionAttempts = 0; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (targetPath) => { + if (path.resolve(String(targetPath)) === path.resolve(extensionPath)) { + extensionAttempts++; + if (extensionAttempts === 1) throw permissionError; + } + await unlink(targetPath); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await uninstallCommand(project, { allProjects: true, force: true, json: true }); + const first = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(first.projects[0].status).toBe('failed'); + await expect(fs.access(extensionPath)).resolves.toBeUndefined(); + log.mockClear(); + + await uninstallCommand(project, { allProjects: true, force: true, json: true }); + const second = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(second.projects[0]).toMatchObject({ + status: 'uninstalled', + summary: { targetsProcessed: 1, totalFailures: 0 }, + }); + } finally { + log.mockRestore(); + unlinkSpy.mockRestore(); + } + + expect(extensionAttempts).toBe(2); + await expect(fs.access(extensionPath)).rejects.toMatchObject({ code: 'ENOENT' }); + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf8')) as { + projects: unknown[]; + }; + expect(registry.projects).toEqual([]); + }); + it.each([true, false])( 'reports canonical Codex cleanup refusal and preserves project state in %s output', async (json) => { @@ -762,7 +1472,7 @@ describe('uninstallCommand interactive selection', () => { await expect( fs.access(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md')), - ).resolves.toBeUndefined(); + ).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.readFile(path.join(tmpDir, 'AGENTS.md'), 'utf8')).resolves.toContain( 'comet-ambient-resume', ); @@ -774,7 +1484,7 @@ describe('uninstallCommand interactive selection', () => { }, ); - it('stops follow-on cleanup when a legacy-only Codex root refuses removal', async () => { + it('removes Rules before preserving a legacy-only Codex Skill root that refuses removal', async () => { const sharedSkills = path.join(tmpDir, 'legacy-only-shared-skills'); await fs.mkdir(path.join(sharedSkills, 'comet'), { recursive: true }); await fs.writeFile(path.join(sharedSkills, 'comet', 'SKILL.md'), '# Legacy Comet\n'); @@ -798,8 +1508,8 @@ describe('uninstallCommand interactive selection', () => { log.mockRestore(); } await expect( - fs.readFile(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md'), 'utf8'), - ).resolves.toBe('# Keep Rule\n'); + fs.access(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md')), + ).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.lstat(path.join(tmpDir, '.codex', 'skills'))).resolves.toMatchObject({}); }); diff --git a/test/app/update.test.ts b/test/app/update.test.ts index 819fd9a1..851c0c53 100644 --- a/test/app/update.test.ts +++ b/test/app/update.test.ts @@ -43,6 +43,43 @@ const claudePlatform: Platform = { openspecToolId: 'claude', }; +type ComponentFailure = 'Skill' | 'Rule' | 'Hook'; + +async function arrangeComponentFailure( + projectPath: string, + failure: ComponentFailure, +): Promise<{ installMode: 'copy' | 'symlink' }> { + await fs.mkdir(path.join(projectPath, '.codex'), { recursive: true }); + + if (failure === 'Skill') { + await fs.mkdir(path.join(projectPath, '.codex', 'skills', 'comet'), { recursive: true }); + await fs.writeFile( + path.join(projectPath, '.codex', 'skills', 'comet', 'SKILL.md'), + '# Legacy Comet\n', + ); + await fs.mkdir(path.join(projectPath, '.agents', 'skills', 'comet'), { recursive: true }); + await fs.writeFile( + path.join(projectPath, '.agents', 'skills', 'comet', 'user-file.md'), + '# Keep\n', + ); + return { installMode: 'symlink' }; + } + + await fs.mkdir(path.join(projectPath, '.agents', 'skills', 'comet'), { recursive: true }); + await fs.writeFile( + path.join(projectPath, '.agents', 'skills', 'comet', 'SKILL.md'), + '# Comet\n\nUse this skill.\n', + ); + + if (failure === 'Rule') { + await fs.writeFile(path.join(projectPath, '.codex', 'rules'), 'blocking file'); + } else { + await fs.mkdir(path.join(projectPath, '.codex', 'hooks.json'), { recursive: true }); + } + + return { installMode: 'copy' }; +} + describe('update command helpers', () => { let tmpDir: string; @@ -161,7 +198,21 @@ describe('update command helpers', () => { const targets = await detectInstalledCometTargets(projectDir, { scopes: ['project'] }); - expect(targets.map((target) => target.platform.id)).not.toContain('codex'); + expect(targets.map((target) => target.platform.id)).toEqual(['antigravity']); + }); + + it('assigns a shared project .agents Skill root only to Codex when .codex evidence exists', async () => { + const projectDir = path.join(tmpDir, 'shared-agents-with-codex'); + await fs.mkdir(path.join(projectDir, '.agents', 'skills', 'comet'), { recursive: true }); + await fs.mkdir(path.join(projectDir, '.codex'), { recursive: true }); + await fs.writeFile( + path.join(projectDir, '.agents', 'skills', 'comet', 'SKILL.md'), + '# Comet\n', + ); + + const targets = await detectInstalledCometTargets(projectDir, { scopes: ['project'] }); + + expect(targets.map((target) => target.platform.id)).toEqual(['codex']); }); it('updates an explicitly scoped canonical global Codex install without a detection path', async () => { @@ -216,6 +267,32 @@ describe('update command helpers', () => { await fs.mkdir(legacyPersonal, { recursive: true }); await fs.writeFile(path.join(legacyComet, 'SKILL.md'), '# Comet\n\nUse this skill.'); await fs.writeFile(path.join(legacyPersonal, 'SKILL.md'), '# Personal\n'); + const legacyHookPath = path.join(tmpDir, '.codex', 'settings.local.json'); + await fs.mkdir(path.dirname(legacyHookPath), { recursive: true }); + await fs.writeFile( + legacyHookPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: 'node .codex/skills/comet/scripts/comet-hook-guard.mjs', + }, + { type: 'command', command: 'node my-user-hook.mjs' }, + ], + }, + ], + }, + }, + null, + 2, + ), + 'utf8', + ); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { @@ -232,17 +309,196 @@ describe('update command helpers', () => { await expect(fs.readFile(path.join(legacyPersonal, 'SKILL.md'), 'utf8')).resolves.toBe( '# Personal\n', ); - const settings = JSON.parse( - await fs.readFile(path.join(tmpDir, '.codex', 'settings.local.json'), 'utf8'), - ); - expect(settings.hooks.PreToolUse[0].hooks[0].command.replaceAll('\\', '/')).toContain( + const hooks = JSON.parse(await fs.readFile(path.join(tmpDir, '.codex', 'hooks.json'), 'utf8')); + expect(hooks.hooks.PreToolUse[0].hooks[0].command.replaceAll('\\', '/')).toContain( '/.agents/skills/comet/scripts/comet-hook-guard.mjs', ); + const legacy = JSON.parse( + await fs.readFile(path.join(tmpDir, '.codex', 'settings.local.json'), 'utf8'), + ); + expect(legacy.hooks.PreToolUse[0].hooks).toEqual([ + { type: 'command', command: 'node my-user-hook.mjs' }, + ]); await expect( fs.access(path.join(tmpDir, '.agents', 'settings.local.json')), ).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('does not update Codex hooks when the managed Hook script cannot be copied', async () => { + const fakeHome = path.join(tmpDir, 'hook-copy-failure-home'); + const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const skillDir = path.join(tmpDir, '.agents', 'skills', 'comet'); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# Comet\n\nUse this skill.'); + await fs.writeFile(path.join(skillDir, 'scripts'), 'blocking file'); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await updateCommand(tmpDir, { skipNpm: true, scope: 'project' }); + } finally { + log.mockRestore(); + homedirSpy.mockRestore(); + } + + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it.each(['Skill', 'Rule', 'Hook'])( + '%s failure is reported as incomplete in JSON and does not refresh the registry', + async (failure) => { + const fakeHome = path.join(tmpDir, `component-failure-json-${failure}`); + const options = await arrangeComponentFailure(tmpDir, failure); + await upsertProjectInstallation(tmpDir, [{ platform: 'codex', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await updateCommand(tmpDir, { + json: true, + skipNpm: true, + scope: 'project', + installMode: options.installMode, + }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.status).toBe('incomplete'); + expect(result.skills.totalFailed > 0).toBe(failure === 'Skill'); + expect(result.rules.totalFailed > 0).toBe(failure === 'Rule'); + expect(result.hooks.totalFailed > 0).toBe(failure === 'Hook'); + + const component = `${failure.toLowerCase()}s` as 'skills' | 'rules' | 'hooks'; + expect(result[component].targets[0].failed).toBeGreaterThan(0); + expect(result[component].targets[0].reason).toEqual(expect.any(String)); + } finally { + log.mockRestore(); + homedirSpy.mockRestore(); + } + + const registry = JSON.parse(await fs.readFile(getProjectRegistryPath(fakeHome), 'utf-8')) as { + projects: Array<{ lastSource: string }>; + }; + expect(registry.projects[0].lastSource).toBe('init'); + + if (failure === 'Skill') { + await expect( + fs.access(path.join(tmpDir, '.codex', 'rules', 'comet-phase-guard.md')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + }, + ); + + it.each(['Skill', 'Rule', 'Hook'])( + '%s failure is reported as incomplete in text output', + async (failure) => { + const fakeHome = path.join(tmpDir, `component-failure-text-${failure}`); + const options = await arrangeComponentFailure(tmpDir, failure); + const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await updateCommand(tmpDir, { + skipNpm: true, + scope: 'project', + installMode: options.installMode, + }); + const output = log.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toMatch(/incomplete/iu); + if (failure === 'Skill') { + expect(output).toContain( + 'Codex (project) Skill: failed (1) - 1 Skill file(s) failed to install', + ); + expect(output).not.toMatch(/Antigravity.*Skill: failed/u); + } else if (failure === 'Rule') { + expect(output).toContain( + 'Codex (project) Rule: failed (1) - 1 Rule file(s) failed to install', + ); + } else { + expect(output).toMatch( + /Codex \(project\) Hook: failed \(1\) - Invalid Codex settings at .*hooks\.json: EISDIR/iu, + ); + } + } finally { + log.mockRestore(); + homedirSpy.mockRestore(); + } + }, + ); + + it.each(['Skill', 'Rule', 'Hook'])( + '%s failure marks all-projects status failed', + async (failure) => { + const fakeHome = path.join(tmpDir, `component-failure-all-projects-${failure}`); + const project = path.join(tmpDir, `component-failure-project-${failure}`); + const options = await arrangeComponentFailure(project, failure); + await upsertProjectInstallation(project, [{ platform: 'codex', language: 'en' }], 'init', { + homeDir: fakeHome, + }); + const registryBefore = await fs.readFile(getProjectRegistryPath(fakeHome), 'utf-8'); + const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await updateCommand(project, { + allProjects: true, + json: true, + skipNpm: true, + installMode: options.installMode, + }); + const result = JSON.parse(log.mock.calls.map((call) => call.join(' ')).join('\n')); + expect(result.projects[0].status).toBe('failed'); + expect(result.projects[0].reason).toMatch(new RegExp(failure, 'iu')); + const failures = result.projects[0].failures as Array>; + if (failure === 'Skill') { + expect(failures).toEqual([ + expect.objectContaining({ + platformName: 'Codex', + scope: 'project', + component: 'Skill', + status: 'failed', + failed: 1, + reason: '1 Skill file(s) failed to install', + }), + ]); + } else if (failure === 'Rule') { + expect(failures).toContainEqual( + expect.objectContaining({ + platform: 'codex', + platformName: 'Codex', + scope: 'project', + component: 'Rule', + status: 'failed', + failed: 1, + reason: '1 Rule file(s) failed to install', + }), + ); + } else { + expect(failures).toContainEqual( + expect.objectContaining({ + platform: 'codex', + platformName: 'Codex', + scope: 'project', + component: 'Hook', + status: 'failed', + failed: 1, + reason: expect.stringMatching(/Invalid Codex settings at .*hooks\.json: EISDIR/iu), + }), + ); + } + } finally { + log.mockRestore(); + homedirSpy.mockRestore(); + } + + await expect(fs.readFile(getProjectRegistryPath(fakeHome), 'utf-8')).resolves.toBe( + registryBefore, + ); + }, + ); + it.each([true, false])( 'reports legacy Codex cleanup refusal as incomplete in %s output', async (json) => { @@ -267,6 +523,7 @@ describe('update command helpers', () => { const result = JSON.parse(output); expect(result.skills.cleanupFailed).toBeGreaterThan(0); expect(result.skills.targets[0].cleanupFailed).toBeGreaterThan(0); + expect(result.skills.targets[0].reason).toMatch(/cleanup/iu); } else { expect(output).toMatch(/incomplete|failed/iu); } diff --git a/test/domains/bundle/bundle-platform.test.ts b/test/domains/bundle/bundle-platform.test.ts index 44337693..16956a83 100644 --- a/test/domains/bundle/bundle-platform.test.ts +++ b/test/domains/bundle/bundle-platform.test.ts @@ -140,6 +140,24 @@ describe('Bundle platform compiler', () => { ]); }); + it('plans Codex hooks in .codex/hooks.json while scripts remain under .agents', async () => { + const codex = targets.find((target) => target.id === 'codex')!; + + const report = await compileBundleForPlatform(ir(), codex, { + projectRoot, + scope: 'project', + locale: 'zh', + }); + + expect(report.executableDisclosures).toEqual([ + expect.objectContaining({ + id: 'protect-write', + destination: path.join(projectRoot, '.codex', 'hooks.json'), + command: expect.stringContaining('.agents/skills'), + }), + ]); + }); + it('reports every required and optional capability gap without dropping it silently', async () => { const kimi = targets.find((target) => target.id === 'kimicode')!; diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index 67efd88a..20403f59 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -199,6 +199,8 @@ function legacyProjection(document: Record): Record { designDoc: 'docs/superpowers/specs/demo-design.md', plan: 'docs/superpowers/plans/demo-plan.md', verifyResult: 'pass', + verifyFailures: 0, verificationReport: 'docs/superpowers/verification/demo.md', branchStatus: 'handled', createdAt: '2026-06-14', diff --git a/test/domains/comet-classic/classic-hook-guard.test.ts b/test/domains/comet-classic/classic-hook-guard.test.ts index 2b8e4534..b3030c7a 100644 --- a/test/domains/comet-classic/classic-hook-guard.test.ts +++ b/test/domains/comet-classic/classic-hook-guard.test.ts @@ -3,7 +3,6 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; -import { parse } from 'yaml'; import { readRunState } from '../../../domains/engine/state.js'; const scriptsDir = path.resolve('assets', 'skills', 'comet', 'scripts'); @@ -71,7 +70,13 @@ async function seedChange( dir: string, name: string, phase: 'open' | 'design' | 'build' | 'verify' | 'archive', - options: { archived?: boolean; workflow?: 'full' | 'hotfix'; designDoc?: string | null } = {}, + options: { + archived?: boolean; + workflow?: 'full' | 'hotfix'; + designDoc?: string | null; + plan?: string | null; + verificationReport?: string | null; + } = {}, ): Promise { const changeDir = path.join(dir, 'openspec', 'changes', name); await fs.mkdir(changeDir, { recursive: true }); @@ -88,7 +93,8 @@ async function seedChange( `workflow: ${workflow}`, `phase: ${phase}`, `design_doc: ${designDoc ?? 'null'}`, - 'plan: null', + `plan: ${options.plan ?? 'null'}`, + `verification_report: ${options.verificationReport ?? 'null'}`, `build_mode: ${phase === 'open' || phase === 'design' ? 'null' : 'executing-plans'}`, `isolation: ${phase === 'open' || phase === 'design' ? 'null' : 'branch'}`, `verify_mode: ${phase === 'verify' || phase === 'archive' ? 'light' : 'null'}`, @@ -102,6 +108,253 @@ async function seedChange( } describe('Classic hook guard command', () => { + describe('standard Superpowers artifact first writes', () => { + it.each([ + { + label: 'design document', + changeName: 'design-change', + phase: 'design' as const, + target: ['specs', '2026-07-13-durable-retries-design.md'], + }, + { + label: 'implementation plan', + changeName: 'build-change', + phase: 'build' as const, + target: ['plans', '2026-07-13-durable-retries.md'], + }, + { + label: 'verification report', + changeName: 'verify-change', + phase: 'verify' as const, + target: ['reports', '2026-07-13-durable-retries-verify.md'], + }, + ])('allows a standard first $label write for a single active change', async (example) => { + const dir = await makeProject(); + await seedChange(dir, example.changeName, example.phase); + const target = path.join(dir, 'docs', 'superpowers', ...example.target); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(0); + expect(result.stderr).toContain(`phase: ${example.phase}, superpowers`); + }); + + it('allows the selected build change to create a standard plan with multiple active changes', async () => { + const dir = await makeProject(); + await seedChange(dir, 'build-change', 'build'); + await seedChange(dir, 'unrelated-design', 'design'); + expect(run(dir, 'state', ['select', 'build-change']).status).toBe(0); + const target = path.join( + dir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-durable-retries.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('phase: build, superpowers'); + }); + + it('requires selection before a standard plan write with multiple active changes', async () => { + const dir = await makeProject(); + await seedChange(dir, 'build-change', 'build'); + await seedChange(dir, 'unrelated-design', 'design'); + const target = path.join( + dir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-durable-retries.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('multiple active changes require a current change'); + expect(result.stderr).toContain('comet state select '); + }); + + it.each(['open', 'design', 'verify', 'archive'] as const)( + 'blocks a standard plan first write during %s', + async (phase) => { + const dir = await makeProject(); + await seedChange(dir, 'wrong-phase', phase); + const target = path.join( + dir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-durable-retries.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('Expected phase: build'); + expect(result.stderr).not.toContain('include the change name'); + }, + ); + + it('allows the recorded plan and blocks a second unrecorded plan', async () => { + const dir = await makeProject(); + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await seedChange(dir, 'occupied-plan', 'build', { plan: recorded }); + + const recordedResult = run( + dir, + 'hook-guard', + [], + hookInput(path.join(dir, ...recorded.split('/'))), + ); + const secondResult = run( + dir, + 'hook-guard', + [], + hookInput(path.join(dir, 'docs', 'superpowers', 'plans', '2026-07-13-second-feature.md')), + ); + + expect(recordedResult.status).toBe(0); + expect(secondResult.status).toBe(2); + expect(secondResult.stderr).toContain('plan is already recorded'); + expect(secondResult.stderr).toContain(recorded); + }); + + it('blocks a named standard plan when the governing change plan slot is occupied', async () => { + const dir = await makeProject(); + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await seedChange(dir, 'occupied-plan', 'build', { plan: recorded }); + const target = path.join( + dir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-occupied-plan-plan.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('plan is already recorded'); + expect(result.stderr).toContain(recorded); + }); + + it.skipIf(process.platform !== 'win32')( + 'treats a Windows case-variant standard plan as wrong-phase while preserving diagnostics', + async () => { + const dir = await makeProject(); + await seedChange(dir, 'windows-wrong-phase', 'design'); + const relativeTarget = 'Docs/superpowers/plans/2026-07-13-windows-wrong-phase-plan.md'; + + const result = run( + dir, + 'hook-guard', + [], + hookInput(path.join(dir, ...relativeTarget.split('/'))), + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('Expected phase: build'); + expect(result.stderr).toContain(`Target file: ${relativeTarget}`); + }, + ); + + it.skipIf(process.platform !== 'win32')( + 'blocks a Windows case-variant named plan when the slot is occupied', + async () => { + const dir = await makeProject(); + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await seedChange(dir, 'windows-occupied-plan', 'build', { plan: recorded }); + const target = path.join( + dir, + 'Docs', + 'superpowers', + 'plans', + '2026-07-13-windows-occupied-plan-plan.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('plan is already recorded'); + expect(result.stderr).toContain(recorded); + }, + ); + + it.skipIf(process.platform !== 'win32')( + 'allows a Windows case-variant of an exact recorded artifact path', + async () => { + const dir = await makeProject(); + const recorded = 'docs/superpowers/plans/2026-07-13-recorded.md'; + await seedChange(dir, 'recorded-case-plan', 'design', { plan: recorded }); + const target = path.join(dir, 'Docs', 'superpowers', 'plans', '2026-07-13-recorded.md'); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('phase: design, superpowers'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not treat a non-Windows case variant as an exact recorded artifact path', + async () => { + const dir = await makeProject(); + const recorded = 'docs/superpowers/plans/2026-07-13-recorded.md'; + await seedChange(dir, 'recorded-case-plan', 'design', { plan: recorded }); + const relativeTarget = 'Docs/superpowers/plans/2026-07-13-recorded.md'; + + const result = run( + dir, + 'hook-guard', + [], + hookInput(path.join(dir, ...relativeTarget.split('/'))), + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('source writes are not allowed during design'); + expect(result.stderr).toContain(`Target file: ${relativeTarget}`); + }, + ); + + it('fails closed for a stale selection before a standard plan first write', async () => { + const dir = await makeProject(); + await initializeGitProject(dir); + await seedChange(dir, 'build-change', 'build'); + await seedChange(dir, 'other-build', 'build'); + expect(run(dir, 'state', ['select', 'build-change']).status).toBe(0); + git(dir, ['switch', '-c', 'other']); + const target = path.join( + dir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-durable-retries.md', + ); + + const result = run(dir, 'hook-guard', [], hookInput(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('current change selection is stale or invalid'); + }); + + it.each([ + path.join('docs', 'superpowers', 'notes', '2026-07-13-note.md'), + path.join('docs', 'superpowers', 'plans', 'nested', '2026-07-13-plan.md'), + path.join('docs', 'superpowers', 'plans', '2026-07-13-plan.txt'), + ])('keeps non-standard Superpowers paths blocked: %s', async (target) => { + const dir = await makeProject(); + await seedChange(dir, 'build-change', 'build'); + + const result = run(dir, 'hook-guard', [], hookInput(path.join(dir, target))); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('unmatched Superpowers artifact'); + }); + }); + it('requires a current change before source writes with multiple active changes', async () => { const dir = await makeProject(); await seedChange(dir, 'build-ready', 'build'); @@ -268,26 +521,43 @@ describe('Classic hook guard command', () => { expect(result.stderr).toContain('allowed: no active comet change'); }); - it('blocks source writes in design and silently migrates the active change', async () => { + it('blocks source writes in design without migrating the active change or creating Run files', async () => { const dir = await makeProject(); - const changeDir = await seedDesignChange(dir); + const changeDir = await seedChange(dir, 'read-only-design', 'design'); + const stateFile = path.join(changeDir, '.comet.yaml'); + const before = await fs.readFile(stateFile, 'utf8'); const result = run(dir, 'hook-guard', [], hookInput(path.join(dir, 'src', 'index.ts'))); expect(result.status).toBe(2); expect(result.stderr).toContain('COMET PHASE GUARD'); expect(result.stderr).toContain('Current phase: design'); - const state = parse(await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8')) as Record< - string, - unknown - >; - expect(state).toMatchObject({ - classic_migration: 1, + expect(await fs.readFile(stateFile, 'utf8')).toBe(before); + expect(await readRunState(changeDir)).toBeNull(); + await expect(fs.access(path.join(changeDir, '.comet'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('leaves legacy command fields byte-for-byte unchanged while guarding', async () => { + const dir = await makeProject(); + const changeDir = await seedChange(dir, 'legacy-read-only', 'design'); + const stateFile = path.join(changeDir, '.comet.yaml'); + await fs.appendFile( + stateFile, + 'build_command: node legacy-build.js\nverify_command: node legacy-verify.js\n', + ); + const before = await fs.readFile(stateFile, 'utf8'); + + const result = run(dir, 'hook-guard', [], hookInput(path.join(dir, 'src', 'index.ts'))); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('Current phase: design'); + expect(await fs.readFile(stateFile, 'utf8')).toBe(before); + expect(await readRunState(changeDir)).toBeNull(); + await expect(fs.access(path.join(changeDir, '.comet'))).rejects.toMatchObject({ + code: 'ENOENT', }); - const runState = await readRunState(changeDir); - expect(runState).not.toBeNull(); - expect(runState!.skill).toBe('comet-classic'); - expect(runState!.currentStep).toBe('full.design.handoff'); }); it('allows OpenSpec artifact writes in design', async () => { diff --git a/test/domains/comet-classic/classic-migrate.test.ts b/test/domains/comet-classic/classic-migrate.test.ts index eb9ca69f..0595c893 100644 --- a/test/domains/comet-classic/classic-migrate.test.ts +++ b/test/domains/comet-classic/classic-migrate.test.ts @@ -63,6 +63,7 @@ function classic(overrides: Partial = {}): ClassicState { designDoc: null, plan: null, verifyResult: 'pending', + verifyFailures: 0, verificationReport: null, branchStatus: 'pending', createdAt: '2026-06-14', diff --git a/test/domains/comet-classic/classic-resolver.test.ts b/test/domains/comet-classic/classic-resolver.test.ts index 77b86d3c..223b222d 100644 --- a/test/domains/comet-classic/classic-resolver.test.ts +++ b/test/domains/comet-classic/classic-resolver.test.ts @@ -22,6 +22,7 @@ function state(overrides: Partial = {}): ClassicState { designDoc: null, plan: null, verifyResult: 'pending', + verifyFailures: 0, verificationReport: null, branchStatus: 'pending', createdAt: '2026-06-14', diff --git a/test/domains/comet-classic/classic-runtime.test.ts b/test/domains/comet-classic/classic-runtime.test.ts index af049cb2..42a24386 100644 --- a/test/domains/comet-classic/classic-runtime.test.ts +++ b/test/domains/comet-classic/classic-runtime.test.ts @@ -10,7 +10,6 @@ import { ensureClassicRuntimeRun } from '../../../domains/comet-classic/classic- const scriptsDir = path.resolve('assets', 'skills', 'comet', 'scripts'); const stateScript = path.join(scriptsDir, 'comet-state.mjs'); const validateScript = path.join(scriptsDir, 'comet-yaml-validate.mjs'); -const hookGuardScript = path.join(scriptsDir, 'comet-hook-guard.mjs'); const buildScript = path.resolve('scripts', 'build', 'build-classic-runtime.mjs'); const temporaryDirectories: string[] = []; @@ -466,6 +465,8 @@ describe('Classic script bundles', () => { cwd: directory, encoding: 'utf8', }); + const changeDir = path.join(directory, 'openspec', 'changes', 'demo'); + await ensureClassicRuntimeRun(changeDir); spawnSync(process.execPath, [stateScript, 'set', 'demo', 'phase', 'build'], { cwd: directory, encoding: 'utf8', @@ -479,15 +480,6 @@ describe('Classic script bundles', () => { cwd: directory, encoding: 'utf8', }); - const hook = spawnSync(process.execPath, [hookGuardScript], { - cwd: directory, - encoding: 'utf8', - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { file_path: 'src/index.ts' }, - }), - }); - expect(hook.status).toBe(0); await fs.mkdir(path.join(directory, 'docs'), { recursive: true }); await fs.writeFile(path.join(directory, 'docs', 'plan.md'), '- [ ] implement\n'); @@ -495,7 +487,6 @@ describe('Classic script bundles', () => { cwd: directory, encoding: 'utf8', }); - const changeDir = path.join(directory, 'openspec', 'changes', 'demo'); const runState = await readRunState(changeDir); expect(set.status).toBe(0); diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index 924d8e4f..147415d5 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -27,6 +27,7 @@ function classicState(): ClassicState { designDoc: 'docs/superpowers/specs/design.md', plan: 'docs/superpowers/plans/plan.md', verifyResult: 'fail', + verifyFailures: 2, verificationReport: 'docs/verification.md', branchStatus: 'handled', createdAt: '2026-06-01', diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index dcfb4f52..c9ed5dce 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -228,6 +228,21 @@ describe('comet scripts', () => { expect(yaml).toContain('branch_status: pending'); }, 20_000); + it.each(['hotfix', 'tweak'])( + 'initializes %s in the current workspace without claiming branch isolation', + async (workflow) => { + const result = runNode(tmpDir, stateScript, ['init', `${workflow}-current`, workflow]); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', `${workflow}-current`, '.comet.yaml'), + 'utf8', + ); + + expect(result.status).toBe(0); + expect(yaml).toContain('isolation: current'); + }, + 20_000, + ); + it('prints successful initialization to stdout so PowerShell does not surface NativeCommandError', async () => { const result = runNode(tmpDir, stateScript, ['init', 'powershell-friendly', 'full']); @@ -238,11 +253,14 @@ describe('comet scripts', () => { expect(result.stderr).toBe(''); }, 20_000); - it('loads the classic runtime package from COMET_RUNTIME_CLASSIC_ROOT', async () => { + it('keeps hook guard read-only when COMET_RUNTIME_CLASSIC_ROOT is configured', async () => { const init = runNode(tmpDir, stateScript, ['init', 'runtime-root', 'full'], { COMET_RUNTIME_CLASSIC_ROOT: classicRuntimeRoot, COMET_CLASSIC_SKILL_ROOT: '', }); + const changeDir = path.join(tmpDir, 'openspec', 'changes', 'runtime-root'); + const stateFile = path.join(changeDir, '.comet.yaml'); + const before = await fs.readFile(stateFile, 'utf8'); const targetFile = path.join(tmpDir, 'src', 'index.ts'); await fs.mkdir(path.dirname(targetFile), { recursive: true }); const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile), { @@ -252,11 +270,10 @@ describe('comet scripts', () => { expect(init.status).toBe(0); expect(result.status).toBe(2); - const runState = await fs.readFile( - path.join(tmpDir, 'openspec', 'changes', 'runtime-root', '.comet', 'run-state.json'), - 'utf8', - ); - expect(JSON.parse(runState)).toMatchObject({ skill: 'comet-classic' }); + expect(await fs.readFile(stateFile, 'utf8')).toBe(before); + await expect(fs.access(path.join(changeDir, '.comet'))).rejects.toMatchObject({ + code: 'ENOENT', + }); }, 20_000); it('keeps COMET_CLASSIC_SKILL_ROOT as a compatibility fallback', async () => { @@ -2041,7 +2058,10 @@ describe('comet scripts', () => { expect(guard.status).not.toBe(0); expect(guard.stderr).toContain('[FAIL] isolation selected'); expect(guard.stderr).toContain('[FAIL] build_mode selected'); - expect(guard.stderr).toContain('Next: ask the user to choose branch or worktree'); + expect(guard.stderr).toContain('Next: choose a valid workspace mode'); + expect(guard.stderr).toContain( + 'comet state set missing-build-decisions isolation ', + ); expect(guard.stderr).toContain('Next: ask the user to choose an execution mode'); expect(transition.status).not.toBe(0); expect(transition.stderr).toContain('isolation must be branch or worktree'); @@ -2541,6 +2561,8 @@ describe('comet scripts', () => { expect(guard.status).not.toBe(0); expect(guard.stderr).toContain('[FAIL] subagent dispatch confirmed'); expect(guard.stderr).toContain('subagent_dispatch must be confirmed'); + expect(guard.stderr).toContain('return to /comet-build Step 2'); + expect(guard.stderr).not.toContain('ask the user to switch'); expect(transition.status).not.toBe(0); expect(transition.stderr).toContain('subagent_dispatch must be confirmed'); }, 20_000); @@ -2812,23 +2834,44 @@ describe('comet scripts', () => { [ 'workflow: full', 'phase: archive', + 'context_compression: off', 'build_mode: executing-plans', 'build_pause: null', - 'tdd_mode: null', + 'subagent_dispatch: null', + 'tdd_mode: tdd', + 'review_mode: off', 'isolation: branch', 'verify_mode: light', + 'base_ref: null', 'design_doc: null', 'plan: null', 'verify_result: pass', + 'verification_report: null', + 'branch_status: pending', + 'auto_transition: true', + 'created_at: 2026-05-21', 'verified_at: 2026-05-21', 'archived: true', + 'direct_override: null', + 'handoff_context: null', + 'handoff_hash: null', '', ].join('\n'), ); + const pending = runNode(tmpDir, guardScript, ['done-change', 'archive']); + const handled = runNode(tmpDir, stateScript, [ + 'set', + 'done-change', + 'branch_status', + 'handled', + ]); const result = runNode(tmpDir, guardScript, ['done-change', 'archive']); - expect(result.status).toBe(0); + expect(pending.status).not.toBe(0); + expect(pending.stderr).toContain('[FAIL] branch_status=handled'); + expect(handled.status).toBe(0); + expect(result.status, result.stderr).toBe(0); expect(result.stderr).toContain('ALL CHECKS PASSED'); }); @@ -3570,18 +3613,28 @@ describe('comet scripts', () => { ].join('\n'), ); + const manualFailureCount = runNode(tmpDir, stateScript, [ + 'set', + 'verify-change', + 'verify_failures', + '7', + ]); const fail = runNode(tmpDir, stateScript, ['transition', 'verify-change', 'verify-fail']); const failedPhase = runNode(tmpDir, stateScript, ['get', 'verify-change', 'phase']); const failedResult = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verify_result']); + const failedCount = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verify_failures']); const failedBranchStatus = runNode(tmpDir, stateScript, [ 'get', 'verify-change', 'branch_status', ]); + expect(manualFailureCount.status).not.toBe(0); + expect(manualFailureCount.stderr).toContain('machine-owned field'); expect(fail.status).toBe(0); expect(failedPhase.stdout.trim()).toBe('build'); expect(failedResult.stdout.trim()).toBe('fail'); + expect(failedCount.stdout.trim()).toBe('1'); expect(failedBranchStatus.stdout.trim()).toBe('pending'); const forceVerify = runNode(tmpDir, stateScript, ['set', 'verify-change', 'phase', 'verify'], { @@ -3599,11 +3652,10 @@ describe('comet scripts', () => { 'verification_report', 'docs/superpowers/reports/verify-change.md', ]); - runNode(tmpDir, stateScript, ['set', 'verify-change', 'branch_status', 'handled']); - const pass = runNode(tmpDir, stateScript, ['transition', 'verify-change', 'verify-pass']); const passedPhase = runNode(tmpDir, stateScript, ['get', 'verify-change', 'phase']); const passedResult = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verify_result']); + const passedCount = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verify_failures']); const verifiedAt = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verified_at']); const archiveConfirmation = runNode(tmpDir, stateScript, [ 'get', @@ -3614,6 +3666,10 @@ describe('comet scripts', () => { expect(pass.status).toBe(0); expect(passedPhase.stdout.trim()).toBe('archive'); expect(passedResult.stdout.trim()).toBe('pass'); + expect(passedCount.stdout.trim()).toBe('0'); + expect( + runNode(tmpDir, stateScript, ['get', 'verify-change', 'branch_status']).stdout.trim(), + ).toBe('pending'); expect(verifiedAt.stdout.trim()).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(archiveConfirmation.stdout.trim()).toBe('pending'); }, 20_000); @@ -3715,7 +3771,7 @@ describe('comet scripts', () => { expect(verifyResult.stdout.trim()).toBe('pending'); expect(verifiedAt.stdout.trim()).toBe('null'); expect(report.stdout.trim()).toBe('docs/superpowers/reports/archive-reopen.md'); - expect(branchStatus.stdout.trim()).toBe('handled'); + expect(branchStatus.stdout.trim()).toBe('pending'); expect(confirmation.stdout.trim()).toBe('null'); }, 20_000); @@ -3799,7 +3855,7 @@ describe('comet scripts', () => { expect(result.status).not.toBe(0); expect(result.stderr).toContain('[FAIL] verification_report exists'); - expect(result.stderr).toContain('[FAIL] branch_status=handled'); + expect(result.stderr).not.toContain('branch_status=handled'); expect(phase.stdout.trim()).toBe('verify'); }, 20_000); @@ -3918,12 +3974,20 @@ describe('comet scripts', () => { const escalatedWorkflow = runNode(tmpDir, stateScript, ['get', name, 'workflow']); const profile = runNode(tmpDir, stateScript, ['get', name, 'classic_profile']); const designDoc = runNode(tmpDir, stateScript, ['get', name, 'design_doc']); + const buildMode = runNode(tmpDir, stateScript, ['get', name, 'build_mode']); + const tddMode = runNode(tmpDir, stateScript, ['get', name, 'tdd_mode']); + const reviewMode = runNode(tmpDir, stateScript, ['get', name, 'review_mode']); + const isolation = runNode(tmpDir, stateScript, ['get', name, 'isolation']); expect(result.status).toBe(0); expect(phase.stdout.trim()).toBe('design'); expect(escalatedWorkflow.stdout.trim()).toBe('full'); expect(profile.stdout.trim()).toBe('full'); expect(designDoc.stdout.trim()).toBe('null'); + expect(buildMode.stdout.trim()).toBe('null'); + expect(tddMode.stdout.trim()).toBe('null'); + expect(reviewMode.stdout.trim()).toBe('null'); + expect(isolation.stdout.trim()).toBe('null'); } }, 20_000); @@ -4387,7 +4451,7 @@ describe('comet scripts', () => { 'plan: null', 'verify_result: pass', 'verification_report: docs/superpowers/reports/recover-verify.md', - 'branch_status: handled', + 'branch_status: pending', 'verified_at: null', 'archived: false', '', @@ -4404,8 +4468,10 @@ describe('comet scripts', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('Phase: verify'); expect(result.stdout).toContain('verify_result: DONE (pass)'); - expect(result.stdout).toContain('branch_status: DONE (handled)'); - expect(result.stdout).toContain('guard to transition to archive'); + expect(result.stdout).toContain('branch_status: DEFERRED (handled after the archive commit)'); + expect(result.stdout).toContain( + 'Continue to archive; branch handling happens after archive changes are committed', + ); }); it('outputs recovery context for design phase with handoff but no design doc', async () => { @@ -4638,12 +4704,12 @@ describe('comet scripts', () => { ); expect(yaml).toContain('verify_result: pending'); expect(yaml).toContain('verification_report: docs/report.md'); - expect(yaml).toContain('branch_status: handled'); + expect(yaml).toContain('branch_status: pending'); }); }); - describe('review fix: verify-fail preserves branch_status', () => { - it('does not reset branch_status on verify-fail (H6)', async () => { + describe('verify-fail invalidates premature branch handling', () => { + it('resets branch_status so archive owns final branch handling', async () => { await createChange( tmpDir, 'branch-preserve', @@ -4678,7 +4744,7 @@ describe('comet scripts', () => { ); expect(yaml).toContain('verify_result: fail'); expect(yaml).toContain('phase: build'); - expect(yaml).toContain('branch_status: handled'); + expect(yaml).toContain('branch_status: pending'); }); }); @@ -4887,6 +4953,48 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }, 20_000); + it.each([ + ['valid pre-Run state', ''], + [ + 'legacy command fields', + 'build_command: node legacy-build.js\nverify_command: node legacy-verify.js\n', + ], + ])('does not mutate %s or create distributed Run files', async (_label, legacyFields) => { + const changeDir = await createChange( + tmpDir, + 'read-only-hook', + [ + 'workflow: full', + 'phase: design', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: null', + 'verify_mode: null', + 'verify_result: pending', + 'verification_report: null', + 'verified_at: null', + 'archived: false', + legacyFields, + ].join('\n'), + ); + const stateFile = path.join(changeDir, '.comet.yaml'); + const before = await fs.readFile(stateFile, 'utf8'); + + const result = runHookGuard( + tmpDir, + hookGuardScript, + hookStdin(path.join(tmpDir, 'src', 'feature.ts')), + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('Current phase: design'); + expect(await fs.readFile(stateFile, 'utf8')).toBe(before); + await expect(fs.access(path.join(changeDir, '.comet'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + it('allows writes to openspec/ in design phase', async () => { await createChange( tmpDir, @@ -4960,6 +5068,148 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }, 20_000); + it('allows the first standard Superpowers plan write without a private suffix', async () => { + await createChange( + tmpDir, + 'standard-plan-write', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/standard-design.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: branch', + 'verify_mode: null', + 'verify_result: pending', + 'verification_report: null', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + const target = path.join( + tmpDir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-durable-retries.md', + ); + + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(target)); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('phase: build, superpowers'); + }, 20_000); + + it('blocks a second write after the standard Superpowers plan slot is occupied', async () => { + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await createChange( + tmpDir, + 'occupied-standard-plan', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/occupied-standard-plan-design.md', + `plan: ${recorded}`, + 'build_mode: executing-plans', + 'isolation: branch', + 'verify_mode: null', + 'verify_result: pending', + 'verification_report: null', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + const target = path.join( + tmpDir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-second-feature.md', + ); + + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('plan is already recorded'); + expect(result.stderr).toContain(recorded); + }, 20_000); + + it('blocks a named standard plan after the distributed plan slot is occupied', async () => { + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await createChange( + tmpDir, + 'occupied-standard-plan', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/occupied-standard-plan-design.md', + `plan: ${recorded}`, + 'build_mode: executing-plans', + 'isolation: branch', + 'verify_mode: null', + 'verify_result: pending', + 'verification_report: null', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + const target = path.join( + tmpDir, + 'docs', + 'superpowers', + 'plans', + '2026-07-13-occupied-standard-plan-plan.md', + ); + + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('plan is already recorded'); + expect(result.stderr).toContain(recorded); + }, 20_000); + + it.skipIf(process.platform !== 'win32')( + 'blocks a Windows case-variant named plan after the distributed slot is occupied', + async () => { + const recorded = 'docs/superpowers/plans/2026-07-13-existing.md'; + await createChange( + tmpDir, + 'windows-occupied-plan', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/windows-occupied-plan-design.md', + `plan: ${recorded}`, + 'build_mode: executing-plans', + 'isolation: branch', + 'verify_mode: null', + 'verify_result: pending', + 'verification_report: null', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + const target = path.join( + tmpDir, + 'Docs', + 'superpowers', + 'plans', + '2026-07-13-windows-occupied-plan-plan.md', + ); + + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(target)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('plan is already recorded'); + expect(result.stderr).toContain(recorded); + }, + 20_000, + ); + it('blocks source code writes in design phase', async () => { await createChange( tmpDir, @@ -5327,7 +5577,19 @@ describe('comet scripts', () => { await createChange( tmpDir, 'env-issue-ledger', - ['workflow: full', 'phase: design', 'archived: false', ''].join('\n'), + [ + 'workflow: full', + 'phase: design', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: null', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), ); const docsDir = path.join(tmpDir, 'docs', 'superpowers', 'specs'); @@ -5349,7 +5611,19 @@ describe('comet scripts', () => { await createChange( tmpDir, 'auth-v2', - ['workflow: full', 'phase: design', 'archived: false', ''].join('\n'), + [ + 'workflow: full', + 'phase: design', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: null', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), ); const docsDir = path.join(tmpDir, 'docs', 'superpowers', 'specs'); @@ -5362,7 +5636,7 @@ describe('comet scripts', () => { expect(result.stderr).toContain('phase: design, superpowers'); }, 20_000); - it('does not route unmatched docs/superpowers writes to an unrelated eligible change', async () => { + it('requires selection before a standard specs write with multiple active changes', async () => { await createChange( tmpDir, 'a-open-change', @@ -5381,10 +5655,11 @@ describe('comet scripts', () => { const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile)); expect(result.status).toBe(2); - expect(result.stderr).toContain('Current phase: open'); + expect(result.stderr).toContain('multiple active changes require a current change'); + expect(result.stderr).toContain('comet state select '); }, 20_000); - it('blocks unmatched docs/superpowers writes when all active changes are eligible phases', async () => { + it('requires selection before an unmatched standard specs write with multiple eligible changes', async () => { await createChange( tmpDir, 'auth', @@ -5409,7 +5684,8 @@ describe('comet scripts', () => { const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile)); expect(result.status).toBe(2); - expect(result.stderr).toContain('unmatched Superpowers artifact'); + expect(result.stderr).toContain('multiple active changes require a current change'); + expect(result.stderr).toContain('comet state select '); }, 20_000); it('requires a current change for repo source writes with multiple active changes', async () => { diff --git a/test/domains/factory/factory-package.test.ts b/test/domains/factory/factory-package.test.ts index a917141d..2f57b0f1 100644 --- a/test/domains/factory/factory-package.test.ts +++ b/test/domains/factory/factory-package.test.ts @@ -16,6 +16,14 @@ import { const execFileAsync = promisify(execFile); +function frontmatterDescription(source: string): string { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/u); + expect(match).not.toBeNull(); + const document = parse(match![1]!) as { description?: unknown }; + expect(typeof document.description).toBe('string'); + return document.description as string; +} + function packagePlan(options: { root: string; name: string; @@ -109,9 +117,16 @@ describe('Factory skill package generation', () => { path.join(output.packageRoot, 'reference', 'composition-report.md'), 'utf8', ); + const decisionPoints = await fs.readFile( + path.join(output.packageRoot, 'reference', 'decision-points.md'), + 'utf8', + ); expect(output.wrapperClassification).toBe('scaffold-blocked'); expect(compositionReport).toContain('Wrapper classification: scaffold-blocked'); + expect(decisionPoints).toContain('No decision points have been authored'); + expect(decisionPoints).toContain('pause only when at least two valid choices'); + expect(decisionPoints).not.toContain('confirm Output Schemas'); }); it('generates Claude Code custom agent definitions separately from portable role briefs', async () => { @@ -199,6 +214,18 @@ describe('Factory skill package generation', () => { expect(entry).toContain('Output Schemas'); expect(entry).not.toContain('workflow-state.mjs init'); expect(entry).toContain('/comet-open'); + expect(frontmatterDescription(entry)).toContain('team-comet managed workflow'); + expect(frontmatterDescription(entry)).toContain('Route through this entry Skill'); + const openNode = await fs.readFile( + path.join(output.packageRoot, '..', 'team-comet-open', 'SKILL.md'), + 'utf8', + ); + expect(frontmatterDescription(openNode)).toContain( + 'Use only when explicitly invoked as /team-comet-open or routed by the team-comet entry/runtime', + ); + expect(frontmatterDescription(openNode)).toContain( + 'Do not use for ordinary standalone tasks or as the workflow entry', + ); expect(skillYaml.orchestration?.steps?.[0]?.action?.ref).toBe('team-comet-open'); const runRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-workflow-contract-run-')); diff --git a/test/domains/integrations/openspec.test.ts b/test/domains/integrations/openspec.test.ts index e197ce7e..a22e3288 100644 --- a/test/domains/integrations/openspec.test.ts +++ b/test/domains/integrations/openspec.test.ts @@ -38,6 +38,36 @@ describe('openspec', () => { }); }); + describe('OpenSpec CLI compatibility', () => { + it.each([ + ['1.5.0', true], + ['OpenSpec 1.5.1', true], + ['v2.0.0', true], + ['1.4.9', false], + ['1.5.0-beta.1', false], + ['unknown', false], + ])('evaluates %s against the minimum supported version', async (version, compatible) => { + const { isOpenSpecVersionCompatible } = + await import('../../../domains/integrations/openspec.js'); + + expect(isOpenSpecVersionCompatible(version)).toBe(compatible); + }); + + it('rejects a stale existing CLI when upgrade fails', async () => { + mockedExecFileSync.mockReturnValueOnce(Buffer.from('/usr/bin/openspec')); + mockedExecFileSync.mockImplementationOnce(() => { + throw new Error('npm upgrade failed'); + }); + mockedExecFileSync.mockReturnValueOnce(Buffer.from('1.3.1')); + + const { installOpenSpec } = await import('../../../domains/integrations/openspec.js'); + const result = await installOpenSpec('/tmp/test', ['claude'], 'project'); + + expect(result).toBe('failed'); + expect(mockedExecFileSync).toHaveBeenCalledTimes(3); + }); + }); + describe('installOpenSpec', () => { it('accepts the Kimi OpenSpec tool id from platform definitions', async () => { mockedExecFileSync.mockReturnValueOnce(Buffer.from('/usr/bin/openspec')); @@ -422,7 +452,9 @@ describe('openspec', () => { mockedExecFileSync.mockImplementationOnce(() => { throw new Error('npm upgrade failed'); }); - // Third call: openspec init fails with stderr + // Third call: existing OpenSpec version is compatible + mockedExecFileSync.mockReturnValueOnce(Buffer.from('1.5.0')); + // Fourth call: openspec init fails with stderr const error = new Error('Command failed: openspec init ...') as Error & { stderr?: Buffer }; error.stderr = Buffer.from('network timeout while fetching OpenSpec skills'); mockedExecFileSync.mockImplementationOnce(() => { @@ -447,7 +479,9 @@ describe('openspec', () => { mockedExecFileSync.mockImplementationOnce(() => { throw new Error('npm upgrade failed'); }); - // Third call: openspec init fails with timeout + // Third call: existing OpenSpec version is compatible + mockedExecFileSync.mockReturnValueOnce(Buffer.from('1.5.0')); + // Fourth call: openspec init fails with timeout const error = new Error('Command failed: openspec init ...') as Error & { stderr?: Buffer; code?: string; diff --git a/test/domains/skill/comet-open-batch-completion.test.ts b/test/domains/skill/comet-open-batch-completion.test.ts new file mode 100644 index 00000000..398839ba --- /dev/null +++ b/test/domains/skill/comet-open-batch-completion.test.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('comet-open 批量拆分完成协议', () => { + it('由 OpenSpec CLI 状态驱动 artifact 生成,不硬编码 schema 顺序', async () => { + const skill = await readFile( + path.resolve('assets', 'skills-zh', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('不得硬编码生成顺序'); + expect(skill).toContain('从尚未完成且为 `status: "ready"` 的 artifacts 中'); + expect(skill).toContain('推进 `applyRequires` 依赖闭包'); + expect(skill).not.toContain( + '**标准产物循环**(对每个 `artifact-id`:`proposal` → `design` → `tasks`)', + ); + }); + + it('所有拆分项通过 CLI 完成检查后才允许宣告批量拆分完成', async () => { + const skill = await readFile( + path.resolve('assets', 'skills-zh', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('openspec status --change "" --json'); + expect(skill).toContain('`applyRequires` 列出的每个 artifact'); + expect(skill).toContain('`isComplete` 仅作诊断信息'); + expect(skill).toContain('任一拆分项未通过检查时,不得宣告拆分完成'); + }); +}); diff --git a/test/domains/skill/comet-open-english-batch-completion.test.ts b/test/domains/skill/comet-open-english-batch-completion.test.ts new file mode 100644 index 00000000..7cfa4c21 --- /dev/null +++ b/test/domains/skill/comet-open-english-batch-completion.test.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('comet-open English batch completion protocol', () => { + it('uses the OpenSpec status graph instead of a hard-coded artifact order', async () => { + const skill = await readFile( + path.resolve('assets', 'skills', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('From unfinished `ready` artifacts'); + expect(skill).toContain('Must not hard-code generation order'); + expect(skill).toContain('advance the `applyRequires` dependency closure'); + expect(skill).not.toContain( + '**Standard Artifact Loop** (for each `artifact-id`: `proposal` → `design` → `tasks`)', + ); + }); + + it('requires every split item to pass the CLI completion checks', async () => { + const skill = await readFile( + path.resolve('assets', 'skills', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('openspec status --change "" --json'); + expect(skill).toContain('Every artifact listed in `applyRequires` must be `done`'); + expect(skill).toContain('Treat `isComplete` as diagnostic only'); + expect(skill).toContain('If any split item fails these checks'); + expect(skill).toContain('comet state check design'); + }); + + it('defines an explicit recovery action for done, ready, and blocked artifacts', async () => { + const skill = await readFile( + path.resolve('assets', 'skills', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('On recovery, process the status in this order'); + expect(skill).toContain('`done`: keep the artifact unchanged and do not regenerate it'); + expect(skill).toContain('`ready`: fetch its instructions'); + expect(skill).toContain('`blocked`: follow `missingDeps`'); + expect(skill).toContain('dependencies in the `applyRequires` closure'); + expect(skill).toContain('until every item in `applyRequires` is `done`'); + expect(skill).toContain('an optional artifact outside `applyRequires` must not block'); + }); +}); diff --git a/test/domains/skill/comet-open-recovery-semantics.test.ts b/test/domains/skill/comet-open-recovery-semantics.test.ts new file mode 100644 index 00000000..842137fa --- /dev/null +++ b/test/domains/skill/comet-open-recovery-semantics.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('comet-open 恢复语义', () => { + it('明确说明 done、ready 和 blocked 对应的恢复动作', async () => { + const skill = await readFile( + path.resolve('assets', 'skills-zh', 'comet-open', 'SKILL.md'), + 'utf8', + ); + + expect(skill).toContain('恢复时按以下顺序处理'); + expect(skill).toContain('`done`:该 artifact 已完成,保持原文件不变'); + expect(skill).toContain('`ready`:依赖已经满足,可以生成'); + expect(skill).toContain('`blocked`:读取 `missingDeps`'); + expect(skill).toContain('先完成属于 `applyRequires` 依赖闭包的依赖 artifact'); + expect(skill).toContain('直到 `applyRequires` 全部为 `done`'); + expect(skill).toContain('非 `applyRequires` 的可选 artifact'); + }); +}); diff --git a/test/domains/skill/platform-inspect.test.ts b/test/domains/skill/platform-inspect.test.ts new file mode 100644 index 00000000..3900cf09 --- /dev/null +++ b/test/domains/skill/platform-inspect.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + getPlatformRuleDestinations, + inspectCometHooksForPlatform, +} from '../../../domains/skill/platform-inspect.js'; +import { installCometHooksForPlatform } from '../../../domains/skill/platform-install.js'; +import { PLATFORMS, type Platform } from '../../../platform/install/platforms.js'; + +function platform(id: string): Platform { + const found = PLATFORMS.find((candidate) => candidate.id === id); + if (!found) throw new Error(`missing platform fixture: ${id}`); + return found; +} + +function hookConfigPath(baseDir: string, platformId: string): string { + switch (platformId) { + case 'claude': + return path.join(baseDir, '.claude', 'settings.local.json'); + case 'codex': + return path.join(baseDir, '.codex', 'hooks.json'); + case 'qwen': + return path.join(baseDir, '.qwen', 'settings.json'); + case 'gemini': + return path.join(baseDir, '.gemini', 'settings.json'); + case 'windsurf': + return path.join(baseDir, '.windsurf', 'hooks.json'); + case 'github-copilot': + return path.join(baseDir, '.github', 'hooks', 'comet-guard.json'); + case 'kiro': + return path.join(baseDir, '.kiro', 'hooks', 'comet-hook-guard.kiro.hook'); + default: + throw new Error(`missing Hook path fixture: ${platformId}`); + } +} + +async function installManagedHookScripts(baseDir: string, target: Platform): Promise { + const manifest = JSON.parse( + await fs.readFile(path.resolve('assets', 'manifest.json'), 'utf8'), + ) as { hooks?: Record }; + for (const scriptRelPath of Object.keys(manifest.hooks ?? {})) { + const scriptPath = path.join(baseDir, target.skillsDir, 'skills', ...scriptRelPath.split('/')); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile(scriptPath, '// managed Hook script\n', 'utf8'); + } +} + +describe('platform component inspection', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-platform-inspect-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it.each([ + ['claude', '.claude/rules/comet-phase-guard.md'], + ['cursor', '.cursor/rules/comet-phase-guard.mdc'], + ['codex', '.codex/rules/comet-phase-guard.md'], + ['github-copilot', '.github/instructions/comet-phase-guard.instructions.md'], + ])( + 'returns the normalized language-independent Rule destination for %s', + async (id, relative) => { + const destinations = await getPlatformRuleDestinations(tmpDir, platform(id), 'project'); + + expect(destinations).toEqual([path.join(tmpDir, ...relative.split('/'))]); + expect(await fs.readdir(tmpDir)).toEqual([]); + }, + ); + + it('returns no Rule destinations for an unsupported platform without changing disk', async () => { + await expect( + getPlatformRuleDestinations(tmpDir, platform('gemini'), 'project'), + ).resolves.toEqual([]); + expect(await fs.readdir(tmpDir)).toEqual([]); + }); + + it.each(['claude', 'codex', 'qwen', 'gemini', 'windsurf', 'github-copilot', 'kiro'])( + 'recognizes the managed Hook command in the %s format', + async (id) => { + const target = platform(id); + await installManagedHookScripts(tmpDir, target); + await expect(installCometHooksForPlatform(tmpDir, target, 'project')).resolves.toMatchObject({ + status: 'installed', + }); + const configPath = hookConfigPath(tmpDir, id); + const before = await fs.readFile(configPath, 'utf8'); + + await expect(inspectCometHooksForPlatform(tmpDir, target, 'project')).resolves.toEqual({ + present: true, + }); + expect(await fs.readFile(configPath, 'utf8')).toBe(before); + }, + ); + + it.each(['claude', 'codex', 'qwen', 'gemini', 'windsurf', 'github-copilot', 'kiro'])( + 'does not accept an unmanaged command in an existing %s Hook config', + async (id) => { + const target = platform(id); + await installManagedHookScripts(tmpDir, target); + await installCometHooksForPlatform(tmpDir, target, 'project'); + const configPath = hookConfigPath(tmpDir, id); + const unmanaged = (await fs.readFile(configPath, 'utf8')).replaceAll( + 'comet-hook-guard', + 'user-hook', + ); + await fs.writeFile(configPath, unmanaged); + + await expect(inspectCometHooksForPlatform(tmpDir, target, 'project')).resolves.toEqual({ + present: false, + }); + expect(await fs.readFile(configPath, 'utf8')).toBe(unmanaged); + }, + ); + + it('returns a parse error for malformed canonical Hook JSON without rewriting it', async () => { + const configPath = hookConfigPath(tmpDir, 'claude'); + const malformed = '{\r\n "hooks": {\r\n'; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, malformed); + + const result = await inspectCometHooksForPlatform(tmpDir, platform('claude'), 'project'); + + expect(result.present).toBe(false); + expect(result.error).toContain('Invalid Hook JSON'); + expect(await fs.readFile(configPath, 'utf8')).toBe(malformed); + }); + + it('does not report a current Hook healthy when its manifest-owned script is missing', async () => { + const target = platform('claude'); + await installCometHooksForPlatform(tmpDir, target, 'project'); + + await expect(inspectCometHooksForPlatform(tmpDir, target, 'project')).resolves.toMatchObject({ + present: false, + error: expect.stringContaining('script'), + }); + }); + + it('does not accept a legacy .sh command as the current manifest Hook', async () => { + const target = platform('claude'); + await installManagedHookScripts(tmpDir, target); + await installCometHooksForPlatform(tmpDir, target, 'project'); + const configPath = hookConfigPath(tmpDir, 'claude'); + const legacy = (await fs.readFile(configPath, 'utf8')).replaceAll( + 'comet-hook-guard.mjs', + 'comet-hook-guard.sh', + ); + await fs.writeFile(configPath, legacy, 'utf8'); + + await expect(inspectCometHooksForPlatform(tmpDir, target, 'project')).resolves.toEqual({ + present: false, + }); + }); + + it('returns an error for an unreadable canonical Hook path without changing it', async () => { + const configPath = hookConfigPath(tmpDir, 'claude'); + await fs.mkdir(configPath, { recursive: true }); + + const result = await inspectCometHooksForPlatform(tmpDir, platform('claude'), 'project'); + + expect(result.present).toBe(false); + expect(result.error).toBeDefined(); + expect((await fs.stat(configPath)).isDirectory()).toBe(true); + }); + + it('does not create a missing Hook config or report unsupported Hooks as present', async () => { + await expect( + inspectCometHooksForPlatform(tmpDir, platform('claude'), 'project'), + ).resolves.toEqual({ present: false }); + await expect( + inspectCometHooksForPlatform(tmpDir, platform('cursor'), 'project'), + ).resolves.toEqual({ present: false }); + expect(await fs.readdir(tmpDir)).toEqual([]); + }); +}); diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index 64fa5240..2cd041a6 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1,13 +1,34 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; + +const { readJsonMock, readFileMock, writeFileMock } = vi.hoisted(() => ({ + readJsonMock: vi.fn(), + readFileMock: vi.fn(), + writeFileMock: vi.fn(), +})); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + readFileMock.mockImplementation(actual.readFile); + writeFileMock.mockImplementation(actual.writeFile); + return { ...actual, readFile: readFileMock, writeFile: writeFileMock }; +}); + +vi.mock('../../../platform/fs/file-system.js', async (importOriginal) => { + const actual = await importOriginal(); + readJsonMock.mockImplementation(actual.readJson); + return { ...actual, readJson: readJsonMock }; +}); + import { getAssetsDir, readManifest, getManifestSkills, createWorkingDirs, copyCometSkillsForPlatform, + copyCometRulesForPlatform, installCometHooksForPlatform, parseProjectConfigOverrides, renderProjectConfig, @@ -20,6 +41,15 @@ describe('skills', () => { let tmpDir: string; beforeEach(async () => { + readJsonMock.mockReset(); + readJsonMock.mockImplementation( + async (filePath: string) => + JSON.parse(await fs.readFile(filePath, 'utf-8')) as Record, + ); + readFileMock.mockReset(); + readFileMock.mockImplementation(fs.readFile); + writeFileMock.mockReset(); + writeFileMock.mockImplementation(fs.writeFile); tmpDir = path.join( os.tmpdir(), `comet-skills-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -100,6 +130,211 @@ describe('skills', () => { }); }); + describe('copyCometRulesForPlatform', () => { + it('reports a missing Rule source as failed', async () => { + readJsonMock.mockResolvedValue({ + version: 'test', + skills: [], + rules: ['comet/rules/missing-rule.md'], + }); + const platform = PLATFORMS.find((candidate) => candidate.id === 'claude')!; + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect( + copyCometRulesForPlatform(tmpDir, platform, true, 'zh', 'project'), + ).resolves.toEqual({ copied: 0, skipped: 0, failed: 1 }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Rule source not found')); + } finally { + error.mockRestore(); + } + }); + + it('reports a Rule source permission failure without calling it missing', async () => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'claude')!; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + readFileMock.mockRejectedValueOnce(permissionError); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect( + copyCometRulesForPlatform(tmpDir, platform, true, 'zh', 'project'), + ).resolves.toEqual({ copied: 0, skipped: 0, failed: 1 }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Failed to copy rule')); + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('Rule source not found')); + } finally { + error.mockRestore(); + } + }); + + it('reports a Rule source access failure without calling it missing', async () => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'claude')!; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockRejectedValue(permissionError); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect( + copyCometRulesForPlatform(tmpDir, platform, true, 'zh', 'project'), + ).resolves.toEqual({ copied: 0, skipped: 0, failed: 1 }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Failed to copy rule')); + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('Rule source not found')); + } finally { + accessSpy.mockRestore(); + error.mockRestore(); + } + }); + + it('reports a Rule copy permission failure', async () => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'claude')!; + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + writeFileMock.mockRejectedValueOnce(permissionError); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect( + copyCometRulesForPlatform(tmpDir, platform, true, 'zh', 'project'), + ).resolves.toEqual({ copied: 0, skipped: 0, failed: 1 }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Failed to copy rule')); + } finally { + error.mockRestore(); + } + }); + }); + + it.each([ + { installMode: 'copy' as const, destinationRoot: ['.claude', 'skills'] }, + { installMode: 'symlink' as const, destinationRoot: ['.comet', 'skills', 'skills'] }, + ])( + 'counts a $installMode Skill destination preflight access error instead of rejecting', + async ({ installMode, destinationRoot }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'claude')!; + const blockedDestination = path.join(tmpDir, ...destinationRoot, 'comet', 'SKILL.md'); + const access = fs.access.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockImplementation(async (filePath, mode) => { + if (path.resolve(String(filePath)) === path.resolve(blockedDestination)) { + throw permissionError; + } + await access(filePath, mode); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const result = await copyCometSkillsForPlatform( + tmpDir, + platform, + false, + 'skills', + 'project', + installMode, + ); + expect(result.failed).toBeGreaterThan(0); + expect(error).toHaveBeenCalledWith(expect.stringContaining('permission denied')); + } finally { + error.mockRestore(); + accessSpy.mockRestore(); + } + }, + ); + + it.each(['copy', 'symlink'] as const)( + 'counts an OpenCode command artifact access failure in %s mode without rejecting', + async (installMode) => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'opencode')!; + const blockedArtifact = path.join(tmpDir, '.opencode', 'commands', 'comet.md'); + const access = fs.access.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockImplementation(async (filePath, mode) => { + if (path.resolve(String(filePath)) === path.resolve(blockedArtifact)) { + throw permissionError; + } + await access(filePath, mode); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const result = await copyCometSkillsForPlatform( + tmpDir, + platform, + false, + 'skills', + 'project', + installMode, + ); + expect(result.failed).toBeGreaterThan(0); + expect(error).toHaveBeenCalledWith(expect.stringContaining('permission denied')); + } finally { + error.mockRestore(); + accessSpy.mockRestore(); + } + }, + ); + + it.each(['copy', 'symlink'] as const)( + 'counts a Pi settings write failure in %s mode without creating an extension', + async (installMode) => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'pi')!; + const settingsPath = path.join(tmpDir, '.pi', 'settings.json'); + const extensionPath = path.join(tmpDir, '.pi', 'extensions', 'comet-commands.ts'); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + writeFileMock.mockImplementation(async (filePath, ...args) => { + const resolved = path.resolve(String(filePath)); + if (resolved === path.resolve(settingsPath)) throw permissionError; + return fs.writeFile(filePath, ...args); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const result = await copyCometSkillsForPlatform( + tmpDir, + platform, + false, + 'skills', + 'project', + installMode, + ); + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('permission denied')); + await expect(fs.access(extensionPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + error.mockRestore(); + } + }, + ); + + it.each(['copy', 'symlink'] as const)( + 'counts a Pi extension write failure in %s mode without rejecting', + async (installMode) => { + const platform = PLATFORMS.find((candidate) => candidate.id === 'pi')!; + const settingsPath = path.join(tmpDir, '.pi', 'settings.json'); + const extensionPath = path.join(tmpDir, '.pi', 'extensions', 'comet-commands.ts'); + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile(settingsPath, '{"enableSkillCommands":true}\n', 'utf8'); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + writeFileMock.mockImplementation(async (filePath, ...args) => { + if (path.resolve(String(filePath)) === path.resolve(extensionPath)) throw permissionError; + return fs.writeFile(filePath, ...args); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const result = await copyCometSkillsForPlatform( + tmpDir, + platform, + false, + 'skills', + 'project', + installMode, + ); + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('permission denied')); + } finally { + error.mockRestore(); + } + }, + ); + describe('createWorkingDirs', () => { it('creates superpowers spec and plan directories', async () => { await createWorkingDirs(tmpDir); @@ -216,7 +451,7 @@ describe('skills', () => { expect(command).toContain('$ARGUMENTS'); expect(command).toContain('# Comet Phase 1: Open'); expect(command).toContain('## Steps'); - expect(command).toContain('node "$COMET_STATE" init full'); + expect(command).toContain('comet state init full'); expect(command).not.toContain('Immediately load the `comet-open` skill with the skill tool'); expect(path.basename(commandPath)).toBe('comet-open.md'); }); @@ -295,6 +530,335 @@ describe('skills', () => { const expectedHookCommand = (skillsDir: string, baseDir = tmpDir) => `node "${normalized(path.join(baseDir, skillsDir, 'skills', ...currentCometScript.split('/')))}" --project-root "${normalized(baseDir)}"`; + it('returns failed when the Hook manifest cannot be read', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + readJsonMock.mockRejectedValueOnce(new Error('manifest unavailable')); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'failed', + reason: 'manifest unavailable', + }); + }); + + it('returns failed when a Hook-capable platform does not declare a format', async () => { + const platform: Platform = { + id: 'missing-hook-format', + name: 'Missing Hook Format', + skillsDir: '.missing-hook-format', + openspecToolId: 'missing-hook-format', + supportsHooks: true, + }; + + await expect(installCometHooksForPlatform(tmpDir, platform, 'project')).resolves.toEqual({ + status: 'failed', + reason: 'hook-capable platform does not declare a hook format', + }); + }); + + it.each([ + { scope: 'project' as const, baseDir: () => tmpDir }, + { scope: 'global' as const, baseDir: () => path.join(tmpDir, 'home') }, + ])('writes $scope Codex hooks to .codex/hooks.json', async ({ scope, baseDir }) => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const root = baseDir(); + + await expect(installCometHooksForPlatform(root, codex, scope)).resolves.toEqual({ + status: 'installed', + }); + + const hooks = JSON.parse(await fs.readFile(path.join(root, '.codex', 'hooks.json'), 'utf-8')); + expect(hooks.hooks.PreToolUse[0].hooks[0].command.replaceAll('\\', '/')).toContain( + '/.agents/skills/comet/scripts/comet-hook-guard.mjs', + ); + await expect( + fs.access(path.join(root, '.codex', 'settings.local.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps Codex hook installation idempotent when the project path contains spaces', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const root = path.join(tmpDir, 'Jane Doe project'); + const canonicalPath = path.join(root, '.codex', 'hooks.json'); + + await installCometHooksForPlatform(root, codex, 'project'); + const firstInstall = JSON.parse(await fs.readFile(canonicalPath, 'utf-8')); + await installCometHooksForPlatform(root, codex, 'project'); + const secondInstall = JSON.parse(await fs.readFile(canonicalPath, 'utf-8')); + + expect(secondInstall).toEqual(firstInstall); + expect(secondInstall.hooks.PreToolUse[0].hooks).toHaveLength(1); + }); + + it('preserves canonical group metadata and malformed entries while replacing managed hooks', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const userHandler = { type: 'command', command: 'node my-user-hook.mjs' }; + const canonical = { + hooks: { + PreToolUse: [ + null, + 'manual-group', + { + matcher: 'Write|Edit', + description: 'primary group metadata', + hooks: [null, 'manual-handler', { type: 'command', command: staleCometCommand }], + }, + { + matcher: 'Write|Edit', + customField: { duplicate: true }, + hooks: [{ type: 'command', command: staleCometCommand }, userHandler], + }, + { + matcher: 'Write|Edit', + keepEmpty: true, + hooks: [{ type: 'command', command: staleCometCommand }], + }, + ], + }, + }; + await fs.mkdir(path.dirname(canonicalPath), { recursive: true }); + await fs.writeFile(canonicalPath, JSON.stringify(canonical, null, 2), 'utf-8'); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + const firstInstall = JSON.parse(await fs.readFile(canonicalPath, 'utf-8')); + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + const secondInstall = JSON.parse(await fs.readFile(canonicalPath, 'utf-8')); + + expect(secondInstall).toEqual(firstInstall); + expect(secondInstall.hooks.PreToolUse[0]).toBeNull(); + expect(secondInstall.hooks.PreToolUse[1]).toBe('manual-group'); + expect(secondInstall.hooks.PreToolUse[2].description).toBe('primary group metadata'); + expect(secondInstall.hooks.PreToolUse[2].hooks.slice(0, 2)).toEqual([null, 'manual-handler']); + expect(secondInstall.hooks.PreToolUse[2].hooks[2].command.replaceAll('\\', '/')).toContain( + '/.agents/skills/comet/scripts/comet-hook-guard.mjs', + ); + expect(secondInstall.hooks.PreToolUse[3]).toEqual({ + matcher: 'Write|Edit', + customField: { duplicate: true }, + hooks: [userHandler], + }); + expect(secondInstall.hooks.PreToolUse[4]).toEqual({ + matcher: 'Write|Edit', + keepEmpty: true, + hooks: [], + }); + }); + + it('migrates only Comet hooks from the historical Codex settings file', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const legacy = { + model: 'gpt-5', + hooks: { + PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo post' }] }], + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + { type: 'command', command: staleCometCommand }, + { type: 'command', command: 'node my-user-hook.mjs' }, + ], + }, + ], + }, + }; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, JSON.stringify(legacy, null, 2), 'utf-8'); + + await installCometHooksForPlatform(tmpDir, codex, 'project'); + + const migrated = JSON.parse(await fs.readFile(legacyPath, 'utf-8')); + expect(migrated.model).toBe('gpt-5'); + expect(migrated.hooks.PostToolUse).toEqual(legacy.hooks.PostToolUse); + expect(migrated.hooks.PreToolUse[0].hooks).toEqual([ + { type: 'command', command: 'node my-user-hook.mjs' }, + ]); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).resolves.toBeUndefined(); + }); + + it('migrates quoted managed hook paths with spaces without matching malformed commands', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const managedPath = 'C:/Users/Jane Doe/.agents/skills/comet/scripts/comet-hook-guard.mjs'; + const preservedCommands = [`node "${managedPath}`, `node "${managedPath}"; echo not-managed`]; + const legacy = { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: `node "${managedPath}" --project-root "C:/Users/Jane Doe"`, + }, + { type: 'command', command: `node '${managedPath}'` }, + { + type: 'command', + command: 'node C:/Users/Jane/.agents/skills/comet/scripts/comet-hook-guard.mjs', + }, + ...preservedCommands.map((command) => ({ type: 'command', command })), + ], + }, + ], + }, + }; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, JSON.stringify(legacy, null, 2), 'utf-8'); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + + const migrated = JSON.parse(await fs.readFile(legacyPath, 'utf-8')); + expect( + migrated.hooks.PreToolUse[0].hooks.map((handler: { command: string }) => handler.command), + ).toEqual(preservedCommands); + }); + + it('keeps canonical Codex hook installation successful when legacy cleanup cannot be written', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const legacy = { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [{ type: 'command', command: staleCometCommand }], + }, + ], + }, + }; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, JSON.stringify(legacy, null, 2), 'utf-8'); + writeFileMock + .mockImplementationOnce(fs.writeFile) + .mockRejectedValueOnce(new Error('simulated legacy write failure')); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + await expect(fs.access(canonicalPath)).resolves.toBeUndefined(); + await expect(fs.readFile(legacyPath, 'utf-8')).resolves.toBe(JSON.stringify(legacy, null, 2)); + }); + + it('keeps canonical Codex hook installation successful when legacy access fails', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const legacy = '{\n "hooks": {}\n}\n'; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, legacy, 'utf-8'); + const access = fs.access.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockImplementation(async (filePath, mode) => { + if (path.resolve(String(filePath)) === path.resolve(legacyPath)) throw permissionError; + await access(filePath, mode); + }); + + try { + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + } finally { + accessSpy.mockRestore(); + } + + await expect(fs.access(canonicalPath)).resolves.toBeUndefined(); + await expect(fs.readFile(legacyPath, 'utf-8')).resolves.toBe(legacy); + }); + + it('preserves legacy hook groups, group fields, and non-object handlers during migration', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const legacy = { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + description: 'Comet-only group metadata', + hooks: [{ type: 'command', command: staleCometCommand }], + }, + { + matcher: 'Bash', + customField: { preserved: true }, + hooks: [null, 'manual-marker', { type: 'command', command: staleCometCommand }], + }, + ], + }, + }; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, JSON.stringify(legacy, null, 2), 'utf-8'); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + + const migrated = JSON.parse(await fs.readFile(legacyPath, 'utf-8')); + expect(migrated.hooks.PreToolUse).toEqual([ + { + matcher: 'Write|Edit', + description: 'Comet-only group metadata', + hooks: [], + }, + { + matcher: 'Bash', + customField: { preserved: true }, + hooks: [null, 'manual-marker'], + }, + ]); + }); + + it('installs canonical Codex hooks without changing invalid historical JSON', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const invalid = '{\r\n "hooks": {\r\n'; + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, invalid, 'utf-8'); + + await expect(installCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + status: 'installed', + }); + + await expect(fs.readFile(legacyPath, 'utf-8')).resolves.toBe(invalid); + await expect(fs.access(path.join(tmpDir, '.codex', 'hooks.json'))).resolves.toBeUndefined(); + }); + + it('does not overwrite invalid canonical Codex hooks or migrate the historical file', async () => { + const codex = PLATFORMS.find((candidate) => candidate.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const invalidCanonical = '{\r\n "hooks": {\r\n'; + const legacy = `${JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [{ type: 'command', command: staleCometCommand }], + }, + ], + }, + }, + null, + 2, + )}\r\n`; + await fs.mkdir(path.dirname(canonicalPath), { recursive: true }); + await fs.writeFile(canonicalPath, invalidCanonical, 'utf-8'); + await fs.writeFile(legacyPath, legacy, 'utf-8'); + + const result = await installCometHooksForPlatform(tmpDir, codex, 'project'); + + expect(result.status).toBe('failed'); + expect(result.reason).toContain('Invalid Codex settings'); + await expect(fs.readFile(canonicalPath, 'utf-8')).resolves.toBe(invalidCanonical); + await expect(fs.readFile(legacyPath, 'utf-8')).resolves.toBe(legacy); + }); + it('merges Claude-style hooks into an existing matcher group without replacing user hooks', async () => { const platform: Platform = { id: 'claude', @@ -374,7 +938,7 @@ describe('skills', () => { await fs.writeFile(settingsPath, JSON.stringify(malformedSettings), 'utf-8'); await expect(installCometHooksForPlatform(tmpDir, platform)).resolves.toEqual({ - installed: true, + status: 'installed', }); const updated = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); @@ -460,7 +1024,7 @@ describe('skills', () => { await fs.writeFile(settingsPath, JSON.stringify(initialSettings), 'utf-8'); await expect(installCometHooksForPlatform(homeDir, platform, 'global')).resolves.toEqual({ - installed: true, + status: 'installed', }); const updated = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); @@ -480,11 +1044,42 @@ describe('skills', () => { const result = await installCometHooksForPlatform(tmpDir, platform, 'project'); - expect(result.installed).toBe(false); - expect(result.reason).toContain('Invalid codebuddy settings'); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('Invalid CodeBuddy Code settings'); await expect(fs.readFile(settingsPath, 'utf-8')).resolves.toBe(invalidSettings); }); + it.each([ + { + id: 'claude', + configPath: ['.claude', 'settings.local.json'], + }, + { + id: 'amazon-q', + configPath: ['.amazonq', 'settings.local.json'], + }, + { + id: 'gemini', + configPath: ['.gemini', 'settings.json'], + }, + { + id: 'windsurf', + configPath: ['.windsurf', 'hooks.json'], + }, + ])('leaves malformed $id Hook JSON byte-for-byte unchanged', async ({ id, configPath }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === id)!; + const settingsPath = path.join(tmpDir, ...configPath); + const malformedSettings = '{\r\n "hooks": {\r\n'; + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile(settingsPath, malformedSettings, 'utf-8'); + + const result = await installCometHooksForPlatform(tmpDir, platform, 'project'); + + expect(result.status).toBe('failed'); + expect(result.reason).toContain(`Invalid ${platform.name} settings`); + await expect(fs.readFile(settingsPath, 'utf-8')).resolves.toBe(malformedSettings); + }); + it('merges Gemini hooks into the existing matcher group idempotently', async () => { const platform: Platform = { id: 'gemini', @@ -588,15 +1183,15 @@ describe('skills', () => { }); describe('Chinese Comet workflow safeguards', () => { - it('requires OpenSpec instructions for each standard open artifact', async () => { + it('uses the OpenSpec status graph to drive Chinese open artifacts', async () => { const zhOpen = await fs.readFile( path.resolve('assets', 'skills-zh', 'comet-open', 'SKILL.md'), 'utf-8', ); - expect(zhOpen).toContain('openspec instructions proposal --change "" --json'); - expect(zhOpen).toContain('openspec instructions design --change "" --json'); - expect(zhOpen).toContain('openspec instructions tasks --change "" --json'); + expect(zhOpen).toContain('openspec instructions --change "" --json'); + expect(zhOpen).toContain('不得硬编码生成顺序'); + expect(zhOpen).not.toContain('openspec instructions proposal --change "" --json'); for (const field of [ '`context`', '`rules`', @@ -610,19 +1205,19 @@ describe('skills', () => { expect(zhOpen).toContain('不得复制到 artifact 内容中'); expect(zhOpen).toContain('每创建一个 artifact 后'); expect(zhOpen).toContain('openspec status --change "" --json'); - expect(zhOpen).toContain('必须立即停止 artifact 创建'); + expect(zhOpen).toContain('必须立即停止并报告 OpenSpec 错误'); expect(zhOpen).toContain('不得回退为硬编码文档结构'); }); - it('requires OpenSpec instructions for each standard open artifact (English)', async () => { + it('uses the OpenSpec status graph to drive English open artifacts', async () => { const enOpen = await fs.readFile( path.resolve('assets', 'skills', 'comet-open', 'SKILL.md'), 'utf-8', ); - expect(enOpen).toContain('openspec instructions proposal --change "" --json'); - expect(enOpen).toContain('openspec instructions design --change "" --json'); - expect(enOpen).toContain('openspec instructions tasks --change "" --json'); + expect(enOpen).toContain('openspec instructions --change "" --json'); + expect(enOpen).toContain('Must not hard-code generation order'); + expect(enOpen).not.toContain('openspec instructions proposal --change "" --json'); for (const field of [ '`context`', '`rules`', @@ -633,10 +1228,10 @@ describe('skills', () => { ]) { expect(enOpen).toContain(field); } - expect(enOpen).toContain('must not copy them into the artifact content'); - expect(enOpen).toContain('After creating each artifact'); + expect(enOpen).toContain('must not copy them into artifact content'); + expect(enOpen).toContain('Re-run status after creating each artifact'); expect(enOpen).toContain('openspec status --change "" --json'); - expect(enOpen).toContain('must immediately stop artifact creation'); + expect(enOpen).toContain('Also stop if status/instructions fails'); expect(enOpen).toContain('Must not fall back to hard-coded artifact prose'); }); @@ -758,16 +1353,14 @@ describe('skills', () => { '若当前平台没有结构化提问工具,则必须在对话中提出明确选项并停止流程', ); expect(zhDecisionPoint).toContain('不得用推荐规则、默认值、历史偏好'); - expect(zhOpen).toContain('### 1b. 需求澄清完成确认(阻塞点)'); - expect(zhOpen).toContain( - '不得在用户确认需求澄清完成前创建 proposal.md、design.md 或 tasks.md', - ); + expect(zhOpen).toContain('### 1b. 需求与 Change 名称解析(默认不阻塞)'); + expect(zhOpen).toContain('范围与命名都明确时直接继续'); expect(zhOpen).toContain('`comet/reference/decision-point.md`'); expect(zhOpen).toContain( '完整 `/comet` 流程默认不得使用 Skill 工具加载 `openspec-propose` 技能', ); expect(zhOpen).toContain( - '技能加载后,按其指引创建 change 骨架,但当 Step 1b 的已确认澄清摘要已存在于对话上下文时', + '当 Step 1b 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为', ); expect(zhOpen).not.toContain('OpenSpec artifact 指令'); expect(zhOpen).not.toContain('fast-forward'); @@ -787,17 +1380,16 @@ describe('skills', () => { ); expect(zhDesign).not.toContain('跳过重复上下文探索,直接进入设计提问'); expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch` 或 `worktree`'); - expect(zhBuild).toContain('不得根据推荐规则自行选择执行方式'); + expect(zhBuild).toContain('也不得自行选择执行方式、TDD 模式或代码审查模式'); expect(zhBuild).toContain('`comet/reference/decision-point.md`'); + expect(zhVerify).toContain('前 3 次可修复失败自动回到 build'); expect(zhVerify).toContain( - '验证不通过时**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户决定修复或接受偏差', - ); - expect(zhVerify).toContain( - '必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式', - ); - expect(zhVerify).toContain( - '只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`', + '只有接受 WARNING/SUGGESTION 偏差或第 4 次失败后的策略选择才是用户决策点', ); + expect(zhVerify).toContain('不要在 verify 阶段处理、合并或丢弃分支'); + expect(zhVerify).toContain('不要写入 `branch_status: handled`'); + expect(zhArchive).toContain('### 5. 归档提交后的分支处理'); + expect(zhArchive).toContain('comet state set branch_status handled'); expect(zhArchive).toContain('### 1. 归档前最终确认(阻塞点)'); expect(zhArchive).toContain('不得在用户确认前运行 `comet archive ""`'); expect(zhArchive).toContain('`comet/reference/decision-point.md`'); @@ -814,7 +1406,7 @@ describe('skills', () => { '命中质变信号或文件数 tripwire 时,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**', ); expect(zhTweak).toContain('不得直接进入 `/comet-design`'); - expect(zhComet).toContain('`verify_result: fail` → 进入验证失败决策阻塞点'); + expect(zhComet).toContain('`verify_result: fail` → 自动调用 `/comet-build` 继续修复'); expect(zhComet).not.toContain( '`verify_result: fail` → `node "$COMET_STATE" transition verify-fail` 后 `/comet-build`', ); @@ -825,15 +1417,17 @@ describe('skills', () => { expect(zhTweak).toContain('带 delta spec 的验证分流'); // HIGH: hotfix/tweak IMPORTANT blocks must acknowledge verify decision points - expect(zhHotfix).toContain('验证阶段(comet-verify)的验证失败决策和分支处理决策'); - expect(zhTweak).toContain('验证阶段(comet-verify)的验证失败决策和分支处理决策'); + expect(zhHotfix).toContain('验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差'); + expect(zhTweak).toContain('验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差'); + expect(zhHotfix).toContain('归档提交后的分支处理决策'); + expect(zhTweak).toContain('归档提交后的分支处理决策'); expect(zhHotfix).toContain('归档前最终确认'); expect(zhTweak).toContain('归档前最终确认'); // MEDIUM: comet-design brainstorming does not write Design Doc before confirmation expect(zhDesign).toContain('brainstorming 阶段不写入 Design Doc 文件'); expect(zhDesign).toContain('增量更新 `brainstorm-summary.md`'); - expect(zhDesign).toContain('### 1e. 主动式上下文压缩'); + expect(zhDesign).toContain('### 3a. 可选主动式上下文压缩'); // MEDIUM: comet-verify Spec drift requires user choice expect(zhVerify).toContain( @@ -850,8 +1444,8 @@ describe('skills', () => { '若 `build_pause: plan-ready` 但 `isolation`、`build_mode`、`tdd_mode` 和 `review_mode` 都已经设置,则视为 stale pause', ); expect(zhComet).toContain('工作区隔离、执行方式、TDD 模式和代码审查模式'); - expect(zhBuild).toContain('提供 plan-ready 暂停点'); - expect(zhBuild).toContain('不得自动继续,也不得把暂停写入 `build_mode`'); + expect(zhBuild).toContain('一个联合决策点'); + expect(zhBuild).toContain('不得自动选择,也不得把暂停写入 `build_mode`'); expect(zhBuild).toContain('在 `executing-plans` 下,主会话直接执行任务'); expect(zhBuild).toContain('review_mode'); expect(zhBuild).toContain('| `off` | 不自动派发代码审查 |'); @@ -880,9 +1474,10 @@ describe('skills', () => { ); expect(zhVerify).toContain('不能把该绕过标记视为可审计的验证或构建证据'); - // MEDIUM: comet-verify Step 1b treats CRITICAL/IMPORTANT as blocking - expect(zhVerify).toContain('CRITICAL 或 IMPORTANT 失败项必须修复'); - expect(zhVerify).toContain('不允许跳过修复直接全部接受'); + // MEDIUM: comet-verify Step 1b auto-repairs CRITICAL/IMPORTANT findings + // without turning mandatory work into a user decision. + expect(zhVerify).toContain('不得创建“是否修复”的伪决策'); + expect(zhVerify).toContain('CRITICAL/IMPORTANT 始终不可豁免'); expect(zhVerify).toContain('当 `review_mode: standard` 或 `thorough` 时'); expect(zhVerify).toContain('当 `review_mode: off` 时跳过自动代码审查'); expect(zhVerify).toContain('只检查正确性、安全、边界条件'); @@ -891,8 +1486,8 @@ describe('skills', () => { expect(zhVerify).toContain('不执行 spec 覆盖率、Design Doc 一致性或漂移检查'); expect(zhHotfix).toContain('默认 `review_mode: off`'); - // MEDIUM: hotfix IMPORTANT covers >3-tasks comet-build decision points - expect(zhHotfix).toContain('任务超过 3 个转入 `/comet-build` 时的工作区隔离和执行方式选择'); + // MEDIUM: hotfix task count alone does not escalate; only qualitative scope signals do. + expect(zhHotfix).toContain('任务数量本身不触发 `/comet-build`'); // LOW: comet-build "中" level requires user confirmation before brainstorming expect(zhBuild).toContain( @@ -929,13 +1524,13 @@ describe('skills', () => { expect(zhOpen).toContain( '批量拆分模式下,单个拆分项完成 open 阶段后不得自动流转到 `/comet-design`', ); - expect(zhOpen).toContain('拆分完毕后必须暂停询问用户开始哪一个 change'); - expect(zhOpen).toContain('恢复时先检查已创建的 active changes'); + expect(zhOpen).toContain('只有所有拆分项都通过两项 CLI 检查后'); + expect(zhOpen).toContain('断点恢复时先读取 `.comet/batches/.json`'); // IMPORTANT: main entry and build subskill agree scope expansion is blocking expect(zhComet).toContain('build 阶段范围扩张需重新设计或拆分新 change'); expect(zhComet).toContain('archive 阶段执行归档脚本前的最终确认'); - expect(zhComet).toContain('open 阶段大型 PRD 需确认拆分为多个 change'); + expect(zhComet).toContain('open 阶段大型 PRD 是否拆分为多个 changes'); // IMPORTANT: accepted Spec drift edits must not loop back through dirty-worktree handling expect(zhVerify).toContain('选项 A 属于 verify 阶段允许产物'); @@ -948,13 +1543,13 @@ describe('skills', () => { '若 `build_mode: subagent-driven-development`,不得在主窗口直接执行任务', ); expect(zhBuild).toContain('主会话只负责协调,禁止直接编写实现代码'); - expect(zhBuild).toContain('如果当前平台没有真实后台 agent 调度能力'); + expect(zhBuild).toContain('若无法确认真实后台调度能力'); expect(zhBuild).toContain( '先确认当前平台存在可调用的真实后台 subagent / Task / multi-agent 调度能力', ); expect(zhBuild).toContain('`comet state set subagent_dispatch confirmed`'); expect(zhBuild).toContain( - '用户选择改用主窗口执行后,必须先运行 `comet state set build_mode executing-plans`', + '用户在该联合决策中选择主窗口执行后,先运行 `comet state set build_mode executing-plans`', ); expect(zhBuild).not.toContain('使用 Skill 工具加载对应技能'); expect(zhBuild).toContain('tdd_mode'); @@ -994,7 +1589,7 @@ describe('skills', () => { expect(zhCometRule).toContain( 'brainstorming in progress: incrementally update brainstorm-summary.md', ); - expect(zhCometRule).toContain('active compaction gate'); + expect(zhCometRule).toContain('Design Doc、状态和最新 handoff 落盘后按需执行'); expect(zhCometRule).toContain( '使用 Skill 工具重新加载 Superpowers `subagent-driven-development` 技能', ); @@ -1004,7 +1599,7 @@ describe('skills', () => { expect(zhCometRule).toContain('禁止在主会话中直接执行 task'); for (const content of [zhOpen, zhDesign]) { expect(content).toContain('自动衔接下一阶段'); - expect(content).toContain('node "$COMET_STATE" next '); + expect(content).toContain('comet state next '); expect(content).toContain('`NEXT: auto`'); expect(content).toContain('`NEXT: manual`'); expect(content).toContain('按 `HINT`'); @@ -1017,13 +1612,13 @@ describe('skills', () => { expect(content).toContain('按 `HINT`'); } expect(zhHotfix).toContain('自动衔接下一阶段'); - expect(zhHotfix).toContain('node "$COMET_STATE" next '); + expect(zhHotfix).toContain('comet state next '); expect(zhHotfix).toContain('`NEXT: auto`'); expect(zhHotfix).toContain( '`phase: build` 返回 `comet-hotfix`,`verify` 返回 `comet-verify`,`archive` 返回 `comet-archive`', ); expect(zhTweak).toContain('自动衔接下一阶段'); - expect(zhTweak).toContain('node "$COMET_STATE" next '); + expect(zhTweak).toContain('comet state next '); expect(zhTweak).toContain('`NEXT: auto`'); expect(zhTweak).toContain( '`phase: build` 返回 `comet-tweak`,`verify` 返回 `comet-verify`,`archive` 返回 `comet-archive`', @@ -1074,7 +1669,7 @@ describe('skills', () => { 'utf-8', ); const enCometRule = await fs.readFile( - path.resolve('assets', 'skills', 'comet', 'rules', 'comet-phase-guard.md'), + path.resolve('assets', 'skills', 'comet', 'rules', 'comet-phase-guard.en.md'), 'utf-8', ); const enDecisionPoint = await fs.readFile( @@ -1134,17 +1729,17 @@ describe('skills', () => { 'Never substitute recommendation rules, defaults, historical preferences', ); expect(enOpen).toContain( - '### 1b. Requirements Clarification Completion Confirmation (Blocking Point)', + '### 1b. Resolve Requirements and Change Name (Non-blocking by Default)', ); expect(enOpen).toContain( - 'Must not create proposal.md, design.md, or tasks.md before the user confirms requirements clarification is complete', + 'Do not run `openspec new change` or create proposal/design/tasks while the resolved brief or name remains ambiguous', ); expect(enOpen).toContain( 'Full `/comet` workflow must not use the Skill tool to load the `openspec-propose` skill', ); expect(enOpen).toContain('`comet/reference/decision-point.md`'); expect(enOpen).toContain( - 'After the skill loads, follow its guidance to create the change skeleton, but override its "STOP and wait for user direction" behavior when a confirmed clarification summary from Step 1b is already available in the conversation context', + 'When Step 1b has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior', ); expect(enOpen).toContain( 'The clarification summary must include: goals, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios', @@ -1163,28 +1758,24 @@ describe('skills', () => { 'must not weaken the Superpowers `brainstorming` clarification flow by "skipping redundant context exploration"', ); expect(enDesign).not.toContain('Skip redundant context exploration'); + expect(enBuild).toContain('provide exactly **one joint decision point**'); expect(enBuild).toContain( - 'proceed to Step 3 to choose workspace isolation, execution method, TDD mode, and code review mode', - ); - expect(enBuild).toContain( - 'Then continue this step to choose workspace isolation, execution method, TDD mode, and code review mode', + 'show the plan summary, pause option, and every executable Step 3 setting together', ); expect(enBuild).toContain( - 'Must not choose `branch` or `worktree` based on recommendation rules', - ); - expect(enBuild).toContain( - 'must not choose the execution method, TDD mode, or code review mode based on recommendation rules', + 'do not choose `branch` or `worktree`, execution method, TDD mode, or review mode from recommendations', ); expect(enBuild).toContain('`comet/reference/decision-point.md`'); expect(enVerify).toContain( - 'must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to decide whether to fix or accept the deviation', - ); - expect(enVerify).toContain( - 'Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to choose branch handling method', + 'Automatically return to build for the first 3 repairable failures', ); expect(enVerify).toContain( - 'Only after the user completes selection and the corresponding operation finishes, may `branch_status: handled` be written', + 'Only accepting WARNING/SUGGESTION deviations or choosing a strategy after the 4th failure is a user decision point', ); + expect(enVerify).toContain('Do not handle, merge, or discard branches in verify'); + expect(enVerify).toContain('do not write `branch_status: handled`'); + expect(enArchive).toContain('### 5. Handle the Branch After the Archive Commit'); + expect(enArchive).toContain('comet state set branch_status handled'); expect(enTweak).toContain('Use the Skill tool to load the `openspec-apply-change` skill'); expect(enTweak).toContain('This apply path belongs only to tweak'); expect(enTweak).toContain( @@ -1213,7 +1804,7 @@ describe('skills', () => { expect(enTweak).toContain('Do not directly enter `/comet-design`'); expect(enTweak).toContain('`comet/reference/debug-gate.md`'); expect(enComet).toContain( - '`verify_result: fail` → Enter verification failure decision blocking point', + '`verify_result: fail` → Invoke `/comet-build` automatically to continue repair', ); expect(enComet).not.toContain( '`verify_result: fail` → `node "$COMET_STATE" transition verify-fail` then `/comet-build`', @@ -1221,14 +1812,12 @@ describe('skills', () => { expect(enHotfix).toContain('handle it through this file\'s "Upgrade Assessment"'); expect(enTweak).toContain('handle it through this file\'s "Upgrade Assessment"'); - expect(enHotfix).toContain( - 'verify phase (comet-verify) verification-failure and branch-handling decisions', - ); - expect(enTweak).toContain( - 'verify phase (comet-verify) verification-failure and branch-handling decisions', - ); + expect(enHotfix).toContain('Verify-phase acceptance of WARNING/SUGGESTION deviations'); + expect(enTweak).toContain('Verify-phase acceptance of WARNING/SUGGESTION deviations'); expect(enHotfix).toContain('Final archive confirmation'); expect(enTweak).toContain('Final archive confirmation'); + expect(enHotfix).toContain('branch-handling decision after the archive commit'); + expect(enTweak).toContain('branch-handling decision after the archive commit'); expect(enDesign).toContain('The brainstorming phase does not write to the Design Doc file'); expect(enVerify).toContain( "must use the current platform's available user input/confirmation mechanism as a single-select question to pause and wait for the user to choose the handling method", @@ -1246,10 +1835,8 @@ describe('skills', () => { expect(enComet).toContain( 'workspace isolation, execution method, TDD mode, and code review mode', ); - expect(enBuild).toContain('Provide Plan-Ready Pause Point'); - expect(enBuild).toContain( - 'Must not auto-continue and must not write the pause into `build_mode`', - ); + expect(enBuild).toContain('one joint decision point'); + expect(enBuild).toContain('Do not auto-select or write the pause into `build_mode`'); expect(enBuild).toContain( 'Under `executing-plans`, the main session executes tasks directly', ); @@ -1283,8 +1870,8 @@ describe('skills', () => { '`COMET_SKIP_BUILD=1` is only a compatibility bypass for legacy workflows, not auditable build evidence', ); expect(enVerify).toContain('cannot be treated as auditable verification or build evidence'); - expect(enVerify).toContain('CRITICAL or IMPORTANT failures must be fixed'); - expect(enVerify).toContain('skipping fix to accept all is not allowed'); + expect(enVerify).toContain('Do not manufacture a "whether to fix" decision'); + expect(enVerify).toContain('CRITICAL/IMPORTANT findings are never waivable'); expect(enVerify).toContain('Code review strategy'); expect(enVerify).toContain( 'use the Skill tool to load the Superpowers `requesting-code-review` skill', @@ -1295,11 +1882,9 @@ describe('skills', () => { 'does not perform spec coverage, Design Doc consistency, or drift checks', ); expect(enHotfix).toContain('6 quick checks'); - expect(enHotfix).toContain( - 'workspace isolation and execution-method selection when tasks exceed 3 and transfer to `/comet-build`', - ); + expect(enHotfix).toContain('task count alone does not route to `/comet-build`'); expect(enBuild).toContain( - 'Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to explicitly choose', + "Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly confirm", ); expect(enBuild).toContain( 'must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to decide whether to split into a new change', @@ -1324,19 +1909,15 @@ describe('skills', () => { expect(enOpen).toContain( 'In batch split mode, a single split item must not auto-advance to `/comet-design` after completing the open phase', ); - expect(enOpen).toContain( - 'After splitting is complete, must pause and ask the user which change to start', - ); - expect(enOpen).toContain('On resume, first check already-created active changes'); + expect(enOpen).toContain('Only after every split item passes both CLI checks'); + expect(enOpen).toContain('On resume, read `.comet/batches/.json` first'); expect(enComet).toContain( 'Build phase scope expansion requiring redesign or new change split', ); expect(enComet).toContain( 'Archive phase final confirmation before running the archive script', ); - expect(enComet).toContain( - 'Open phase large PRD requiring confirmation to split into multiple changes', - ); + expect(enComet).toContain('Open phase large PRD split confirmation'); expect(enVerify).toContain('Option A is a verify phase allowed artifact'); expect(enBuild).toContain( 'Must use the Skill tool to load the Superpowers `using-git-worktrees`', @@ -1353,7 +1934,7 @@ describe('skills', () => { expect(enDesign).toContain('openspec/changes//.comet/handoff/spec-context.md'); expect(enDesign).toContain('In beta mode, `spec-context.json` must be structurally valid'); expect(enDesign).toContain('incrementally update `brainstorm-summary.md`'); - expect(enDesign).toContain('### 1e. Active Context Compaction Gate'); + expect(enDesign).toContain('### 3a. Optional Active Context Compaction'); expect(enHotfix).toContain('immediately use the Skill tool to load the `comet-design` skill'); expect(enTweak).toContain('immediately use the Skill tool to load the `comet-design` skill'); expect(enVerify).toContain( @@ -1392,40 +1973,42 @@ describe('skills', () => { ).not.toContain('AskUserQuestion'); expect(enComet).toContain('`comet/reference/decision-point.md`'); expect(enComet).toContain('`auto_transition`'); - expect(enComet).toContain('does not block phase updates'); + expect(enComet).toContain('only controls next skill invocation, not phase advancement'); expect(enCometRule).toContain( 'brainstorming in progress: incrementally update brainstorm-summary.md', ); - expect(enCometRule).toContain('active compaction gate'); + expect(enCometRule).toContain( + 'only after the Design Doc, state evidence, and latest handoff are persisted', + ); expect(enCometRule).toContain( 'Use the Skill tool to reload the Superpowers `subagent-driven-development` skill', ); expect(enCometRule).toContain( - 're-read `comet/reference/subagent-dispatch.md` for Comet-specific extensions', + 'Re-read `comet/reference/subagent-dispatch.md` for Comet-specific extensions', ); - expect(enCometRule).toContain('Do not execute the pending task directly in the main window'); + expect(enCometRule).toContain('Do not execute tasks directly in the main session'); for (const content of [enOpen, enDesign]) { expect(content).toContain('Automatic Handoff to Next Phase'); - expect(content).toContain('node "$COMET_STATE" next '); + expect(content).toContain('comet state next '); expect(content).toContain('`NEXT: auto`'); expect(content).toContain('`NEXT: manual`'); - expect(content).toContain('run `/` manually'); + expect(content).toContain('return control with `HINT`'); } for (const content of [enBuild, enVerify]) { expect(content).toContain('Automatic Handoff to Next Phase'); expect(content).toContain('comet state next '); expect(content).toContain('`NEXT: auto`'); expect(content).toContain('`NEXT: manual`'); - expect(content).toContain('run `/` manually'); + expect(content).toContain('return control with `HINT`'); } expect(enHotfix).toContain('Automatic Handoff to Next Phase'); - expect(enHotfix).toContain('node "$COMET_STATE" next '); + expect(enHotfix).toContain('comet state next '); expect(enHotfix).toContain('`NEXT: auto`'); expect(enHotfix).toContain( '`phase: build` returns `comet-hotfix`, `verify` returns `comet-verify`, `archive` returns `comet-archive`', ); expect(enTweak).toContain('Automatic Handoff to Next Phase'); - expect(enTweak).toContain('node "$COMET_STATE" next '); + expect(enTweak).toContain('comet state next '); expect(enTweak).toContain('`NEXT: auto`'); expect(enTweak).toContain( '`phase: build` returns `comet-tweak`, `verify` returns `comet-verify`, `archive` returns `comet-archive`', @@ -1470,14 +2053,14 @@ describe('skills', () => { '传递给 OpenSpec 的所有提问和产物要求都必须包含解析后的 Comet 产物语言', ); expect(zhSkills['comet-design']).toContain( - 'Language: 使用 `"$COMET_BASH" "$COMET_STATE" get language` 读取到的 Comet 配置产物语言输出', + 'Language: 使用 `comet state get language` 读取到的 Comet 配置产物语言输出', ); expect(zhSkills['comet-build']).toContain( '计划文件和执行反馈必须使用 `comet state get language` 读取到的 Comet 配置产物语言', ); expect(zhSkills['comet-build']).toContain('ARGUMENTS 必须包含与 Step 1 相同的 Language 约束'); expect(zhSkills['comet-verify']).toContain( - '验证报告和分支处理说明必须使用 `comet state get language` 读取到的 Comet 配置产物语言', + '验证报告必须使用 `comet state get language` 读取到的 Comet 配置产物语言', ); expect(zhSkills['comet-archive']).toContain( '归档摘要和生命周期闭环说明必须使用 `comet state get language` 读取到的 Comet 配置产物语言', @@ -1493,7 +2076,7 @@ describe('skills', () => { 'Every prompt and artifact request passed to OpenSpec must include the resolved Comet artifact language', ); expect(enSkills['comet-design']).toContain( - 'Language: Use the configured Comet artifact language from `"$COMET_BASH" "$COMET_STATE" get language`', + 'Language: Use the configured Comet artifact language from `comet state get language`', ); expect(enSkills['comet-build']).toContain( 'Plan files and execution feedback must use the configured Comet artifact language from `comet state get language`', @@ -1502,7 +2085,7 @@ describe('skills', () => { 'ARGUMENTS must include the same Language constraint as Step 1', ); expect(enSkills['comet-verify']).toContain( - 'Verification reports and branch-handling notes must use the configured Comet artifact language from `comet state get language`', + 'Verification reports must use the configured Comet artifact language from `comet state get language`', ); expect(enSkills['comet-archive']).toContain( 'Archive summaries and lifecycle closure notes must use the configured Comet artifact language from `comet state get language`', @@ -1538,7 +2121,8 @@ describe('skills', () => { expect(zhBuild).toContain( '使用 Skill 工具加载 Superpowers `subagent-driven-development` 技能', ); - expect(zhBuild).toContain('选择工作区隔离、执行方式、TDD 模式和代码审查模式'); + expect(zhBuild).toContain('一个联合决策点'); + expect(zhBuild).toContain('工作区隔离、执行方式、TDD 模式和代码审查模式'); expect(zhBuild).toContain('读取 `comet/reference/subagent-dispatch.md` 获取 Comet 专属扩展'); expect(zhBuild).not.toContain('#### Subagent 调度协议'); expect(zhDispatch).toContain('发生冲突时,以本文档中更具体的 Comet 约束为准'); @@ -1554,7 +2138,7 @@ describe('skills', () => { expect(zhDispatch).toContain('每个 task 派发一个全新的后台 implementer agent'); expect(zhDispatch).toContain('task reviewer、修复 agent 和 final reviewer'); expect(zhDispatch).toContain( - 'Language: 使用 "$COMET_BASH" "$COMET_STATE" get language 读取到的 Comet 配置产物语言输出', + 'Language: 使用 comet state get language 读取到的 Comet 配置产物语言输出', ); expect(zhDispatch).toContain('允许修改的文件范围'); expect(zhDispatch).toContain('必须执行的测试命令'); @@ -1610,16 +2194,15 @@ describe('skills', () => { ]) { expect(zhDispatch, `zh dispatch should not bind to ${forbidden}`).not.toContain(forbidden); } - expect(zhDispatch).toContain( - 'node "$COMET_STATE" task-checkoff "$PLAN_FILE" "$PLAN_TASK_TEXT"', - ); + expect(zhDispatch).toContain('comet state task-checkoff '); expect(zhDispatch).not.toContain('PLAN_MATCHES="$(grep -cF'); expect(zhDispatch).toContain('RED 失败命令与失败摘要'); expect(zhDispatch).toContain('GREEN 通过命令与通过摘要'); expect(zhDispatch).not.toContain("grep -n '\\- \\[ \\]' openspec/changes//tasks.md"); expect(zhDispatch).toContain('禁止总结、禁止询问用户是否继续、禁止在任务之间等待用户输入'); expect(zhDispatch).toContain('存在无法从仓库、计划或既有上下文消除的真实歧义'); - expect(zhDispatch).toContain('平台没有真实后台 agent 调度能力'); + expect(zhDispatch).toContain('后台调度能力在执行中失效属于运行停止条件'); + expect(zhDispatch).toContain('不得另设“是否改用 executing-plans”的停顿点'); expect(zhDispatch).toContain('不得加载 `finishing-a-development-branch`'); expect(zhDispatch).toContain('返回 `comet-build` 继续执行退出条件、阶段守卫和后续阶段衔接'); expect(zhRecovery).toContain('重新加载 Superpowers `subagent-driven-development` 技能'); @@ -1660,9 +2243,7 @@ describe('skills', () => { expect(enBuild).toContain( 'workspace isolation, execution method, TDD mode, and code review mode', ); - expect(enBuild).toContain( - 'explicitly choose isolation method, execution method, TDD mode, and code review mode', - ); + expect(enBuild).toContain('one joint decision point'); expect(enBuild).toContain( 'update `isolation`, execution method, TDD mode, and code review mode fields', ); @@ -1683,7 +2264,7 @@ describe('skills', () => { expect(enDispatch).toContain('fresh background implementer agent for every task'); expect(enDispatch).toContain('task reviewer, fix agents, and the final reviewer'); expect(enDispatch).toContain( - 'Language: Use the configured Comet artifact language from "$COMET_BASH" "$COMET_STATE" get language', + 'Language: Use the configured Comet artifact language from comet state get language', ); expect(enDispatch).toContain('allowed file scope'); expect(enDispatch).toContain('required test commands'); @@ -1713,7 +2294,10 @@ describe('skills', () => { ); expect(enDispatch).toContain('Do NOT summarize'); expect(enDispatch).toContain('irreducible ambiguity'); - expect(enDispatch).toContain('real background agent dispatch capability'); + expect(enDispatch).toContain( + 'Background dispatch capability disappearing during execution is a runtime stop condition', + ); + expect(enDispatch).toContain('Do not create a separate "switch to executing-plans" pause'); expect(enDispatch).toContain('must not load `finishing-a-development-branch`'); expect(enDispatch).toContain( 'return control to `comet-build` for exit checks, the phase guard, and phase handoff', @@ -1798,7 +2382,7 @@ describe('skills', () => { ); const zhSection = section(zhGuard, '## 阶段退出后自动过渡'); - expect(zhSection).toContain('comet-state next '); + expect(zhSection).toContain('comet state next '); expect(zhSection).toContain('NEXT: auto'); expect(zhSection).toContain('NEXT: manual'); expect(zhSection).toContain('NEXT: done'); @@ -1806,7 +2390,7 @@ describe('skills', () => { expect(zhSection).not.toContain('open → `comet-design`'); const enSection = section(enGuard, '## Automatic Transition After Phase Exit'); - expect(enSection).toContain('comet-state next '); + expect(enSection).toContain('comet state next '); expect(enSection).toContain('NEXT: auto'); expect(enSection).toContain('NEXT: manual'); expect(enSection).toContain('NEXT: done'); @@ -1824,10 +2408,10 @@ describe('skills', () => { 'utf-8', ); - expect(zhGuard).toContain('`isolation` / `build_mode` / `tdd_mode` / `review_mode` 四项选择'); - expect(enGuard).toContain( - 'four choices: `isolation` / `build_mode` / `tdd_mode` / `review_mode`', - ); + expect(zhGuard).toContain('`isolation` / `build_mode` / `tdd_mode` / `review_mode`'); + expect(zhGuard).toContain('一个联合决策'); + expect(enGuard).toContain('`isolation` / `build_mode` / `tdd_mode` / `review_mode`'); + expect(enGuard).toContain('one joint decision'); }); it('documents the Superpowers workspace hook allowlist in both languages', async () => { diff --git a/test/domains/skill/uninstall.test.ts b/test/domains/skill/uninstall.test.ts new file mode 100644 index 00000000..3dc96453 --- /dev/null +++ b/test/domains/skill/uninstall.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { PLATFORMS } from '../../../platform/install/platforms.js'; +import { installCometHooksForPlatform } from '../../../domains/skill/platform-install.js'; +import { removeCometHooksForPlatform } from '../../../domains/skill/uninstall.js'; + +describe('removeCometHooksForPlatform', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-hook-uninstall-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('ignores malformed historical Codex hooks after canonical cleanup succeeds', async () => { + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + const malformedLegacy = '{\n "hooks": {\n'; + + await installCometHooksForPlatform(tmpDir, codex, 'project'); + await fs.writeFile(legacyPath, malformedLegacy, 'utf8'); + + await expect(removeCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + removed: 1, + failed: 0, + }); + + const cleanedCanonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + expect(cleanedCanonical.hooks.PreToolUse[0].hooks).toEqual([]); + await expect(fs.readFile(legacyPath, 'utf8')).resolves.toBe(malformedLegacy); + }); + + it('ignores unreadable historical Codex hook paths after canonical cleanup succeeds', async () => { + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + + await installCometHooksForPlatform(tmpDir, codex, 'project'); + await fs.mkdir(legacyPath, { recursive: true }); + + await expect(removeCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + removed: 1, + failed: 0, + }); + }); + + it.each([ + { id: 'qwen', configPath: ['.qwen', 'settings.json'] }, + { id: 'gemini', configPath: ['.gemini', 'settings.json'] }, + { id: 'windsurf', configPath: ['.windsurf', 'hooks.json'] }, + ])('fails closed when canonical $id Hook JSON is malformed', async ({ id, configPath }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === id)!; + const settingsPath = path.join(tmpDir, ...configPath); + const malformedSettings = '{\r\n "hooks": {\r\n'; + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile(settingsPath, malformedSettings, 'utf8'); + + await expect(removeCometHooksForPlatform(tmpDir, platform, 'project')).resolves.toEqual({ + removed: 0, + failed: 1, + }); + await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(malformedSettings); + }); + + it.each([ + { id: 'qwen', configPath: ['.qwen', 'settings.json'] }, + { id: 'gemini', configPath: ['.gemini', 'settings.json'] }, + { id: 'windsurf', configPath: ['.windsurf', 'hooks.json'] }, + ])('fails closed when canonical $id Hook JSON is an array', async ({ id, configPath }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === id)!; + const settingsPath = path.join(tmpDir, ...configPath); + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile(settingsPath, '[]\n', 'utf8'); + + await expect(removeCometHooksForPlatform(tmpDir, platform, 'project')).resolves.toEqual({ + removed: 0, + failed: 1, + }); + await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe('[]\n'); + }); + + it.each([ + { id: 'qwen', groupName: 'PreToolUse' }, + { id: 'gemini', groupName: 'BeforeTool' }, + ])( + 'preserves unknown $id group metadata after removing its last managed handler', + async ({ id, groupName }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === id)!; + const settingsPath = path.join(tmpDir, `.${id}`, 'settings.json'); + await installCometHooksForPlatform(tmpDir, platform, 'project'); + const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8')); + settings.hooks[groupName][0].description = 'user-owned group metadata'; + settings.hooks[groupName][0].custom = { keep: true }; + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8'); + + await expect(removeCometHooksForPlatform(tmpDir, platform, 'project')).resolves.toEqual({ + removed: 1, + failed: 0, + }); + + const updated = JSON.parse(await fs.readFile(settingsPath, 'utf8')); + expect(updated.hooks[groupName]).toEqual([ + expect.objectContaining({ + description: 'user-owned group metadata', + custom: { keep: true }, + hooks: [], + }), + ]); + }, + ); + + it.each([ + { + id: 'claude', + accessPath: ['.claude', 'settings.local.json'], + snapshotPath: ['.claude', 'settings.local.json'], + }, + { + id: 'qwen', + accessPath: ['.qwen', 'settings.json'], + snapshotPath: ['.qwen', 'settings.json'], + }, + { + id: 'gemini', + accessPath: ['.gemini', 'settings.json'], + snapshotPath: ['.gemini', 'settings.json'], + }, + { + id: 'windsurf', + accessPath: ['.windsurf', 'hooks.json'], + snapshotPath: ['.windsurf', 'hooks.json'], + }, + { + id: 'kiro', + accessPath: ['.kiro', 'hooks'], + snapshotPath: ['.kiro', 'hooks', 'comet-hook-guard.kiro.hook'], + }, + ])( + 'fails closed when canonical $id Hook configuration is unreadable', + async ({ id, accessPath, snapshotPath }) => { + const platform = PLATFORMS.find((candidate) => candidate.id === id)!; + const blockedPath = path.join(tmpDir, ...accessPath); + const preservedPath = path.join(tmpDir, ...snapshotPath); + await installCometHooksForPlatform(tmpDir, platform, 'project'); + const before = await fs.readFile(preservedPath, 'utf8'); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = + id === 'kiro' + ? undefined + : vi.spyOn(fs, 'access').mockImplementation(async (filePath) => { + if (path.resolve(String(filePath)) === path.resolve(blockedPath)) { + throw permissionError; + } + }); + const readdirSpy = + id === 'kiro' ? vi.spyOn(fs, 'readdir').mockRejectedValue(permissionError) : undefined; + + try { + await expect(removeCometHooksForPlatform(tmpDir, platform, 'project')).resolves.toEqual({ + removed: 0, + failed: 1, + }); + } finally { + accessSpy?.mockRestore(); + readdirSpy?.mockRestore(); + } + + await expect(fs.readFile(preservedPath, 'utf8')).resolves.toBe(before); + }, + ); + + it('keeps unreadable historical Codex Hook access best-effort after canonical cleanup', async () => { + const codex = PLATFORMS.find((platform) => platform.id === 'codex')!; + const canonicalPath = path.join(tmpDir, '.codex', 'hooks.json'); + const legacyPath = path.join(tmpDir, '.codex', 'settings.local.json'); + await installCometHooksForPlatform(tmpDir, codex, 'project'); + const canonicalSource = await fs.readFile(canonicalPath, 'utf8'); + await fs.writeFile(legacyPath, canonicalSource, 'utf8'); + const access = fs.access.bind(fs); + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockImplementation(async (filePath, mode) => { + if (path.resolve(String(filePath)) === path.resolve(legacyPath)) throw permissionError; + await access(filePath, mode); + }); + + try { + await expect(removeCometHooksForPlatform(tmpDir, codex, 'project')).resolves.toEqual({ + removed: 1, + failed: 0, + }); + } finally { + accessSpy.mockRestore(); + } + + const cleanedCanonical = JSON.parse(await fs.readFile(canonicalPath, 'utf8')); + expect(cleanedCanonical.hooks.PreToolUse[0].hooks).toEqual([]); + await expect(fs.readFile(legacyPath, 'utf8')).resolves.toBe(canonicalSource); + }); + + it('reports a regular-file Kiro canonical hooks path without changing its content', async () => { + const kiro = PLATFORMS.find((platform) => platform.id === 'kiro')!; + const hooksPath = path.join(tmpDir, '.kiro', 'hooks'); + const content = 'user-owned regular file\n'; + await fs.mkdir(path.dirname(hooksPath), { recursive: true }); + await fs.writeFile(hooksPath, content, 'utf8'); + + await expect(removeCometHooksForPlatform(tmpDir, kiro, 'project')).resolves.toEqual({ + removed: 0, + failed: 1, + }); + await expect(fs.readFile(hooksPath, 'utf8')).resolves.toBe(content); + }); +}); diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts new file mode 100644 index 00000000..ea6520ff --- /dev/null +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -0,0 +1,364 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +const skillRoot = path.resolve('assets', 'skills'); +const zhSkillRoot = path.resolve('assets', 'skills-zh'); + +async function readSkill(root: string, name: string): Promise { + return fs.readFile(path.join(root, name, 'SKILL.md'), 'utf8'); +} + +function descriptionOf(skill: string): string { + return skill.match(/^description:\s*"([^"]+)"/mu)?.[1] ?? ''; +} + +describe('Comet workflow optimization contracts', () => { + it.each([ + ['中文', zhSkillRoot, 'OpenSpec >= 1.5.0', 'OpenSpec 状态驱动产物循环'], + ['English', skillRoot, 'OpenSpec >= 1.5.0', 'OpenSpec status-driven artifact loop'], + ])( + '%s open flow initializes recoverable state before artifact generation', + async (_language, root, versionMarker, loopMarker) => { + const skill = await readSkill(root, 'comet-open'); + const init = skill.indexOf('comet state init full'); + const loop = skill.indexOf(loopMarker); + + expect(skill).toContain(versionMarker); + expect(init).toBeGreaterThan(-1); + expect(loop).toBeGreaterThan(-1); + expect(init).toBeLessThan(loop); + expect(skill).toContain('applyRequires'); + expect(skill).toContain('changeRoot'); + expect(skill).toContain('.comet/batches/'); + expect(skill).toMatch(/proposal[\s\S]*design[\s\S]*tasks/u); + }, + ); + + it.each([ + ['中文', zhSkillRoot, 'Design Doc 和状态证据落盘后', '无法程序化触发时不得阻塞'], + [ + 'English', + skillRoot, + 'after the Design Doc and state evidence are persisted', + 'must not block when programmatic compaction is unavailable', + ], + ])( + '%s design flow makes compaction a post-persistence optimization', + async (_language, root, after, fallback) => { + const skill = await readSkill(root, 'comet-design'); + + expect(skill).toContain(after); + expect(skill).toContain(fallback); + }, + ); + + it.each([ + ['中文', zhSkillRoot, '先复现问题并记录失败证据', '任务数量本身不触发 `/comet-build`'], + [ + 'English', + skillRoot, + 'reproduce the bug and record failing evidence first', + 'task count alone does not route to `/comet-build`', + ], + ])( + '%s hotfix flow preserves regression evidence without task-count routing', + async (_language, root, regression, routing) => { + const skill = await readSkill(root, 'comet-hotfix'); + + expect(skill).toContain(regression); + expect(skill).toContain(routing); + expect(skill).toContain('isolation: current'); + }, + ); + + it.each([ + ['中文', zhSkillRoot, '并清除预设专属的 `build_mode`'], + ['English', skillRoot, 'and clears preset-only `build_mode`'], + ])( + '%s preset escalation discards lightweight build decisions', + async (_language, root, resetMarker) => { + for (const name of ['comet-hotfix', 'comet-tweak']) { + const skill = await readSkill(root, name); + + expect(skill).toContain(resetMarker); + expect(skill).toContain('`tdd_mode`'); + expect(skill).toContain('`review_mode`'); + expect(skill).toContain('`isolation`'); + expect(skill).toContain('`verify_mode`'); + } + }, + ); + + it.each([ + ['中文', zhSkillRoot, '接受所有偏差'], + ['English', skillRoot, 'accept all deviations'], + ])( + '%s verification keeps non-waivable failures in verify and moves branch handling after archive', + async (_language, root, forbiddenWaiver) => { + const verify = await readSkill(root, 'comet-verify'); + const archive = await readSkill(root, 'comet-archive'); + + expect(verify).not.toContain(forbiddenWaiver); + expect(verify).not.toContain('finishing-a-development-branch'); + expect(archive).toContain('finishing-a-development-branch'); + expect(archive).toContain('comet state set branch_status handled'); + expect(archive).not.toContain('git add -A'); + }, + ); + + it.each([ + ['中文', zhSkillRoot], + ['English', skillRoot], + ])( + '%s primary workflow docs use stable cross-platform Comet commands', + async (_language, root) => { + const names = [ + 'comet', + 'comet-open', + 'comet-design', + 'comet-build', + 'comet-hotfix', + 'comet-tweak', + 'comet-verify', + 'comet-archive', + ]; + const contents = await Promise.all(names.map((name) => readSkill(root, name))); + + for (const content of contents) { + expect(content).not.toMatch(/node "\$COMET_(?:STATE|GUARD|HANDOFF|ARCHIVE)"/u); + expect(content).not.toContain('"$COMET_BASH"'); + expect(content).not.toMatch(/`comet-(?:state|guard|handoff)(?:\.mjs)?\s/u); + expect(content).not.toMatch(/\bgrep\b|\bsed\b|\bhead\b|mkdir -p|\$\(/u); + } + }, + ); + + it.each([ + ['中文', zhSkillRoot, '仅在用户明确调用', '或由 Comet 根 Skill/runtime'], + [ + 'English', + skillRoot, + 'Use only when explicitly invoked', + 'or routed by the root Comet skill/runtime', + ], + ])( + '%s phase skill descriptions cannot bypass root routing', + async (_language, root, explicitMarker, routedMarker) => { + const rootDescription = descriptionOf(await readSkill(root, 'comet')); + + expect(rootDescription).toContain('/comet'); + expect(rootDescription).toContain('active Comet change'); + + for (const name of [ + 'comet-open', + 'comet-design', + 'comet-build', + 'comet-hotfix', + 'comet-tweak', + 'comet-verify', + 'comet-archive', + ]) { + const description = descriptionOf(await readSkill(root, name)); + + expect(description, name).toContain(explicitMarker); + expect(description, name).toContain(routedMarker); + } + + const anyDescription = descriptionOf(await readSkill(root, 'comet-any')); + expect(anyDescription).toMatch(/不要用于一般 Skill|Do not use for general Skill/u); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '### 1b. 需求与 Change 名称解析(默认不阻塞)', + '范围与命名都明确时直接继续', + '最终审视同时确认 change 名称、范围和产物内容', + ], + [ + 'English', + skillRoot, + '### 1b. Resolve Requirements and Change Name (Non-blocking by Default)', + 'Continue directly when scope and naming are both unambiguous', + 'The final review confirms the change name, scope, and artifact content together', + ], + ])( + '%s open flow avoids a redundant pre-artifact confirmation', + async (_language, root, heading, continueMarker, finalReviewMarker) => { + const skill = await readSkill(root, 'comet-open'); + + expect(skill).toContain(heading); + expect(skill).toContain(continueMarker); + expect(skill).toContain(finalReviewMarker); + expect(skill).not.toMatch( + /需求与 Change 名称联合确认|Requirements and Change Name Joint Confirmation/u, + ); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '展示联合决策前先检查当前平台能力', + '分支名也必须在 Step 2 的同一个联合决策中确认', + '使用 Step 2 已确认的分支名,不得再次暂停', + ], + [ + 'English', + skillRoot, + 'Check current platform capabilities before presenting the joint decision', + 'The branch name must be confirmed in the same Step 2 joint decision', + 'Use the branch name already confirmed in Step 2; do not pause again', + ], + ])( + '%s build flow has one executable configuration decision', + async (_language, root, preflight, jointBranch, noSecondPause) => { + const skill = await readSkill(root, 'comet-build'); + + expect(skill).toContain(preflight); + expect(skill).toContain(jointBranch); + expect(skill).toContain(noSecondPause); + expect(skill).not.toMatch( + /必须暂停等待用户改选 `executing-plans`|must pause and wait for the user to choose main-window execution/u, + ); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '返回 `/comet-build` Step 2 的同一个联合决策', + '只剩一个合法模式时直接采用', + '暂停并等待用户改选 `build_mode: executing-plans`', + ], + [ + 'English', + skillRoot, + 'Return to the same `/comet-build` Step 2 joint decision', + 'apply the only valid mode directly when just one remains', + 'pause and wait for the user to choose `build_mode: executing-plans`', + ], + ])( + '%s dispatch capability loss reuses the build decision instead of adding a pause', + async (_language, root, returnMarker, singleModeMarker, stalePause) => { + const dispatch = await fs.readFile( + path.join(root, 'comet', 'reference', 'subagent-dispatch.md'), + 'utf8', + ); + + expect(dispatch).toContain(returnMarker); + expect(dispatch).toContain(singleModeMarker); + expect(dispatch).not.toContain(stalePause); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '前 3 次可修复失败自动回到 build', + '只有接受 WARNING/SUGGESTION 偏差或第 4 次失败后的策略选择才是用户决策点', + '验证不通过时**必须按', + ], + [ + 'English', + skillRoot, + 'Automatically return to build for the first 3 repairable failures', + 'Only accepting WARNING/SUGGESTION deviations or choosing a strategy after the 4th failure is a user decision point', + 'When verification does not pass, **must follow', + ], + ])( + '%s verify flow repairs objective failures without unnecessary pauses', + async (_language, root, automaticRepair, realDecision, oldBlanketPause) => { + const skill = await readSkill(root, 'comet-verify'); + + expect(skill).toContain(automaticRepair); + expect(skill).toContain(realDecision); + expect(skill).not.toContain(oldBlanketPause); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '区分用户决策点、自动处理与停止条件', + '`NEXT: manual` 只是交还控制权,不是新的用户决策点', + ], + [ + 'English', + skillRoot, + 'Distinguish user decisions, automatic handling, and stop conditions', + '`NEXT: manual` returns control; it is not a new user decision point', + ], + ])( + '%s decision protocol does not manufacture choices for deterministic handling', + async (_language, root, classification, manualHandoff) => { + const protocol = await fs.readFile( + path.join(root, 'comet', 'reference', 'decision-point.md'), + 'utf8', + ); + + expect(protocol).toContain(classification); + expect(protocol).toContain(manualHandoff); + }, + ); + + it.each([ + [ + '中文', + zhSkillRoot, + '必须先区分四类情况:用户决策、自动处理、停止条件和手动衔接', + '清晰的首次调用、可确定修复的 guard 失败、单一合法下一步和 `NEXT: manual` 都不得制造确认点', + 'internal Node Skill 的 description 允许普通任务直接触发', + '首次调用,无 workflow 状态', + 'Node guard 失败且原因不明', + ], + [ + 'English', + skillRoot, + 'First distinguish four categories: user decision, automatic handling, stop condition, and manual handoff', + 'A clear first invocation, an objectively repairable guard failure, a sole valid next action, and `NEXT: manual` must not manufacture confirmation', + 'an internal Node Skill description allows ordinary tasks to trigger it', + 'First invocation, no workflow state exists', + 'Node fails its guard and the cause is unclear', + ], + ])( + '%s creator templates preserve trigger boundaries and decision classification', + async ( + _language, + root, + pauseClassification, + entryClassification, + reviewerBoundary, + staleFirstPause, + staleGuardPause, + ) => { + const creatorRoot = path.join(root, 'comet-any', 'reference'); + const pauseAuthor = await fs.readFile( + path.join(creatorRoot, 'subagents', 'pause-points-author.md'), + 'utf8', + ); + const entryAuthor = await fs.readFile( + path.join(creatorRoot, 'subagents', 'workflow-entry-author.md'), + 'utf8', + ); + const reviewer = await fs.readFile( + path.join(creatorRoot, 'subagents', 'skill-reviewer.md'), + 'utf8', + ); + const example = await fs.readFile(path.join(creatorRoot, 'authored-zone-example.md'), 'utf8'); + + expect(pauseAuthor).toContain(pauseClassification); + expect(entryAuthor).toContain(entryClassification); + expect(reviewer).toContain(reviewerBoundary); + expect(example).not.toContain(staleFirstPause); + expect(example).not.toContain(staleGuardPause); + }, + ); +}); diff --git a/test/platform/detect.test.ts b/test/platform/detect.test.ts index 2409044a..3dfbb9c8 100644 --- a/test/platform/detect.test.ts +++ b/test/platform/detect.test.ts @@ -64,6 +64,8 @@ describe('detect', () => { expect(codex?.legacySkillsDirs).toEqual(['.codex']); expect(codex?.detectionPaths).toEqual(['.codex']); expect(codex?.rulesBaseDir).toBe('.codex'); + expect(codex?.hookConfigFile).toBe('hooks.json'); + expect(codex?.legacyHookConfigFiles).toEqual(['settings.local.json']); expect(getPlatformSkillsDir(codex!, 'project')).toBe('.agents'); expect(getPlatformSkillsDir(codex!, 'global')).toBe('.agents'); expect(getPlatformSkillsDirs(codex!, 'project')).toEqual(['.agents', '.codex']); diff --git a/test/platform/file-system.test.ts b/test/platform/file-system.test.ts index ad6ccd8e..46180ac7 100644 --- a/test/platform/file-system.test.ts +++ b/test/platform/file-system.test.ts @@ -9,6 +9,9 @@ import { readJson, writeFile, readDir, + removeFile, + removeDir, + isDirEmpty, } from '../../platform/fs/file-system.js'; describe('file-system utils', () => { @@ -146,6 +149,17 @@ describe('file-system utils', () => { it('returns false for a non-existent path', async () => { expect(await fileExists(path.join(tmpDir, 'nope'))).toBe(false); }); + + it('propagates access permission errors', async () => { + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const accessSpy = vi.spyOn(fs, 'access').mockRejectedValue(error); + + try { + await expect(fileExists(path.join(tmpDir, 'blocked'))).rejects.toBe(error); + } finally { + accessSpy.mockRestore(); + } + }); }); describe('readJson', () => { @@ -205,4 +219,43 @@ describe('file-system utils', () => { } }); }); + + describe('removal failures', () => { + it('propagates removeFile permission errors', async () => { + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockRejectedValue(error); + + try { + await expect(removeFile(path.join(tmpDir, 'blocked.txt'))).rejects.toBe(error); + } finally { + unlinkSpy.mockRestore(); + } + }); + + it('propagates removeDir permission errors', async () => { + const dirPath = path.join(tmpDir, 'blocked'); + await fs.mkdir(dirPath); + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const rmSpy = vi.spyOn(fs, 'rm').mockRejectedValue(error); + + try { + await expect(removeDir(dirPath)).rejects.toBe(error); + } finally { + rmSpy.mockRestore(); + } + }); + }); + + describe('isDirEmpty', () => { + it('propagates readdir permission errors', async () => { + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const readdirSpy = vi.spyOn(fs, 'readdir').mockRejectedValue(error); + + try { + await expect(isDirEmpty(path.join(tmpDir, 'blocked'))).rejects.toBe(error); + } finally { + readdirSpy.mockRestore(); + } + }); + }); }); diff --git a/test/platform/skill-root-owner.test.ts b/test/platform/skill-root-owner.test.ts new file mode 100644 index 00000000..ca3f561a --- /dev/null +++ b/test/platform/skill-root-owner.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hasPlatformDetectionPath = vi.fn(); + +vi.mock('../../platform/install/detect.js', () => ({ + hasPlatformDetectionPath, +})); + +describe('canonical Skill root owner resolution', () => { + beforeEach(() => { + hasPlatformDetectionPath.mockReset(); + }); + + it('does not probe detection paths for unique roots when detection is explicitly disabled', async () => { + hasPlatformDetectionPath.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + const { resolveCanonicalSkillRootOwners } = + await import('../../platform/install/skill-root-owner.js'); + + const owners = await resolveCanonicalSkillRootOwners('C:\\fake-home', 'global', { + respectDetectionPaths: false, + }); + + expect(owners.some((owner) => owner.platform.id === 'codex')).toBe(true); + expect(hasPlatformDetectionPath).not.toHaveBeenCalled(); + }); +}); diff --git a/test/scripts/classic-baseline-benchmark.test.ts b/test/scripts/classic-baseline-benchmark.test.ts index 0593770a..9345c35d 100644 --- a/test/scripts/classic-baseline-benchmark.test.ts +++ b/test/scripts/classic-baseline-benchmark.test.ts @@ -39,5 +39,18 @@ describe('Classic baseline benchmark', () => { 'archive-recovery', 'malformed-rejection', ]); + expect(report.results.slice(0, 4)).toMatchObject( + ['open', 'open', 'open', 'build'].map((phase) => ({ + detail: { + migrationGuard: { + phase, + status: 1, + signal: null, + controlledOutcome: true, + diagnostic: 'BLOCKED — fix failing checks before proceeding to next phase', + }, + }, + })), + ); }, 120_000); });