diff --git a/CHANGELOG.md b/CHANGELOG.md index d18d3b8c..092ea47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to @rpamis/comet will be documented in this file. -## What's Changed [0.4.0-beta.4] - 2026-07-09 +## What's Changed [0.4.0-beta.4] - 2026-07-10 ### Added @@ -14,6 +14,7 @@ All notable changes to @rpamis/comet will be documented in this file. - **Symlink installs with existing Skills**: `comet init` and `comet update --install-mode symlink` now preserve existing platform `skills/` directories and link Comet-managed Skills inside them, so local or third-party Skills no longer make Comet installation fail ([#171](https://github.com/rpamis/comet/issues/171)). - **OpenSpec CLI install scope**: `comet init` now installs or upgrades the OpenSpec CLI as a global tool even during project-scope setup, so choosing OpenSpec no longer creates a project `node_modules/` directory ([#175](https://github.com/rpamis/comet/issues/175)). - **Archived dashboard artifacts**: `comet dashboard` now resolves archived changes back to the project root before reading `docs/superpowers/` path pointers, so archived Superpowers plans, design docs, and verify reports remain visible ([#176](https://github.com/rpamis/comet/issues/176)). +- **Archive confirmation enforcement**: `/comet-archive` now records the user's final approval in machine-owned Classic state and refuses mutating archive runs until that transition succeeds, preventing direct script invocation from bypassing the required confirmation. ## What's Changed [0.4.0-beta.3] - 2026-07-08 diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 7447e23a..a85023c4 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -38,22 +38,28 @@ node "$COMET_STATE" check archive - 本次归档将执行的不可逆动作:按 OpenSpec delta 语义合并主 spec、标注 design doc / plan、移动 change 到 archive 目录 用户确认问题必须以单选题形式呈现,包含以下选项: -- 「确认归档」— 立即执行归档脚本,完成 spec 合并和 change 移动 +- 「确认归档」— 写入最终确认状态后执行归档脚本,完成 spec 合并和 change 移动 - 「需要调整或重新验证」— 不执行归档;运行 `node "$COMET_STATE" transition archive-reopen` 回到 `phase: verify`,再调用 `/comet-verify`。若验证阶段确认需要修复,再按 `/comet-verify` 的验证失败决策回到 `/comet-build` - 「暂不归档」— 不执行归档,保留当前 `phase: archive` 状态,等待用户稍后再次调用 `/comet-archive` -只有用户选择「确认归档」后,才允许继续 Step 2。用户选择「需要调整或重新验证」后,必须先执行 `archive-reopen` 状态回退,不得手动编辑 `.comet.yaml`。 +用户选择「确认归档」后,立即执行: + +```bash +node "$COMET_STATE" transition archive-confirm +``` + +如 transition 返回非零退出码,报告错误并停止。只有 transition 成功后,才允许继续 Step 2。用户选择「需要调整或重新验证」后,必须先执行 `archive-reopen` 状态回退,不得手动编辑 `.comet.yaml`。 ### 2. 执行归档 -运行归档脚本,自动完成以下全部步骤: +运行归档脚本: ```bash node "$COMET_ARCHIVE" "" ``` 脚本自动执行: -1. 入口状态验证(phase=archive, verify_result=pass, archived=false) +1. 入口状态验证(phase=archive, verify_result=pass, archive_confirmation=confirmed, archived=false) 2. Design doc 前置元数据标注(archived-with, status) 3. Plan 前置元数据标注(archived-with) 4. 调用 OpenSpec archive 按 delta 语义合并主 spec 并移动 change 到归档目录 diff --git a/assets/skills-zh/comet/reference/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index 25439a5f..c6318f18 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -26,6 +26,7 @@ verification_report: null branch_status: pending created_at: 2026-05-26 verified_at: null +archive_confirmation: null archived: false ``` @@ -52,6 +53,7 @@ archived: false | `branch_status` | `pending` 或 `handled`,分支处理完成后设为 `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` 会清空该字段,防止复用旧确认 | | `archived` | change 是否已归档 | ## 可选字段 @@ -70,4 +72,5 @@ archived: false - `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` +- `archive_confirmation` 是 machine-owned 字段,只能由 `verify-pass`、`archive-confirm` 和 `archive-reopen` transition 更新,不能通过 `set` 直接伪造确认;`archived` transition 和真实归档命令都要求其值为 `confirmed` - `preset-escalate` 事件:仅允许 `hotfix`/`tweak` workflow 在 `phase: build` 时调用,原子地把 `workflow`/`classic_profile` 置为 `full`、`phase` 回退到 `design`、清空 `design_doc`(满足 comet-design 入口要求)。这是预设升级到 full 的唯一合法通道——直接 `set phase design` 会被状态机硬拦截,`set classic_profile` 属于 machine-owned 字段不可手动设置 diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 29e57057..1fddb44c 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -38,22 +38,28 @@ Before confirmation, show the user a brief summary: - 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: -- "Confirm archive" — immediately run the archive script to complete spec merge and change movement +- "Confirm archive" — record the final confirmation state, then run the archive script to complete spec merge and change movement - "Needs adjustment or re-verification" — do not archive; run `node "$COMET_STATE" transition archive-reopen` to return to `phase: verify`, then invoke `/comet-verify`. If verification confirms fixes are needed, follow `/comet-verify`'s verification-failure decision flow back to `/comet-build` - "Do not archive yet" — do not archive; keep the current `phase: archive` state and wait for the user to invoke `/comet-archive` again later -Only after the user selects "Confirm archive" may Step 2 continue. After the user selects "Needs adjustment or re-verification", must first run the `archive-reopen` state transition; do not edit `.comet.yaml` manually. +After the user selects "Confirm archive", immediately run: + +```bash +node "$COMET_STATE" transition archive-confirm +``` + +If the transition returns a non-zero exit code, report the error and stop. Only after the transition succeeds may Step 2 continue. After the user selects "Needs adjustment or re-verification", must first run the `archive-reopen` state transition; do not edit `.comet.yaml` manually. ### 2. Execute Archive -Run the archive script to automatically complete all steps: +Run the archive script: ```bash node "$COMET_ARCHIVE" "" ``` The script automatically executes: -1. Entry state validation (phase=archive, verify_result=pass, archived=false) +1. Entry state validation (phase=archive, verify_result=pass, archive_confirmation=confirmed, archived=false) 2. Design doc frontmatter annotation (archived-with, status) 3. Plan frontmatter annotation (archived-with) 4. OpenSpec archive for delta-merge semantics and moving the change to the archive directory diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index 97f47fc4..3dff20f8 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -27,6 +27,7 @@ verification_report: null branch_status: pending created_at: 2026-05-26 verified_at: null +archive_confirmation: null archived: false ``` @@ -53,6 +54,7 @@ archived: false | `branch_status` | `pending` or `handled`; set to `handled` after branch handling completes | | `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 | | `archived` | Whether the change has been archived | ## Optional Fields @@ -71,4 +73,5 @@ archived: false - `build_mode: direct` defaults to `hotfix`/`tweak` only; full workflow requires `direct_override: true` - `build_pause` is not an execution mode; must not be written to `build_mode` - These constraints exist in both `comet-guard.mjs build --apply` and `comet-state.mjs transition build-complete` +- `archive_confirmation` is machine-owned and can only be updated by the `verify-pass`, `archive-confirm`, and `archive-reopen` transitions; it cannot be forged with `set`, and both the `archived` transition and the mutating archive command require `confirmed` - `preset-escalate` event: only allows `hotfix`/`tweak` workflow at `phase: build`; atomically sets `workflow`/`classic_profile` to `full`, rewinds `phase` to `design`, and clears `design_doc` (satisfying the comet-design entry requirement). This is the only legal channel for a preset → full upgrade — direct `set phase design` is hard-blocked by the state machine, and `set classic_profile` is a machine-owned field that cannot be set manually diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index bfbd3e48..9245bde6 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7707,6 +7707,10 @@ async function collectClassicEvidence(changeDir, projection) { handoff.satisfied = false; handoff.detail = "handoff hash is missing"; } + evidence.push({ + code: "archive.confirmed", + satisfied: classic?.archiveConfirmation === "confirmed" + }); return evidence; } @@ -7753,11 +7757,11 @@ function resolveVerify(profile, classic, evidence) { } return `${profile}.verify.branch`; } -function resolveArchive(profile, classic, evidence) { +function resolveArchive(profile, classic) { if (classic.verifyResult !== "pass") { throw new Error("archive requires verify_result=pass"); } - return evidenceSatisfied(evidence, "archive.confirmed") ? `${profile}.archive.execute` : `${profile}.archive.confirm`; + return classic.archiveConfirmation === "confirmed" ? `${profile}.archive.execute` : `${profile}.archive.confirm`; } function resolveClassicStepId(classic, evidence) { const profile = profileFor(classic); @@ -7778,7 +7782,7 @@ function resolveClassicStepId(classic, evidence) { case "verify": return resolveVerify(profile, classic, evidence); case "archive": - return resolveArchive(profile, classic, evidence); + return resolveArchive(profile, classic); } } @@ -7804,6 +7808,7 @@ var ISOLATIONS = ["branch", "worktree"]; var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; +var ARCHIVE_CONFIRMATIONS = ["pending", "confirmed"]; var CLASSIC_WIRE_KEYS = [ "workflow", "language", @@ -7825,6 +7830,7 @@ var CLASSIC_WIRE_KEYS = [ "branch_status", "created_at", "verified_at", + "archive_confirmation", "archived", "direct_override", "handoff_context", @@ -7933,6 +7939,7 @@ function classicStateFromDocument(doc) { branchStatus: enumValue(doc, "branch_status", BRANCH_STATUSES), createdAt: nullableString(doc, "created_at"), verifiedAt: nullableString(doc, "verified_at"), + archiveConfirmation: enumValue(doc, "archive_confirmation", ARCHIVE_CONFIRMATIONS), archived: booleanValue(doc, "archived", false), directOverride: booleanValue(doc, "direct_override"), handoffContext: relativePath(doc, "handoff_context"), @@ -7996,6 +8003,7 @@ function classicStateToDocument(state) { branch_status: state.branchStatus, created_at: state.createdAt, verified_at: state.verifiedAt, + archive_confirmation: state.archiveConfirmation, archived: state.archived, direct_override: state.directOverride, handoff_context: state.handoffContext, @@ -9314,6 +9322,7 @@ var CLASSIC_TRANSITION_EVENTS = [ "build-complete", "verify-pass", "verify-fail", + "archive-confirm", "archive-reopen", "archived", "preset-escalate" @@ -9344,6 +9353,11 @@ var CLASSIC_TRANSITION_TABLE = { from: "verify", guardRefs: ["verification-failed"] }, + "archive-confirm": { + event: "archive-confirm", + from: "archive", + guardRefs: ["archive-final-confirmation"] + }, "archive-reopen": { event: "archive-reopen", from: "archive", @@ -9352,7 +9366,7 @@ var CLASSIC_TRANSITION_TABLE = { archived: { event: "archived", from: "archive", - guardRefs: ["verify-result-pass"] + guardRefs: ["verify-result-pass", "archive-confirmed"] }, "preset-escalate": { event: "preset-escalate", @@ -9400,6 +9414,7 @@ function applyClassicTransition(current, event, options = {}) { setField(classic, effects, "verifyResult", "pass"); setField(classic, effects, "phase", "archive"); setField(classic, effects, "verifiedAt", dateOnly(now)); + setField(classic, effects, "archiveConfirmation", "pending"); } else if (event === "verify-fail") { setField(classic, effects, "verifyResult", "fail"); setField(classic, effects, "phase", "build"); @@ -9413,15 +9428,25 @@ function applyClassicTransition(current, event, options = {}) { setField(classic, effects, "classicProfile", "full"); setField(classic, effects, "phase", "design"); setField(classic, effects, "designDoc", null); + } else if (event === "archive-confirm") { + if (classic.verifyResult !== "pass") { + throw new Error(`Cannot apply ${event}: verifyResult must be pass`); + } + if (classic.archived) throw new Error(`Cannot apply ${event}: already archived`); + setField(classic, effects, "archiveConfirmation", "confirmed"); } else if (event === "archive-reopen") { if (classic.archived) throw new Error(`Cannot apply ${event}: already archived`); setField(classic, effects, "verifyResult", "pending"); setField(classic, effects, "phase", "verify"); setField(classic, effects, "verifiedAt", null); + setField(classic, effects, "archiveConfirmation", null); } else { if (classic.verifyResult !== "pass") { throw new Error(`Cannot apply ${event}: verifyResult must be pass`); } + if (classic.archiveConfirmation !== "confirmed") { + throw new Error(`Cannot apply ${event}: archiveConfirmation must be confirmed`); + } setField(classic, effects, "archived", true); } return { classic, effects, definition }; @@ -9637,6 +9662,13 @@ var classicArchiveCommand = async (args) => { if (runtime.run.pending && runtime.run.pending !== actionId) { throw new ArchiveFailure(red(`FATAL: another action is pending: ${runtime.run.pending}`)); } + if (!recovering && !classic.archived && classic.archiveConfirmation !== "confirmed") { + throw new ArchiveFailure( + red( + `FATAL: archive_confirmation is '${classic.archiveConfirmation ?? "null"}', expected 'confirmed'. Run final archive confirmation first.` + ) + ); + } if (!recovering) { const action = { id: actionId, @@ -9708,7 +9740,10 @@ var classicArchiveCommand = async (args) => { archive_directory: archiveDir }; await writeArtifacts(archiveDir, archivedProjection.run.artifactsRef, artifacts); - const archiveTransition = applyClassicTransition(archivedProjection.classic, "archived"); + const archiveTransition = applyClassicTransition( + recovering && archivedProjection.classic.archiveConfirmation !== "confirmed" ? { ...archivedProjection.classic, archiveConfirmation: "confirmed" } : archivedProjection.classic, + "archived" + ); const archivedClassic = archiveTransition.classic; let transitionedRun = archivedProjection.run; if (archivedProjection.run.currentStep !== "completed" || archivedProjection.run.status !== "completed") { @@ -9937,6 +9972,7 @@ var ENUMS = { auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], classic_profile: ["full", "hotfix", "tweak"], @@ -12537,6 +12573,7 @@ var ARTIFACT_LANGUAGES2 = ["en", "zh-CN"]; var EVENTS = CLASSIC_TRANSITION_EVENTS; var MACHINE_OWNED_FIELDS = /* @__PURE__ */ new Set([ ...RUN_WIRE_KEYS, + "archive_confirmation", "classic_profile", "classic_migration" ]); @@ -12557,6 +12594,7 @@ var FIELD_ENUMS = { auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], classic_profile: PROFILES, @@ -12572,6 +12610,7 @@ var CLASSIC_FIELD_WIRE_NAMES2 = { phase: "phase", verificationReport: "verification_report", verifiedAt: "verified_at", + archiveConfirmation: "archive_confirmation", verifyResult: "verify_result", workflow: "workflow" }; @@ -12750,6 +12789,12 @@ function sparseClassicState(record) { branchStatus: enumRecordValue(record, "branch_status", ["pending", "handled"], null), createdAt: nullableRecordString(record, "created_at"), verifiedAt: nullableRecordString(record, "verified_at"), + archiveConfirmation: enumRecordValue( + record, + "archive_confirmation", + ["pending", "confirmed"], + null + ), archived: nullableRecordBoolean(record, "archived") ?? false, directOverride: nullableRecordBoolean(record, "direct_override"), handoffContext: nullableRecordString(record, "handoff_context"), @@ -12844,7 +12889,7 @@ function validateSetValue(field2, value) { } async function setField2(output, name, field2, value, options = {}) { if (MACHINE_OWNED_FIELDS.has(field2) && !options.machineOwned) { - fail2(`ERROR: '${field2}' is a machine-owned Run field and cannot be set directly`); + fail2(`ERROR: '${field2}' is a machine-owned field and cannot be set directly`); } if (!SETTABLE_FIELDS.has(field2) && !MACHINE_OWNED_FIELDS.has(field2)) { fail2(`ERROR: Unknown field: '${field2}'`); @@ -12931,6 +12976,7 @@ async function init(output, name, workflow) { branch_status: "pending", created_at: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), verified_at: null, + archive_confirmation: null, archived: false }); await atomicWrite2(file, document.toString()); @@ -13078,6 +13124,14 @@ async function transition(output, name, event) { } } else if (event === "verify-fail") { await requirePhase(name, "verify"); + } else if (event === "archive-confirm") { + await requirePhase(name, "archive"); + if (await readField3(name, "verify_result") !== "pass") { + fail2(`ERROR: Cannot transition '${name}': verify_result must be pass before archiving`); + } + if (await readField3(name, "archived") === "true") { + fail2(`ERROR: Cannot transition '${name}': already archived`); + } } else if (event === "preset-escalate") { await requirePhase(name, "build"); const workflow = await readField3(name, "workflow"); @@ -13096,6 +13150,11 @@ async function transition(output, name, event) { if (await readField3(name, "verify_result") !== "pass") { fail2(`ERROR: Cannot transition '${name}': verify_result must be pass before archiving`); } + if (await readField3(name, "archive_confirmation") !== "confirmed") { + fail2( + `ERROR: Cannot transition '${name}': archive_confirmation must be confirmed before archiving` + ); + } } await applyTransitionEvent(output, name, event); } @@ -13371,12 +13430,14 @@ async function recoverVerify(output, name) { ); } async function recoverArchive(output, name) { + const archiveConfirmation = await readField3(name, "archive_confirmation"); output.stdout.push( " Archive:", fieldStatus("verify_result", await readField3(name, "verify_result")), + fieldStatus("archive_confirmation", archiveConfirmation), fieldStatus("archived", await readField3(name, "archived")), "", - "Recovery action: Run /comet-archive to complete archiving." + archiveConfirmation === "confirmed" ? "Recovery action: Archive is confirmed. Run /comet-archive to complete archiving." : "Recovery action: Ask for final archive confirmation in /comet-archive before running the archive command." ); } async function recover(output, name) { diff --git a/domains/comet-classic/classic-archive.ts b/domains/comet-classic/classic-archive.ts index c834e220..23804d14 100644 --- a/domains/comet-classic/classic-archive.ts +++ b/domains/comet-classic/classic-archive.ts @@ -272,6 +272,13 @@ export const classicArchiveCommand: ClassicCommandHandler = async (args) => { if (runtime.run.pending && runtime.run.pending !== actionId) { throw new ArchiveFailure(red(`FATAL: another action is pending: ${runtime.run.pending}`)); } + if (!recovering && !classic.archived && classic.archiveConfirmation !== 'confirmed') { + throw new ArchiveFailure( + red( + `FATAL: archive_confirmation is '${classic.archiveConfirmation ?? 'null'}', expected 'confirmed'. Run final archive confirmation first.`, + ), + ); + } if (!recovering) { const action: EngineAction = { @@ -350,7 +357,12 @@ export const classicArchiveCommand: ClassicCommandHandler = async (args) => { }; await writeArtifacts(archiveDir, archivedProjection.run.artifactsRef, artifacts); - const archiveTransition = applyClassicTransition(archivedProjection.classic, 'archived'); + const archiveTransition = applyClassicTransition( + recovering && archivedProjection.classic.archiveConfirmation !== 'confirmed' + ? { ...archivedProjection.classic, archiveConfirmation: 'confirmed' } + : archivedProjection.classic, + 'archived', + ); const archivedClassic = archiveTransition.classic; let transitionedRun = archivedProjection.run; if ( diff --git a/domains/comet-classic/classic-evidence.ts b/domains/comet-classic/classic-evidence.ts index b0844975..9c07d40b 100644 --- a/domains/comet-classic/classic-evidence.ts +++ b/domains/comet-classic/classic-evidence.ts @@ -136,5 +136,10 @@ export async function collectClassicEvidence( handoff.detail = 'handoff hash is missing'; } + evidence.push({ + code: 'archive.confirmed', + satisfied: classic?.archiveConfirmation === 'confirmed', + }); + return evidence; } diff --git a/domains/comet-classic/classic-resolver.ts b/domains/comet-classic/classic-resolver.ts index 85b6a92e..07a4e046 100644 --- a/domains/comet-classic/classic-resolver.ts +++ b/domains/comet-classic/classic-resolver.ts @@ -65,15 +65,11 @@ function resolveVerify( return `${profile}.verify.branch`; } -function resolveArchive( - profile: ClassicProfile, - classic: ClassicState, - evidence: readonly ClassicEvidence[], -): string { +function resolveArchive(profile: ClassicProfile, classic: ClassicState): string { if (classic.verifyResult !== 'pass') { throw new Error('archive requires verify_result=pass'); } - return evidenceSatisfied(evidence, 'archive.confirmed') + return classic.archiveConfirmation === 'confirmed' ? `${profile}.archive.execute` : `${profile}.archive.confirm`; } @@ -104,7 +100,7 @@ export function resolveClassicStepId( case 'verify': return resolveVerify(profile, classic, evidence); case 'archive': - return resolveArchive(profile, classic, evidence); + return resolveArchive(profile, classic); } } diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index ec2aed89..fc707dd2 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -34,6 +34,7 @@ const ARTIFACT_LANGUAGES = ['en', 'zh-CN'] as const; const EVENTS = CLASSIC_TRANSITION_EVENTS; const MACHINE_OWNED_FIELDS = new Set([ ...RUN_WIRE_KEYS, + 'archive_confirmation', 'classic_profile', 'classic_migration', ]); @@ -55,6 +56,7 @@ const FIELD_ENUMS: Record = { auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], classic_profile: PROFILES, @@ -71,6 +73,7 @@ const CLASSIC_FIELD_WIRE_NAMES: Partial> = { phase: 'phase', verificationReport: 'verification_report', verifiedAt: 'verified_at', + archiveConfirmation: 'archive_confirmation', verifyResult: 'verify_result', workflow: 'workflow', }; @@ -278,6 +281,12 @@ function sparseClassicState(record: Record): ClassicState { branchStatus: enumRecordValue(record, 'branch_status', ['pending', 'handled'] as const, null), createdAt: nullableRecordString(record, 'created_at'), verifiedAt: nullableRecordString(record, 'verified_at'), + archiveConfirmation: enumRecordValue( + record, + 'archive_confirmation', + ['pending', 'confirmed'] as const, + null, + ), archived: nullableRecordBoolean(record, 'archived') ?? false, directOverride: nullableRecordBoolean(record, 'direct_override'), handoffContext: nullableRecordString(record, 'handoff_context'), @@ -399,7 +408,7 @@ async function setField( options: { internal?: boolean; machineOwned?: boolean } = {}, ): Promise { if (MACHINE_OWNED_FIELDS.has(field) && !options.machineOwned) { - fail(`ERROR: '${field}' is a machine-owned Run field and cannot be set directly`); + fail(`ERROR: '${field}' is a machine-owned field and cannot be set directly`); } if (!SETTABLE_FIELDS.has(field) && !MACHINE_OWNED_FIELDS.has(field)) { fail(`ERROR: Unknown field: '${field}'`); @@ -490,6 +499,7 @@ async function init(output: CommandOutput, name: string, workflow: string): Prom branch_status: 'pending', created_at: new Date().toISOString().slice(0, 10), verified_at: null, + archive_confirmation: null, archived: false, }); await atomicWrite(file, document.toString()); @@ -657,6 +667,14 @@ async function transition(output: CommandOutput, name: string, event: string): P } } else if (event === 'verify-fail') { await requirePhase(name, 'verify'); + } else if (event === 'archive-confirm') { + await requirePhase(name, 'archive'); + if ((await readField(name, 'verify_result')) !== 'pass') { + fail(`ERROR: Cannot transition '${name}': verify_result must be pass before archiving`); + } + if ((await readField(name, 'archived')) === 'true') { + fail(`ERROR: Cannot transition '${name}': already archived`); + } } else if (event === 'preset-escalate') { // preset (hotfix/tweak) → full: rewind phase to design so the agent can // supplement a Design Doc before continuing. Unlike verify-fail / @@ -681,6 +699,11 @@ async function transition(output: CommandOutput, name: string, event: string): P if ((await readField(name, 'verify_result')) !== 'pass') { fail(`ERROR: Cannot transition '${name}': verify_result must be pass before archiving`); } + if ((await readField(name, 'archive_confirmation')) !== 'confirmed') { + fail( + `ERROR: Cannot transition '${name}': archive_confirmation must be confirmed before archiving`, + ); + } } await applyTransitionEvent(output, name, event as ClassicTransitionEvent); } @@ -1033,12 +1056,16 @@ async function recoverVerify(output: CommandOutput, name: string): Promise } async function recoverArchive(output: CommandOutput, name: string): Promise { + const archiveConfirmation = await readField(name, 'archive_confirmation'); output.stdout.push( ' Archive:', fieldStatus('verify_result', await readField(name, 'verify_result')), + fieldStatus('archive_confirmation', archiveConfirmation), fieldStatus('archived', await readField(name, 'archived')), '', - 'Recovery action: Run /comet-archive to complete archiving.', + archiveConfirmation === 'confirmed' + ? 'Recovery action: Archive is confirmed. Run /comet-archive to complete archiving.' + : 'Recovery action: Ask for final archive confirmation in /comet-archive before running the archive command.', ); } diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index 35832c0a..c4eb546f 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -16,6 +16,7 @@ const ISOLATIONS = ['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; +const ARCHIVE_CONFIRMATIONS = ['pending', 'confirmed'] as const; export type ClassicProfile = (typeof CLASSIC_PROFILES)[number]; export type ClassicPhase = (typeof PHASES)[number]; @@ -42,6 +43,7 @@ export interface ClassicState { branchStatus: (typeof BRANCH_STATUSES)[number] | null; createdAt: string | null; verifiedAt: string | null; + archiveConfirmation: (typeof ARCHIVE_CONFIRMATIONS)[number] | null; archived: boolean; directOverride: boolean | null; handoffContext: string | null; @@ -77,6 +79,7 @@ export const CLASSIC_WIRE_KEYS = [ 'branch_status', 'created_at', 'verified_at', + 'archive_confirmation', 'archived', 'direct_override', 'handoff_context', @@ -207,6 +210,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { branchStatus: enumValue(doc, 'branch_status', BRANCH_STATUSES), createdAt: nullableString(doc, 'created_at'), verifiedAt: nullableString(doc, 'verified_at'), + archiveConfirmation: enumValue(doc, 'archive_confirmation', ARCHIVE_CONFIRMATIONS), archived: booleanValue(doc, 'archived', false)!, directOverride: booleanValue(doc, 'direct_override'), handoffContext: relativePath(doc, 'handoff_context'), @@ -294,6 +298,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { branch_status: state.branchStatus, created_at: state.createdAt, verified_at: state.verifiedAt, + archive_confirmation: state.archiveConfirmation, archived: state.archived, direct_override: state.directOverride, handoff_context: state.handoffContext, diff --git a/domains/comet-classic/classic-transitions.ts b/domains/comet-classic/classic-transitions.ts index 9cf8e3e7..bb2e7053 100644 --- a/domains/comet-classic/classic-transitions.ts +++ b/domains/comet-classic/classic-transitions.ts @@ -6,6 +6,7 @@ export const CLASSIC_TRANSITION_EVENTS = [ 'build-complete', 'verify-pass', 'verify-fail', + 'archive-confirm', 'archive-reopen', 'archived', 'preset-escalate', @@ -58,6 +59,11 @@ export const CLASSIC_TRANSITION_TABLE: Record = { auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], classic_profile: ['full', 'hotfix', 'tweak'], diff --git a/domains/skill/managed-markdown.ts b/domains/skill/managed-markdown.ts index c9d136ca..37922726 100644 --- a/domains/skill/managed-markdown.ts +++ b/domains/skill/managed-markdown.ts @@ -55,10 +55,7 @@ export function renderManagedMarkdownBlock( return `<${tagName}>${lineEnding}${normalizedContent}${lineEnding}${lineEnding}`; } -function assertSingleCompleteBlock( - lines: string[], - tagName: string, -): ManagedBlockLocation | null { +function assertSingleCompleteBlock(lines: string[], tagName: string): ManagedBlockLocation | null { const openTag = `<${tagName}>`; const closeTag = ``; let startLine: number | null = null; @@ -135,7 +132,8 @@ export async function mergeManagedMarkdownBlock( const blockLocation = assertSingleCompleteBlock(lines, options.tagName); if (blockLocation === null) { - const separator = normalizedExisting.length === 0 ? '' : normalizedExisting.endsWith('\n') ? '\n' : '\n\n'; + const separator = + normalizedExisting.length === 0 ? '' : normalizedExisting.endsWith('\n') ? '\n' : '\n\n'; const updated = `${normalizedExisting}${separator}${normalizedBlock}`; await fs.writeFile(filePath, restoreLineEndings(updated, lineEnding), 'utf8'); return { action: 'appended', changed: true }; diff --git a/scripts/benchmark/classic-baseline-regression.mjs b/scripts/benchmark/classic-baseline-regression.mjs index eaafcaad..ece95918 100644 --- a/scripts/benchmark/classic-baseline-regression.mjs +++ b/scripts/benchmark/classic-baseline-regression.mjs @@ -60,7 +60,9 @@ function camelToSnake(str) { async function readState(changeDir) { const yamlState = parse(await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8')); try { - const runJson = JSON.parse(await fs.readFile(path.join(changeDir, '.comet', 'run-state.json'), 'utf8')); + const runJson = JSON.parse( + await fs.readFile(path.join(changeDir, '.comet', 'run-state.json'), 'utf8'), + ); for (const [key, value] of Object.entries(runJson)) { yamlState[camelToSnake(key)] = value; } @@ -263,6 +265,7 @@ async function archiveRecoveryScenario(workspace) { state(directory, 'init', name, 'full'); state(directory, 'set', name, 'phase', 'archive'); state(directory, 'set', name, 'verify_result', 'pass'); + state(directory, 'transition', name, 'archive-confirm'); const openspec = await fakeOpenSpec(directory); const interrupted = run(directory, ['archive', name], { env: { COMET_OPENSPEC: openspec }, diff --git a/test/domains/comet-classic/classic-archive.test.ts b/test/domains/comet-classic/classic-archive.test.ts index 535178f2..00b1ef1d 100644 --- a/test/domains/comet-classic/classic-archive.test.ts +++ b/test/domains/comet-classic/classic-archive.test.ts @@ -45,6 +45,11 @@ async function seedArchiveChange(dir: string): Promise { return path.join(dir, 'openspec', 'changes', 'demo'); } +function confirmArchiveChange(dir: string): void { + const result = run(dir, ['state', 'transition', 'demo', 'archive-confirm']); + expect(result.status).toBe(0); +} + async function fakeOpenSpec( dir: string, mode: 'success' | 'fail' | 'move-fail', @@ -117,9 +122,28 @@ describe('Classic archive command', () => { }); }); + it('rejects mutating archive before final archive confirmation', async () => { + const dir = await makeProject(); + const changeDir = await seedArchiveChange(dir); + const fake = await fakeOpenSpec(dir, 'success'); + + const result = run(dir, ['archive', 'demo'], { COMET_OPENSPEC: fake.command }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('archive_confirmation'); + await expect(fs.access(fake.log)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(changeDir)).resolves.toBeUndefined(); + const state = parse(await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8')) as Record< + string, + unknown + >; + expect(state.archived).toBe(false); + }); + it('archives a verified change and completes its Run transaction', async () => { const dir = await makeProject(); await seedArchiveChange(dir); + confirmArchiveChange(dir); const fake = await fakeOpenSpec(dir, 'success'); const result = run(dir, ['archive', 'demo'], { COMET_OPENSPEC: fake.command }); @@ -160,6 +184,7 @@ describe('Classic archive command', () => { it('treats a completed archive retry as an idempotent no-op', async () => { const dir = await makeProject(); await seedArchiveChange(dir); + confirmArchiveChange(dir); const fake = await fakeOpenSpec(dir, 'success'); expect(run(dir, ['archive', 'demo'], { COMET_OPENSPEC: fake.command }).status).toBe(0); const archiveDir = path.join( @@ -183,6 +208,7 @@ describe('Classic archive command', () => { it('keeps a recoverable pending marker when OpenSpec fails before moving files', async () => { const dir = await makeProject(); const changeDir = await seedArchiveChange(dir); + confirmArchiveChange(dir); const fake = await fakeOpenSpec(dir, 'fail'); const result = run(dir, ['archive', 'demo'], { COMET_OPENSPEC: fake.command }); @@ -204,6 +230,7 @@ describe('Classic archive command', () => { it('reconciles an archive that moved before the external process was interrupted', async () => { const dir = await makeProject(); await seedArchiveChange(dir); + confirmArchiveChange(dir); const interrupted = await fakeOpenSpec(dir, 'move-fail'); expect(run(dir, ['archive', 'demo'], { COMET_OPENSPEC: interrupted.command }).status).toBe(9); const logBeforeRetry = await fs.readFile(interrupted.log, 'utf8'); diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index a5140301..67efd88a 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -155,6 +155,7 @@ function normalizeOutput(value: string, root: string): string { // 0.3.9 baseline (e.g. preset-escalate) in the "Valid values:" error line. // These are intentional enhancements; strip them so the differential // contract compares rejection behavior, not the exact event enumeration. + .replace(/(Valid values: .*) archive-confirm/g, '$1') .replace(/(Valid values: .*) preset-escalate/g, '$1'); const lines = normalizedValue.split('\n'); const kept: string[] = []; @@ -197,6 +198,7 @@ function legacyProjection(document: Record): Record { branchStatus: 'handled', createdAt: '2026-06-14', verifiedAt: '2026-06-14', + archiveConfirmation: null, archived: false, directOverride: null, handoffContext: 'openspec/changes/demo/.comet/handoff/context.json', @@ -135,6 +136,14 @@ describe('Classic evidence collection', () => { expect(evidenceSatisfied(evidence, 'run.checkpoint')).toBe(false); }); + it('derives archive confirmation evidence from Classic state', async () => { + projection.classic!.archiveConfirmation = 'confirmed'; + + const evidence = await collectClassicEvidence(changeDir, projection); + + expect(evidenceSatisfied(evidence, 'archive.confirmed')).toBe(true); + }); + async function writeProjectFile(relativePath: string, content: string): Promise { const file = path.join(projectRoot, relativePath); await fs.mkdir(path.dirname(file), { recursive: true }); diff --git a/test/domains/comet-classic/classic-migrate.test.ts b/test/domains/comet-classic/classic-migrate.test.ts index 622f685c..eb9ca69f 100644 --- a/test/domains/comet-classic/classic-migrate.test.ts +++ b/test/domains/comet-classic/classic-migrate.test.ts @@ -67,6 +67,7 @@ function classic(overrides: Partial = {}): ClassicState { branchStatus: 'pending', createdAt: '2026-06-14', verifiedAt: null, + archiveConfirmation: null, archived: false, directOverride: null, handoffContext: null, diff --git a/test/domains/comet-classic/classic-resolver.test.ts b/test/domains/comet-classic/classic-resolver.test.ts index a98ca81c..77b86d3c 100644 --- a/test/domains/comet-classic/classic-resolver.test.ts +++ b/test/domains/comet-classic/classic-resolver.test.ts @@ -26,6 +26,7 @@ function state(overrides: Partial = {}): ClassicState { branchStatus: 'pending', createdAt: '2026-06-14', verifiedAt: null, + archiveConfirmation: null, archived: false, directOverride: null, handoffContext: null, @@ -150,8 +151,9 @@ const cases: ResolverCase[] = [ verifyResult: 'pass', verificationReport: 'verification.md', branchStatus: 'handled', + archiveConfirmation: 'confirmed', }), - evidence: evidence('verification.report', 'archive.confirmed'), + evidence: evidence('verification.report'), expected: 'full.archive.execute', }, { @@ -207,8 +209,9 @@ const cases: ResolverCase[] = [ verifyResult: 'pass', verificationReport: 'verification.md', branchStatus: 'handled', + archiveConfirmation: 'confirmed', }), - evidence: evidence('verification.report', 'archive.confirmed'), + evidence: evidence('verification.report'), expected: 'tweak.archive.execute', }, ]; diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index 05c1495d..924d8e4f 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -31,6 +31,7 @@ function classicState(): ClassicState { branchStatus: 'handled', createdAt: '2026-06-01', verifiedAt: '2026-06-02', + archiveConfirmation: null, archived: false, directOverride: true, handoffContext: '.comet/handoff/context.json', @@ -148,6 +149,7 @@ describe('Classic state projection', () => { ['verify_mode', 'medium'], ['verify_result', 'maybe'], ['branch_status', 'open'], + ['archive_confirmation', 'yes'], ['classic_profile', 'other'], ])('rejects invalid %s values', async (field, value) => { await writeClassicState(changeDir, { classic: classicState(), run: runState() }); diff --git a/test/domains/comet-classic/comet-scripts-recovery.test.ts b/test/domains/comet-classic/comet-scripts-recovery.test.ts index 684d62be..83983620 100644 --- a/test/domains/comet-classic/comet-scripts-recovery.test.ts +++ b/test/domains/comet-classic/comet-scripts-recovery.test.ts @@ -228,6 +228,9 @@ describe('check --recover', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('Phase: archive'); - expect(result.stdout).toContain('Recovery action: Run /comet-archive to complete archiving.'); + expect(result.stdout).toContain('archive_confirmation: PENDING'); + expect(result.stdout).toContain( + 'Recovery action: Ask for final archive confirmation in /comet-archive before running the archive command.', + ); }); }); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 0c3b6137..fb167837 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2746,6 +2746,7 @@ describe('comet scripts', () => { 'verification_report: docs/superpowers/reports/ready.md', 'branch_status: handled', 'verified_at: 2026-05-21', + 'archive_confirmation: confirmed', 'archived: false', '', ].join('\n'), @@ -2808,6 +2809,7 @@ describe('comet scripts', () => { 'verification_report: docs/superpowers/reports/merge.md', 'branch_status: handled', 'verified_at: 2026-05-21', + 'archive_confirmation: confirmed', 'archived: false', '', ].join('\n'), @@ -2888,6 +2890,7 @@ describe('comet scripts', () => { 'verification_report: docs/superpowers/reports/utc.md', 'branch_status: handled', 'verified_at: 2026-05-21', + 'archive_confirmation: confirmed', 'archived: false', '', ].join('\n'), @@ -3363,11 +3366,27 @@ describe('comet scripts', () => { ), ); + const unverifiedConfirmation = runNode(tmpDir, stateScript, [ + 'transition', + 'archive-not-passed', + 'archive-confirm', + ]); const blocked = runNode(tmpDir, stateScript, ['transition', 'archive-not-passed', 'archived']); + expect(unverifiedConfirmation.status).toBe(1); + expect(unverifiedConfirmation.stderr).toContain('verify_result must be pass before archiving'); expect(blocked.status).toBe(1); expect(blocked.stderr).toContain('verify_result must be pass before archiving'); runNode(tmpDir, stateScript, ['set', 'archive-not-passed', 'verify_result', 'pass']); + const unconfirmed = runNode(tmpDir, stateScript, [ + 'transition', + 'archive-not-passed', + 'archived', + ]); + expect(unconfirmed.status).toBe(1); + expect(unconfirmed.stderr).toContain('archive_confirmation must be confirmed before archiving'); + + runNode(tmpDir, stateScript, ['transition', 'archive-not-passed', 'archive-confirm']); const ok = runNode(tmpDir, stateScript, ['transition', 'archive-not-passed', 'archived']); expect(ok.status).toBe(0); }); @@ -3430,11 +3449,69 @@ describe('comet scripts', () => { const passedPhase = runNode(tmpDir, stateScript, ['get', 'verify-change', 'phase']); const passedResult = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verify_result']); const verifiedAt = runNode(tmpDir, stateScript, ['get', 'verify-change', 'verified_at']); + const archiveConfirmation = runNode(tmpDir, stateScript, [ + 'get', + 'verify-change', + 'archive_confirmation', + ]); expect(pass.status).toBe(0); expect(passedPhase.stdout.trim()).toBe('archive'); expect(passedResult.stdout.trim()).toBe('pass'); expect(verifiedAt.stdout.trim()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(archiveConfirmation.stdout.trim()).toBe('pending'); + }, 20_000); + + it('confirms archive only after the final archive confirmation decision', async () => { + await createChange( + tmpDir, + 'archive-confirm', + [ + 'workflow: full', + 'phase: archive', + 'build_mode: executing-plans', + 'build_pause: null', + 'tdd_mode: tdd', + 'isolation: branch', + 'verify_mode: full', + 'design_doc: null', + 'plan: null', + 'verify_result: pass', + 'branch_status: handled', + 'verified_at: 2026-06-05', + 'archive_confirmation: pending', + 'archived: false', + '', + ].join('\n'), + ); + + const bypass = runNode(tmpDir, stateScript, [ + 'set', + 'archive-confirm', + 'archive_confirmation', + 'confirmed', + ]); + const pending = runNode(tmpDir, stateScript, [ + 'get', + 'archive-confirm', + 'archive_confirmation', + ]); + const result = runNode(tmpDir, stateScript, [ + 'transition', + 'archive-confirm', + 'archive-confirm', + ]); + const confirmation = runNode(tmpDir, stateScript, [ + 'get', + 'archive-confirm', + 'archive_confirmation', + ]); + + expect(bypass.status).not.toBe(0); + expect(bypass.stderr).toContain('machine-owned field'); + expect(pending.stdout.trim()).toBe('pending'); + expect(result.status).toBe(0); + expect(confirmation.stdout.trim()).toBe('confirmed'); }, 20_000); it('reopens archive phase for adjustment or re-verification before archiving', async () => { @@ -3459,6 +3536,7 @@ describe('comet scripts', () => { 'verification_report: docs/superpowers/reports/archive-reopen.md', 'branch_status: handled', 'verified_at: 2026-06-05', + 'archive_confirmation: confirmed', 'archived: false', '', ].join('\n'), @@ -3470,6 +3548,11 @@ describe('comet scripts', () => { const verifiedAt = runNode(tmpDir, stateScript, ['get', 'archive-reopen', 'verified_at']); const report = runNode(tmpDir, stateScript, ['get', 'archive-reopen', 'verification_report']); const branchStatus = runNode(tmpDir, stateScript, ['get', 'archive-reopen', 'branch_status']); + const confirmation = runNode(tmpDir, stateScript, [ + 'get', + 'archive-reopen', + 'archive_confirmation', + ]); expect(result.status).toBe(0); expect(phase.stdout.trim()).toBe('verify'); @@ -3477,6 +3560,7 @@ describe('comet scripts', () => { expect(verifiedAt.stdout.trim()).toBe('null'); expect(report.stdout.trim()).toBe('docs/superpowers/reports/archive-reopen.md'); expect(branchStatus.stdout.trim()).toBe('handled'); + expect(confirmation.stdout.trim()).toBe('null'); }, 20_000); it('rejects archive-reopen after the change is already archived', async () => { @@ -3511,11 +3595,18 @@ describe('comet scripts', () => { 'already-archived', 'archive-reopen', ]); + const confirm = runNode(tmpDir, stateScript, [ + 'transition', + 'already-archived', + 'archive-confirm', + ]); const phase = runNode(tmpDir, stateScript, ['get', 'already-archived', 'phase']); const verifyResult = runNode(tmpDir, stateScript, ['get', 'already-archived', 'verify_result']); expect(result.status).not.toBe(0); expect(result.stderr).toContain('already archived'); + expect(confirm.status).not.toBe(0); + expect(confirm.stderr).toContain('already archived'); expect(phase.stdout.trim()).toBe('archive'); expect(verifyResult.stdout.trim()).toBe('pass'); }, 20_000); @@ -3618,9 +3709,16 @@ describe('comet scripts', () => { ); const result = runNode(tmpDir, stateScript, ['transition', 'wrong-phase', 'build-complete']); + const archiveConfirm = runNode(tmpDir, stateScript, [ + 'transition', + 'wrong-phase', + 'archive-confirm', + ]); expect(result.status).not.toBe(0); expect(result.stderr).toContain('expected phase build'); + expect(archiveConfirm.status).not.toBe(0); + expect(archiveConfirm.stderr).toContain('expected phase archive'); }); it('escalates preset workflows from build to design via preset-escalate', async () => { @@ -3747,6 +3845,7 @@ describe('comet scripts', () => { 'plan: null', 'verify_result: pass', 'verified_at: 2026-05-21', + 'archive_confirmation: confirmed', 'archived: false', '', ].join('\n'),