From 02d56b99ff2cf5d05d0a998b368188f91217c99b Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 19:38:27 +0800 Subject: [PATCH 01/25] feat(comet-classic): add current isolation mode --- assets/skills/comet/scripts/comet-runtime.mjs | 797 +++++++++--------- domains/comet-classic/classic-guard.ts | 6 +- domains/comet-classic/classic-resolver.ts | 9 +- .../comet-classic/classic-state-command.ts | 18 +- domains/comet-classic/classic-state.ts | 11 +- .../comet-classic/classic-validate-command.ts | 4 +- .../comet-classic/classic-hook-guard.test.ts | 7 +- .../comet-classic/classic-resolver.test.ts | 12 + .../comet-classic/classic-state.test.ts | 10 + .../comet-classic/comet-scripts.test.ts | 43 +- 10 files changed, 509 insertions(+), 408 deletions(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 83c9b5cb..f42dfc93 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -49,9 +49,173 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js +// domains/engine/state.ts +var state_exports = {}; +__export(state_exports, { + RUN_STATE_FILE: () => RUN_STATE_FILE, + applyRunStateToDocument: () => applyRunStateToDocument, + readRunState: () => readRunState, + removeRunState: () => removeRunState, + runStateFromDocument: () => runStateFromDocument, + writeRunState: () => writeRunState +}); +import { randomUUID } from "crypto"; +import { promises as fs3 } from "fs"; +import path3 from "path"; +function requiredString(doc, key) { + const value = doc[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Invalid Run state: ${key} must be a non-empty string`); + } + return value; +} +function requiredRunReference(doc, key) { + const value = requiredString(doc, key); + if (path3.isAbsolute(value) || /^(?:[A-Za-z]:|[\\/]|~)/u.test(value) || value.split(/[\\/]/u).includes("..")) { + throw new Error(`Invalid Run state: ${key} must stay inside the change directory`); + } + return value; +} +function retries(doc) { + const raw = doc.run_retries ?? "{}"; + let value; + try { + value = typeof raw === "string" ? JSON.parse(raw) : raw; + } catch (error) { + throw new Error("Invalid Run state: run_retries must be a JSON object", { cause: error }); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Run state: run_retries must be a JSON object"); + } + for (const count of Object.values(value)) { + if (!Number.isInteger(count) || Number(count) < 0) { + throw new Error("Invalid Run state: retry counts must be non-negative integers"); + } + } + return value; +} +function runStateFromDocument(doc) { + if (!doc.run_id) return null; + const runId = requiredString(doc, "run_id"); + const skill = requiredString(doc, "skill"); + const skillVersion = requiredString(doc, "skill_version"); + const skillHash = requiredString(doc, "skill_hash"); + const pendingRef = requiredRunReference(doc, "pending_ref"); + const trajectoryRef = requiredRunReference(doc, "trajectory_ref"); + const contextRef = requiredRunReference(doc, "context_ref"); + const artifactsRef = requiredRunReference(doc, "artifacts_ref"); + const checkpointRef = requiredRunReference(doc, "checkpoint_ref"); + const iteration = Number(doc.iteration); + if (!Number.isInteger(iteration) || iteration < 0) { + throw new Error("Invalid Run state: iteration must be a non-negative integer"); + } + if (doc.orchestration !== "deterministic" && doc.orchestration !== "adaptive") { + throw new Error("Invalid Run state: orchestration must be deterministic or adaptive"); + } + if (doc.run_status !== "running" && doc.run_status !== "waiting" && doc.run_status !== "completed" && doc.run_status !== "failed") { + throw new Error("Invalid Run state: run_status is invalid"); + } + return { + runId, + skill, + skillVersion, + skillHash, + orchestration: doc.orchestration, + currentStep: field(doc, "current_step"), + iteration, + pending: field(doc, "pending"), + pendingRef, + trajectoryRef, + contextRef, + artifactsRef, + checkpointRef, + status: doc.run_status, + retries: retries(doc) + }; +} +function applyRunStateToDocument(doc, state) { + if (state) { + doc.run_id = state.runId; + } else { + delete doc.run_id; + } +} +function runStateToJson(state) { + return { + runId: state.runId, + skill: state.skill, + skillVersion: state.skillVersion, + skillHash: state.skillHash, + orchestration: state.orchestration, + currentStep: state.currentStep, + iteration: state.iteration, + pending: state.pending, + pendingRef: state.pendingRef, + trajectoryRef: state.trajectoryRef, + contextRef: state.contextRef, + artifactsRef: state.artifactsRef, + checkpointRef: state.checkpointRef, + status: state.status, + retries: state.retries + }; +} +function runStateFromJson(json) { + const doc = { + run_id: json.runId, + skill: json.skill, + skill_version: json.skillVersion, + skill_hash: json.skillHash, + orchestration: json.orchestration, + current_step: json.currentStep, + iteration: json.iteration, + pending: json.pending, + pending_ref: json.pendingRef, + trajectory_ref: json.trajectoryRef, + context_ref: json.contextRef, + artifacts_ref: json.artifactsRef, + checkpoint_ref: json.checkpointRef, + run_status: json.status, + run_retries: JSON.stringify(json.retries) + }; + return runStateFromDocument(doc); +} +async function readRunState(changeDir) { + const file = path3.join(changeDir, RUN_STATE_FILE); + let raw; + try { + raw = await fs3.readFile(file, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + const json = JSON.parse(raw); + return runStateFromJson(json); +} +async function writeRunState(changeDir, state) { + await fs3.mkdir(path3.join(changeDir, ".comet"), { recursive: true }); + const file = path3.join(changeDir, RUN_STATE_FILE); + const temporary = path3.join(changeDir, ".comet", `run-state.${randomUUID()}.tmp`); + await fs3.writeFile(temporary, JSON.stringify(runStateToJson(state), null, 2), "utf8"); + await fs3.rename(temporary, file); +} +async function removeRunState(changeDir) { + await fs3.rm(path3.join(changeDir, RUN_STATE_FILE), { force: true }); +} +var field, RUN_STATE_FILE; +var init_state = __esm({ + "domains/engine/state.ts"() { + "use strict"; + field = (doc, key) => { + const value = doc[key]; + return value === null || value === void 0 ? null : String(value); + }; + RUN_STATE_FILE = ".comet/run-state.json"; + } +}); + +// node_modules/yaml/dist/nodes/identity.js var require_identity = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js"(exports) { + "node_modules/yaml/dist/nodes/identity.js"(exports) { "use strict"; var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias"); var DOC = /* @__PURE__ */ Symbol.for("yaml.document"); @@ -106,9 +270,9 @@ var require_identity = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/visit.js +// node_modules/yaml/dist/visit.js var require_visit = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/visit.js"(exports) { + "node_modules/yaml/dist/visit.js"(exports) { "use strict"; var identity = require_identity(); var BREAK = /* @__PURE__ */ Symbol("break visit"); @@ -264,9 +428,9 @@ var require_visit = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/directives.js +// node_modules/yaml/dist/doc/directives.js var require_directives = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/directives.js"(exports) { + "node_modules/yaml/dist/doc/directives.js"(exports) { "use strict"; var identity = require_identity(); var visit = require_visit(); @@ -435,9 +599,9 @@ var require_directives = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/anchors.js +// node_modules/yaml/dist/doc/anchors.js var require_anchors = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/anchors.js"(exports) { + "node_modules/yaml/dist/doc/anchors.js"(exports) { "use strict"; var identity = require_identity(); var visit = require_visit(); @@ -505,9 +669,9 @@ var require_anchors = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/applyReviver.js +// node_modules/yaml/dist/doc/applyReviver.js var require_applyReviver = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/applyReviver.js"(exports) { + "node_modules/yaml/dist/doc/applyReviver.js"(exports) { "use strict"; function applyReviver(reviver, obj, key, val) { if (val && typeof val === "object") { @@ -555,9 +719,9 @@ var require_applyReviver = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/toJS.js +// node_modules/yaml/dist/nodes/toJS.js var require_toJS = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/toJS.js"(exports) { + "node_modules/yaml/dist/nodes/toJS.js"(exports) { "use strict"; var identity = require_identity(); function toJS(value, arg, ctx) { @@ -585,9 +749,9 @@ var require_toJS = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Node.js +// node_modules/yaml/dist/nodes/Node.js var require_Node = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Node.js"(exports) { + "node_modules/yaml/dist/nodes/Node.js"(exports) { "use strict"; var applyReviver = require_applyReviver(); var identity = require_identity(); @@ -626,9 +790,9 @@ var require_Node = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Alias.js +// node_modules/yaml/dist/nodes/Alias.js var require_Alias = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Alias.js"(exports) { + "node_modules/yaml/dist/nodes/Alias.js"(exports) { "use strict"; var anchors = require_anchors(); var visit = require_visit(); @@ -742,9 +906,9 @@ var require_Alias = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Scalar.js +// node_modules/yaml/dist/nodes/Scalar.js var require_Scalar = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Scalar.js"(exports) { + "node_modules/yaml/dist/nodes/Scalar.js"(exports) { "use strict"; var identity = require_identity(); var Node = require_Node(); @@ -772,9 +936,9 @@ var require_Scalar = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/createNode.js +// node_modules/yaml/dist/doc/createNode.js var require_createNode = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/createNode.js"(exports) { + "node_modules/yaml/dist/doc/createNode.js"(exports) { "use strict"; var Alias = require_Alias(); var identity = require_identity(); @@ -847,9 +1011,9 @@ var require_createNode = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Collection.js +// node_modules/yaml/dist/nodes/Collection.js var require_Collection = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Collection.js"(exports) { + "node_modules/yaml/dist/nodes/Collection.js"(exports) { "use strict"; var createNode = require_createNode(); var identity = require_identity(); @@ -990,9 +1154,9 @@ var require_Collection = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyComment.js +// node_modules/yaml/dist/stringify/stringifyComment.js var require_stringifyComment = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyComment.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyComment.js"(exports) { "use strict"; var stringifyComment = (str) => str.replace(/^(?!$)(?: $)?/gm, "#"); function indentComment(comment, indent) { @@ -1007,9 +1171,9 @@ var require_stringifyComment = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/foldFlowLines.js +// node_modules/yaml/dist/stringify/foldFlowLines.js var require_foldFlowLines = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/foldFlowLines.js"(exports) { + "node_modules/yaml/dist/stringify/foldFlowLines.js"(exports) { "use strict"; var FOLD_FLOW = "flow"; var FOLD_BLOCK = "block"; @@ -1143,9 +1307,9 @@ ${indent}${text2.slice(fold + 1, end2)}`; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyString.js +// node_modules/yaml/dist/stringify/stringifyString.js var require_stringifyString = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyString.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyString.js"(exports) { "use strict"; var Scalar = require_Scalar(); var foldFlowLines = require_foldFlowLines(); @@ -1426,9 +1590,9 @@ ${indent}`); } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringify.js +// node_modules/yaml/dist/stringify/stringify.js var require_stringify = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringify.js"(exports) { + "node_modules/yaml/dist/stringify/stringify.js"(exports) { "use strict"; var anchors = require_anchors(); var identity = require_identity(); @@ -1550,9 +1714,9 @@ ${ctx.indent}${str}`; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyPair.js +// node_modules/yaml/dist/stringify/stringifyPair.js var require_stringifyPair = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyPair.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyPair.js"(exports) { "use strict"; var identity = require_identity(); var Scalar = require_Scalar(); @@ -1683,9 +1847,9 @@ ${ctx.indent}`; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/log.js +// node_modules/yaml/dist/log.js var require_log = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/log.js"(exports) { + "node_modules/yaml/dist/log.js"(exports) { "use strict"; var node_process = __require("process"); function debug(logLevel, ...messages) { @@ -1705,9 +1869,9 @@ var require_log = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/merge.js +// node_modules/yaml/dist/schema/yaml-1.1/merge.js var require_merge = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/merge.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/merge.js"(exports) { "use strict"; var identity = require_identity(); var Scalar = require_Scalar(); @@ -1765,9 +1929,9 @@ var require_merge = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/addPairToJSMap.js +// node_modules/yaml/dist/nodes/addPairToJSMap.js var require_addPairToJSMap = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/addPairToJSMap.js"(exports) { + "node_modules/yaml/dist/nodes/addPairToJSMap.js"(exports) { "use strict"; var log = require_log(); var merge = require_merge(); @@ -1829,9 +1993,9 @@ var require_addPairToJSMap = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Pair.js +// node_modules/yaml/dist/nodes/Pair.js var require_Pair = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/Pair.js"(exports) { + "node_modules/yaml/dist/nodes/Pair.js"(exports) { "use strict"; var createNode = require_createNode(); var stringifyPair = require_stringifyPair(); @@ -1869,9 +2033,9 @@ var require_Pair = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyCollection.js +// node_modules/yaml/dist/stringify/stringifyCollection.js var require_stringifyCollection = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyCollection.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyCollection.js"(exports) { "use strict"; var identity = require_identity(); var stringify = require_stringify(); @@ -2020,9 +2184,9 @@ ${indent}${end}`; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/YAMLMap.js +// node_modules/yaml/dist/nodes/YAMLMap.js var require_YAMLMap = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/YAMLMap.js"(exports) { + "node_modules/yaml/dist/nodes/YAMLMap.js"(exports) { "use strict"; var stringifyCollection = require_stringifyCollection(); var addPairToJSMap = require_addPairToJSMap(); @@ -2164,9 +2328,9 @@ var require_YAMLMap = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/map.js +// node_modules/yaml/dist/schema/common/map.js var require_map = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/map.js"(exports) { + "node_modules/yaml/dist/schema/common/map.js"(exports) { "use strict"; var identity = require_identity(); var YAMLMap = require_YAMLMap(); @@ -2186,9 +2350,9 @@ var require_map = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/YAMLSeq.js +// node_modules/yaml/dist/nodes/YAMLSeq.js var require_YAMLSeq = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/YAMLSeq.js"(exports) { + "node_modules/yaml/dist/nodes/YAMLSeq.js"(exports) { "use strict"; var createNode = require_createNode(); var stringifyCollection = require_stringifyCollection(); @@ -2302,9 +2466,9 @@ var require_YAMLSeq = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/seq.js +// node_modules/yaml/dist/schema/common/seq.js var require_seq = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/seq.js"(exports) { + "node_modules/yaml/dist/schema/common/seq.js"(exports) { "use strict"; var identity = require_identity(); var YAMLSeq = require_YAMLSeq(); @@ -2324,9 +2488,9 @@ var require_seq = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/string.js +// node_modules/yaml/dist/schema/common/string.js var require_string = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/string.js"(exports) { + "node_modules/yaml/dist/schema/common/string.js"(exports) { "use strict"; var stringifyString = require_stringifyString(); var string = { @@ -2343,9 +2507,9 @@ var require_string = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/null.js +// node_modules/yaml/dist/schema/common/null.js var require_null = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/common/null.js"(exports) { + "node_modules/yaml/dist/schema/common/null.js"(exports) { "use strict"; var Scalar = require_Scalar(); var nullTag = { @@ -2361,9 +2525,9 @@ var require_null = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/bool.js +// node_modules/yaml/dist/schema/core/bool.js var require_bool = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/bool.js"(exports) { + "node_modules/yaml/dist/schema/core/bool.js"(exports) { "use strict"; var Scalar = require_Scalar(); var boolTag = { @@ -2385,9 +2549,9 @@ var require_bool = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyNumber.js +// node_modules/yaml/dist/stringify/stringifyNumber.js var require_stringifyNumber = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyNumber.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyNumber.js"(exports) { "use strict"; function stringifyNumber({ format, minFractionDigits, tag, value }) { if (typeof value === "bigint") @@ -2412,9 +2576,9 @@ var require_stringifyNumber = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/float.js +// node_modules/yaml/dist/schema/core/float.js var require_float = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/float.js"(exports) { + "node_modules/yaml/dist/schema/core/float.js"(exports) { "use strict"; var Scalar = require_Scalar(); var stringifyNumber = require_stringifyNumber(); @@ -2458,9 +2622,9 @@ var require_float = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/int.js +// node_modules/yaml/dist/schema/core/int.js var require_int = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/int.js"(exports) { + "node_modules/yaml/dist/schema/core/int.js"(exports) { "use strict"; var stringifyNumber = require_stringifyNumber(); var intIdentify = (value) => typeof value === "bigint" || Number.isInteger(value); @@ -2503,9 +2667,9 @@ var require_int = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/schema.js +// node_modules/yaml/dist/schema/core/schema.js var require_schema = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/core/schema.js"(exports) { + "node_modules/yaml/dist/schema/core/schema.js"(exports) { "use strict"; var map = require_map(); var _null = require_null(); @@ -2531,9 +2695,9 @@ var require_schema = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/json/schema.js +// node_modules/yaml/dist/schema/json/schema.js var require_schema2 = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/json/schema.js"(exports) { + "node_modules/yaml/dist/schema/json/schema.js"(exports) { "use strict"; var Scalar = require_Scalar(); var map = require_map(); @@ -2598,9 +2762,9 @@ var require_schema2 = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/binary.js +// node_modules/yaml/dist/schema/yaml-1.1/binary.js var require_binary = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/binary.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/binary.js"(exports) { "use strict"; var node_buffer = __require("buffer"); var Scalar = require_Scalar(); @@ -2664,9 +2828,9 @@ var require_binary = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/pairs.js +// node_modules/yaml/dist/schema/yaml-1.1/pairs.js var require_pairs = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/pairs.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/pairs.js"(exports) { "use strict"; var identity = require_identity(); var Pair = require_Pair(); @@ -2742,9 +2906,9 @@ ${cn.comment}` : item.comment; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/omap.js +// node_modules/yaml/dist/schema/yaml-1.1/omap.js var require_omap = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/omap.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/omap.js"(exports) { "use strict"; var identity = require_identity(); var toJS = require_toJS(); @@ -2820,9 +2984,9 @@ var require_omap = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/bool.js +// node_modules/yaml/dist/schema/yaml-1.1/bool.js var require_bool2 = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/bool.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/bool.js"(exports) { "use strict"; var Scalar = require_Scalar(); function boolStringify({ value, source }, ctx) { @@ -2852,9 +3016,9 @@ var require_bool2 = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/float.js +// node_modules/yaml/dist/schema/yaml-1.1/float.js var require_float2 = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/float.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/float.js"(exports) { "use strict"; var Scalar = require_Scalar(); var stringifyNumber = require_stringifyNumber(); @@ -2901,9 +3065,9 @@ var require_float2 = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/int.js +// node_modules/yaml/dist/schema/yaml-1.1/int.js var require_int2 = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/int.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/int.js"(exports) { "use strict"; var stringifyNumber = require_stringifyNumber(); var intIdentify = (value) => typeof value === "bigint" || Number.isInteger(value); @@ -2980,9 +3144,9 @@ var require_int2 = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/set.js +// node_modules/yaml/dist/schema/yaml-1.1/set.js var require_set = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/set.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/set.js"(exports) { "use strict"; var identity = require_identity(); var Pair = require_Pair(); @@ -3069,9 +3233,9 @@ var require_set = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js +// node_modules/yaml/dist/schema/yaml-1.1/timestamp.js var require_timestamp = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/timestamp.js"(exports) { "use strict"; var stringifyNumber = require_stringifyNumber(); function parseSexagesimal(str, asBigInt) { @@ -3157,9 +3321,9 @@ var require_timestamp = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/schema.js +// node_modules/yaml/dist/schema/yaml-1.1/schema.js var require_schema3 = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/yaml-1.1/schema.js"(exports) { + "node_modules/yaml/dist/schema/yaml-1.1/schema.js"(exports) { "use strict"; var map = require_map(); var _null = require_null(); @@ -3201,9 +3365,9 @@ var require_schema3 = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/tags.js +// node_modules/yaml/dist/schema/tags.js var require_tags = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/tags.js"(exports) { + "node_modules/yaml/dist/schema/tags.js"(exports) { "use strict"; var map = require_map(); var _null = require_null(); @@ -3295,9 +3459,9 @@ var require_tags = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/Schema.js +// node_modules/yaml/dist/schema/Schema.js var require_Schema = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/schema/Schema.js"(exports) { + "node_modules/yaml/dist/schema/Schema.js"(exports) { "use strict"; var identity = require_identity(); var map = require_map(); @@ -3327,9 +3491,9 @@ var require_Schema = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyDocument.js +// node_modules/yaml/dist/stringify/stringifyDocument.js var require_stringifyDocument = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/stringify/stringifyDocument.js"(exports) { + "node_modules/yaml/dist/stringify/stringifyDocument.js"(exports) { "use strict"; var identity = require_identity(); var stringify = require_stringify(); @@ -3407,9 +3571,9 @@ var require_stringifyDocument = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/Document.js +// node_modules/yaml/dist/doc/Document.js var require_Document = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/doc/Document.js"(exports) { + "node_modules/yaml/dist/doc/Document.js"(exports) { "use strict"; var Alias = require_Alias(); var Collection = require_Collection(); @@ -3716,9 +3880,9 @@ var require_Document = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/errors.js +// node_modules/yaml/dist/errors.js var require_errors = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/errors.js"(exports) { + "node_modules/yaml/dist/errors.js"(exports) { "use strict"; var YAMLError = class extends Error { constructor(name, pos, code, message) { @@ -3781,9 +3945,9 @@ ${pointer} } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-props.js +// node_modules/yaml/dist/compose/resolve-props.js var require_resolve_props = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-props.js"(exports) { + "node_modules/yaml/dist/compose/resolve-props.js"(exports) { "use strict"; function resolveProps(tokens, { flow, indicator, next: next2, offset, onError, parentIndent, startOnNewline }) { let spaceBefore = false; @@ -3915,9 +4079,9 @@ var require_resolve_props = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-contains-newline.js +// node_modules/yaml/dist/compose/util-contains-newline.js var require_util_contains_newline = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-contains-newline.js"(exports) { + "node_modules/yaml/dist/compose/util-contains-newline.js"(exports) { "use strict"; function containsNewline(key) { if (!key) @@ -3957,9 +4121,9 @@ var require_util_contains_newline = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-flow-indent-check.js +// node_modules/yaml/dist/compose/util-flow-indent-check.js var require_util_flow_indent_check = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-flow-indent-check.js"(exports) { + "node_modules/yaml/dist/compose/util-flow-indent-check.js"(exports) { "use strict"; var utilContainsNewline = require_util_contains_newline(); function flowIndentCheck(indent, fc, onError) { @@ -3975,9 +4139,9 @@ var require_util_flow_indent_check = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-map-includes.js +// node_modules/yaml/dist/compose/util-map-includes.js var require_util_map_includes = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-map-includes.js"(exports) { + "node_modules/yaml/dist/compose/util-map-includes.js"(exports) { "use strict"; var identity = require_identity(); function mapIncludes(ctx, items, search) { @@ -3991,9 +4155,9 @@ var require_util_map_includes = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-map.js +// node_modules/yaml/dist/compose/resolve-block-map.js var require_resolve_block_map = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-map.js"(exports) { + "node_modules/yaml/dist/compose/resolve-block-map.js"(exports) { "use strict"; var Pair = require_Pair(); var YAMLMap = require_YAMLMap(); @@ -4099,9 +4263,9 @@ var require_resolve_block_map = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-seq.js +// node_modules/yaml/dist/compose/resolve-block-seq.js var require_resolve_block_seq = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-seq.js"(exports) { + "node_modules/yaml/dist/compose/resolve-block-seq.js"(exports) { "use strict"; var YAMLSeq = require_YAMLSeq(); var resolveProps = require_resolve_props(); @@ -4150,9 +4314,9 @@ var require_resolve_block_seq = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-end.js +// node_modules/yaml/dist/compose/resolve-end.js var require_resolve_end = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-end.js"(exports) { + "node_modules/yaml/dist/compose/resolve-end.js"(exports) { "use strict"; function resolveEnd(end, offset, reqSpace, onError) { let comment = ""; @@ -4193,9 +4357,9 @@ var require_resolve_end = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-flow-collection.js +// node_modules/yaml/dist/compose/resolve-flow-collection.js var require_resolve_flow_collection = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-flow-collection.js"(exports) { + "node_modules/yaml/dist/compose/resolve-flow-collection.js"(exports) { "use strict"; var identity = require_identity(); var Pair = require_Pair(); @@ -4387,9 +4551,9 @@ var require_resolve_flow_collection = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-collection.js +// node_modules/yaml/dist/compose/compose-collection.js var require_compose_collection = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-collection.js"(exports) { + "node_modules/yaml/dist/compose/compose-collection.js"(exports) { "use strict"; var identity = require_identity(); var Scalar = require_Scalar(); @@ -4452,9 +4616,9 @@ var require_compose_collection = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-scalar.js +// node_modules/yaml/dist/compose/resolve-block-scalar.js var require_resolve_block_scalar = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-block-scalar.js"(exports) { + "node_modules/yaml/dist/compose/resolve-block-scalar.js"(exports) { "use strict"; var Scalar = require_Scalar(); function resolveBlockScalar(ctx, scalar2, onError) { @@ -4635,9 +4799,9 @@ var require_resolve_block_scalar = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-flow-scalar.js +// node_modules/yaml/dist/compose/resolve-flow-scalar.js var require_resolve_flow_scalar = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/resolve-flow-scalar.js"(exports) { + "node_modules/yaml/dist/compose/resolve-flow-scalar.js"(exports) { "use strict"; var Scalar = require_Scalar(); var resolveEnd = require_resolve_end(); @@ -4855,9 +5019,9 @@ var require_resolve_flow_scalar = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-scalar.js +// node_modules/yaml/dist/compose/compose-scalar.js var require_compose_scalar = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-scalar.js"(exports) { + "node_modules/yaml/dist/compose/compose-scalar.js"(exports) { "use strict"; var identity = require_identity(); var Scalar = require_Scalar(); @@ -4936,9 +5100,9 @@ var require_compose_scalar = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-empty-scalar-position.js +// node_modules/yaml/dist/compose/util-empty-scalar-position.js var require_util_empty_scalar_position = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports) { + "node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports) { "use strict"; function emptyScalarPosition(offset, before, pos) { if (before) { @@ -4966,9 +5130,9 @@ var require_util_empty_scalar_position = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-node.js +// node_modules/yaml/dist/compose/compose-node.js var require_compose_node = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-node.js"(exports) { + "node_modules/yaml/dist/compose/compose-node.js"(exports) { "use strict"; var Alias = require_Alias(); var identity = require_identity(); @@ -5072,9 +5236,9 @@ var require_compose_node = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-doc.js +// node_modules/yaml/dist/compose/compose-doc.js var require_compose_doc = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/compose-doc.js"(exports) { + "node_modules/yaml/dist/compose/compose-doc.js"(exports) { "use strict"; var Document3 = require_Document(); var composeNode = require_compose_node(); @@ -5115,9 +5279,9 @@ var require_compose_doc = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/composer.js +// node_modules/yaml/dist/compose/composer.js var require_composer = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/compose/composer.js"(exports) { + "node_modules/yaml/dist/compose/composer.js"(exports) { "use strict"; var node_process = __require("process"); var directives = require_directives(); @@ -5323,9 +5487,9 @@ ${end.comment}` : end.comment; } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-scalar.js +// node_modules/yaml/dist/parse/cst-scalar.js var require_cst_scalar = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-scalar.js"(exports) { + "node_modules/yaml/dist/parse/cst-scalar.js"(exports) { "use strict"; var resolveBlockScalar = require_resolve_block_scalar(); var resolveFlowScalar = require_resolve_flow_scalar(); @@ -5508,9 +5672,9 @@ var require_cst_scalar = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-stringify.js +// node_modules/yaml/dist/parse/cst-stringify.js var require_cst_stringify = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-stringify.js"(exports) { + "node_modules/yaml/dist/parse/cst-stringify.js"(exports) { "use strict"; var stringify = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst); function stringifyToken(token) { @@ -5569,9 +5733,9 @@ var require_cst_stringify = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-visit.js +// node_modules/yaml/dist/parse/cst-visit.js var require_cst_visit = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst-visit.js"(exports) { + "node_modules/yaml/dist/parse/cst-visit.js"(exports) { "use strict"; var BREAK = /* @__PURE__ */ Symbol("break visit"); var SKIP = /* @__PURE__ */ Symbol("skip children"); @@ -5631,9 +5795,9 @@ var require_cst_visit = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst.js +// node_modules/yaml/dist/parse/cst.js var require_cst = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/cst.js"(exports) { + "node_modules/yaml/dist/parse/cst.js"(exports) { "use strict"; var cstScalar = require_cst_scalar(); var cstStringify = require_cst_stringify(); @@ -5733,9 +5897,9 @@ var require_cst = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/lexer.js +// node_modules/yaml/dist/parse/lexer.js var require_lexer = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/lexer.js"(exports) { + "node_modules/yaml/dist/parse/lexer.js"(exports) { "use strict"; var cst = require_cst(); function isEmpty(ch) { @@ -6322,9 +6486,9 @@ var require_lexer = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/line-counter.js +// node_modules/yaml/dist/parse/line-counter.js var require_line_counter = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/line-counter.js"(exports) { + "node_modules/yaml/dist/parse/line-counter.js"(exports) { "use strict"; var LineCounter = class { constructor() { @@ -6353,9 +6517,9 @@ var require_line_counter = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/parser.js +// node_modules/yaml/dist/parse/parser.js var require_parser = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/parse/parser.js"(exports) { + "node_modules/yaml/dist/parse/parser.js"(exports) { "use strict"; var node_process = __require("process"); var cst = require_cst(); @@ -7227,9 +7391,9 @@ var require_parser = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/public-api.js +// node_modules/yaml/dist/public-api.js var require_public_api = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/public-api.js"(exports) { + "node_modules/yaml/dist/public-api.js"(exports) { "use strict"; var composer = require_composer(); var Document3 = require_Document(); @@ -7324,9 +7488,9 @@ var require_public_api = __commonJS({ } }); -// node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/index.js +// node_modules/yaml/dist/index.js var require_dist = __commonJS({ - "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/index.js"(exports) { + "node_modules/yaml/dist/index.js"(exports) { "use strict"; var composer = require_composer(); var Document3 = require_Document(); @@ -7376,170 +7540,6 @@ var require_dist = __commonJS({ } }); -// domains/engine/state.ts -var state_exports = {}; -__export(state_exports, { - RUN_STATE_FILE: () => RUN_STATE_FILE, - applyRunStateToDocument: () => applyRunStateToDocument, - readRunState: () => readRunState, - removeRunState: () => removeRunState, - runStateFromDocument: () => runStateFromDocument, - writeRunState: () => writeRunState -}); -import { randomUUID } from "crypto"; -import { promises as fs3 } from "fs"; -import path3 from "path"; -function requiredString(doc, key) { - const value = doc[key]; - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Invalid Run state: ${key} must be a non-empty string`); - } - return value; -} -function requiredRunReference(doc, key) { - const value = requiredString(doc, key); - if (path3.isAbsolute(value) || /^(?:[A-Za-z]:|[\\/]|~)/u.test(value) || value.split(/[\\/]/u).includes("..")) { - throw new Error(`Invalid Run state: ${key} must stay inside the change directory`); - } - return value; -} -function retries(doc) { - const raw = doc.run_retries ?? "{}"; - let value; - try { - value = typeof raw === "string" ? JSON.parse(raw) : raw; - } catch (error) { - throw new Error("Invalid Run state: run_retries must be a JSON object", { cause: error }); - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Invalid Run state: run_retries must be a JSON object"); - } - for (const count of Object.values(value)) { - if (!Number.isInteger(count) || Number(count) < 0) { - throw new Error("Invalid Run state: retry counts must be non-negative integers"); - } - } - return value; -} -function runStateFromDocument(doc) { - if (!doc.run_id) return null; - const runId = requiredString(doc, "run_id"); - const skill = requiredString(doc, "skill"); - const skillVersion = requiredString(doc, "skill_version"); - const skillHash = requiredString(doc, "skill_hash"); - const pendingRef = requiredRunReference(doc, "pending_ref"); - const trajectoryRef = requiredRunReference(doc, "trajectory_ref"); - const contextRef = requiredRunReference(doc, "context_ref"); - const artifactsRef = requiredRunReference(doc, "artifacts_ref"); - const checkpointRef = requiredRunReference(doc, "checkpoint_ref"); - const iteration = Number(doc.iteration); - if (!Number.isInteger(iteration) || iteration < 0) { - throw new Error("Invalid Run state: iteration must be a non-negative integer"); - } - if (doc.orchestration !== "deterministic" && doc.orchestration !== "adaptive") { - throw new Error("Invalid Run state: orchestration must be deterministic or adaptive"); - } - if (doc.run_status !== "running" && doc.run_status !== "waiting" && doc.run_status !== "completed" && doc.run_status !== "failed") { - throw new Error("Invalid Run state: run_status is invalid"); - } - return { - runId, - skill, - skillVersion, - skillHash, - orchestration: doc.orchestration, - currentStep: field(doc, "current_step"), - iteration, - pending: field(doc, "pending"), - pendingRef, - trajectoryRef, - contextRef, - artifactsRef, - checkpointRef, - status: doc.run_status, - retries: retries(doc) - }; -} -function applyRunStateToDocument(doc, state) { - if (state) { - doc.run_id = state.runId; - } else { - delete doc.run_id; - } -} -function runStateToJson(state) { - return { - runId: state.runId, - skill: state.skill, - skillVersion: state.skillVersion, - skillHash: state.skillHash, - orchestration: state.orchestration, - currentStep: state.currentStep, - iteration: state.iteration, - pending: state.pending, - pendingRef: state.pendingRef, - trajectoryRef: state.trajectoryRef, - contextRef: state.contextRef, - artifactsRef: state.artifactsRef, - checkpointRef: state.checkpointRef, - status: state.status, - retries: state.retries - }; -} -function runStateFromJson(json) { - const doc = { - run_id: json.runId, - skill: json.skill, - skill_version: json.skillVersion, - skill_hash: json.skillHash, - orchestration: json.orchestration, - current_step: json.currentStep, - iteration: json.iteration, - pending: json.pending, - pending_ref: json.pendingRef, - trajectory_ref: json.trajectoryRef, - context_ref: json.contextRef, - artifacts_ref: json.artifactsRef, - checkpoint_ref: json.checkpointRef, - run_status: json.status, - run_retries: JSON.stringify(json.retries) - }; - return runStateFromDocument(doc); -} -async function readRunState(changeDir) { - const file = path3.join(changeDir, RUN_STATE_FILE); - let raw; - try { - raw = await fs3.readFile(file, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return null; - throw error; - } - const json = JSON.parse(raw); - return runStateFromJson(json); -} -async function writeRunState(changeDir, state) { - await fs3.mkdir(path3.join(changeDir, ".comet"), { recursive: true }); - const file = path3.join(changeDir, RUN_STATE_FILE); - const temporary = path3.join(changeDir, ".comet", `run-state.${randomUUID()}.tmp`); - await fs3.writeFile(temporary, JSON.stringify(runStateToJson(state), null, 2), "utf8"); - await fs3.rename(temporary, file); -} -async function removeRunState(changeDir) { - await fs3.rm(path3.join(changeDir, RUN_STATE_FILE), { force: true }); -} -var field, RUN_STATE_FILE; -var init_state = __esm({ - "domains/engine/state.ts"() { - "use strict"; - field = (doc, key) => { - const value = doc[key]; - return value === null || value === void 0 ? null : String(value); - }; - RUN_STATE_FILE = ".comet/run-state.json"; - } -}); - // domains/comet-classic/classic-cli.ts import { pathToFileURL } from "url"; @@ -7723,79 +7723,6 @@ import { createHash as createHash2, randomUUID as randomUUID5 } from "crypto"; import { promises as fs8 } from "fs"; import path9 from "path"; -// domains/comet-classic/classic-resolver.ts -function profileFor(classic) { - return classic.classicProfile ?? classic.workflow; -} -function fullBuildConfigured(classic) { - if (!classic.buildMode || !classic.tddMode || !classic.isolation || !classic.verifyMode) { - return false; - } - if (classic.buildMode === "subagent-driven-development") { - return classic.subagentDispatch === "confirmed"; - } - if (classic.buildMode === "direct") return classic.directOverride === true; - return true; -} -function presetBuildConfigured(classic) { - return Boolean( - classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation === "branch" && classic.verifyMode === "light" - ); -} -function resolveBuild(profile, classic, evidence) { - if (classic.verifyResult === "fail") { - return profile === "full" ? "full.build.fix" : `${profile}.build.execute`; - } - if (profile === "full") { - if (!evidenceSatisfied(evidence, "build.plan")) return "full.build.plan"; - if (classic.buildPause === "plan-ready") return "full.build.plan-ready"; - if (!fullBuildConfigured(classic)) return "full.build.configure"; - } else if (!presetBuildConfigured(classic)) { - throw new Error(`${profile} build configuration is incomplete`); - } - return evidenceSatisfied(evidence, "build.tasks-complete") ? `${profile}.build.complete` : `${profile}.build.execute`; -} -function resolveVerify(profile, classic, evidence) { - if (classic.verifyResult !== "pass" || !evidenceSatisfied(evidence, "verification.report")) { - return `${profile}.verify.run`; - } - return `${profile}.verify.branch`; -} -function resolveArchive(profile, classic) { - if (classic.verifyResult !== "pass") { - throw new Error("archive requires verify_result=pass"); - } - return classic.archiveConfirmation === "confirmed" ? `${profile}.archive.execute` : `${profile}.archive.confirm`; -} -function resolveClassicStepId(classic, evidence) { - const profile = profileFor(classic); - if (classic.archived && classic.phase !== "archive") { - throw new Error("archived=true requires phase=archive"); - } - if (classic.archived) return "completed"; - if (profile !== "full" && classic.phase === "design") { - throw new Error(`${profile} workflow cannot enter design`); - } - switch (classic.phase) { - case "open": - return `${profile}.open`; - case "design": - return evidenceSatisfied(evidence, "design.handoff") ? "full.design.document" : "full.design.handoff"; - case "build": - return resolveBuild(profile, classic, evidence); - case "verify": - return resolveVerify(profile, classic, evidence); - case "archive": - return resolveArchive(profile, classic); - } -} - -// domains/comet-classic/classic-store.ts -var import_yaml = __toESM(require_dist(), 1); -import { randomUUID as randomUUID2 } from "crypto"; -import { promises as fs4 } from "fs"; -import path4 from "path"; - // domains/comet-classic/classic-state.ts init_state(); var CLASSIC_PROFILES = ["full", "hotfix", "tweak"]; @@ -7808,7 +7735,15 @@ 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 ISOLATION_MODES = [ + { value: "branch", allowedInPreset: true }, + { value: "worktree", allowedInPreset: false }, + { value: "current", allowedInPreset: true } +]; +var ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value); +var PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter( + (mode) => mode.allowedInPreset +).map((mode) => mode.value); var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; @@ -8017,7 +7952,78 @@ function classicStateToDocument(state) { }; } +// domains/comet-classic/classic-resolver.ts +function profileFor(classic) { + return classic.classicProfile ?? classic.workflow; +} +function fullBuildConfigured(classic) { + if (!classic.buildMode || !classic.tddMode || !classic.isolation || !classic.verifyMode) { + return false; + } + if (classic.buildMode === "subagent-driven-development") { + return classic.subagentDispatch === "confirmed"; + } + if (classic.buildMode === "direct") return classic.directOverride === true; + return true; +} +function presetBuildConfigured(classic) { + return Boolean( + classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation !== null && PRESET_ALLOWED_ISOLATIONS.includes(classic.isolation) && classic.verifyMode === "light" + ); +} +function resolveBuild(profile, classic, evidence) { + if (classic.verifyResult === "fail") { + return profile === "full" ? "full.build.fix" : `${profile}.build.execute`; + } + if (profile === "full") { + if (!evidenceSatisfied(evidence, "build.plan")) return "full.build.plan"; + if (classic.buildPause === "plan-ready") return "full.build.plan-ready"; + if (!fullBuildConfigured(classic)) return "full.build.configure"; + } else if (!presetBuildConfigured(classic)) { + throw new Error(`${profile} build configuration is incomplete`); + } + return evidenceSatisfied(evidence, "build.tasks-complete") ? `${profile}.build.complete` : `${profile}.build.execute`; +} +function resolveVerify(profile, classic, evidence) { + if (classic.verifyResult !== "pass" || !evidenceSatisfied(evidence, "verification.report")) { + return `${profile}.verify.run`; + } + return `${profile}.verify.branch`; +} +function resolveArchive(profile, classic) { + if (classic.verifyResult !== "pass") { + throw new Error("archive requires verify_result=pass"); + } + return classic.archiveConfirmation === "confirmed" ? `${profile}.archive.execute` : `${profile}.archive.confirm`; +} +function resolveClassicStepId(classic, evidence) { + const profile = profileFor(classic); + if (classic.archived && classic.phase !== "archive") { + throw new Error("archived=true requires phase=archive"); + } + if (classic.archived) return "completed"; + if (profile !== "full" && classic.phase === "design") { + throw new Error(`${profile} workflow cannot enter design`); + } + switch (classic.phase) { + case "open": + return `${profile}.open`; + case "design": + return evidenceSatisfied(evidence, "design.handoff") ? "full.design.document" : "full.design.handoff"; + case "build": + return resolveBuild(profile, classic, evidence); + case "verify": + return resolveVerify(profile, classic, evidence); + case "archive": + return resolveArchive(profile, classic); + } +} + // domains/comet-classic/classic-store.ts +var import_yaml = __toESM(require_dist(), 1); +import { randomUUID as randomUUID2 } from "crypto"; +import { promises as fs4 } from "fs"; +import path4 from "path"; init_state(); function documentRecord(document) { const value = document.toJS(); @@ -10095,7 +10101,7 @@ var ENUMS = { subagent_dispatch: ["confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["branch", "worktree"], + isolation: ISOLATIONS, verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], @@ -10644,11 +10650,11 @@ Next: check off corresponding completed plan tasks, then commit the plan update. } async function isolationSelected(changeDir, change) { const isolation = await readField(changeDir, "isolation"); - if (isolation === "branch" || isolation === "worktree") return pass(); + if (ISOLATIONS.includes(isolation)) return pass(); 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 one of ${ISOLATIONS.join(", ")}, got '${isolation || "null"}' +Next: ask the user to choose branch, worktree, or current, create the chosen isolation when needed, then run: + node "$COMET_STATE" set ${change} isolation <${ISOLATIONS.join("|")}>` ); } async function buildModeSelected(changeDir, change) { @@ -12943,7 +12949,7 @@ var FIELD_ENUMS = { subagent_dispatch: ["null", "confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["branch", "worktree"], + isolation: ISOLATIONS, verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], @@ -13127,7 +13133,7 @@ function sparseClassicState(record) { ["off", "standard", "thorough"], null ), - isolation: enumRecordValue(record, "isolation", ["branch", "worktree"], null), + isolation: enumRecordValue(record, "isolation", ISOLATIONS, null), verifyMode: enumRecordValue(record, "verify_mode", ["light", "full"], null), autoTransition: nullableRecordBoolean(record, "auto_transition"), baseRef: nullableRecordString(record, "base_ref"), @@ -13315,7 +13321,7 @@ async function init(output, name, workflow) { subagent_dispatch: null, tdd_mode: preset ? "direct" : null, review_mode: reviewMode, - isolation: preset ? "branch" : null, + isolation: null, verify_mode: preset ? "light" : null, auto_transition: await autoTransition() === "true", base_ref: gitOutput(["rev-parse", "--verify", "HEAD"]), @@ -13346,9 +13352,9 @@ 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)) { + if (!ISOLATIONS.includes(isolation)) { fail2( - `ERROR: Cannot transition '${name}': isolation must be branch or worktree, got '${isolation || "null"}'` + `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(", ")}, got '${isolation || "null"}'` ); } if (!["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) { @@ -13927,6 +13933,13 @@ async function currentChange(output) { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === "selected") { output.stdout.push(resolution.selection.change); + const { directory } = await resolveClassicChangeDirectory(resolution.selection.change); + const projection = await readClassicState(directory, { migrate: false }); + const isolation = projection.classic?.isolation ?? null; + const branch = resolution.selection.branch ?? gitOutput(["rev-parse", "--abbrev-ref", "HEAD"]); + output.stderr.push( + `[CURRENT] isolation: ${isolation ?? "null"}${branch ? `, branch: ${branch}` : ""}` + ); return; } if (resolution.status === "missing") { diff --git a/domains/comet-classic/classic-guard.ts b/domains/comet-classic/classic-guard.ts index 021aba0a..4354e38e 100644 --- a/domains/comet-classic/classic-guard.ts +++ b/domains/comet-classic/classic-guard.ts @@ -13,7 +13,7 @@ import { inspectClassicChange } from './classic-diagnostics.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; import { ensureClassicRuntimeRun, transitionClassicRuntimeRun } from './classic-runtime-run.js'; import type { ClassicRunContext } from './classic-migrate.js'; -import type { ClassicPhase, ClassicState } from './classic-state.js'; +import { ISOLATIONS, type ClassicPhase, type ClassicState } from './classic-state.js'; import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_GUARD_TRANSITION_EVENT, applyClassicTransition } from './classic-transitions.js'; import { classicValidateCommand } from './classic-validate-command.js'; @@ -487,9 +487,9 @@ async function planTasksAllDone(changeDir: string): Promise { async function isolationSelected(changeDir: string, change: string): Promise { const isolation = await readField(changeDir, 'isolation'); - if (isolation === 'branch' || isolation === 'worktree') return pass(); + if ((ISOLATIONS as readonly string[]).includes(isolation)) return pass(); 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 one of ${ISOLATIONS.join(', ')}, got '${isolation || 'null'}'\nNext: ask the user to choose branch, worktree, or current, create the chosen isolation when needed, then run:\n node "$COMET_STATE" set ${change} isolation <${ISOLATIONS.join('|')}>`, ); } diff --git a/domains/comet-classic/classic-resolver.ts b/domains/comet-classic/classic-resolver.ts index 07a4e046..d6a8d810 100644 --- a/domains/comet-classic/classic-resolver.ts +++ b/domains/comet-classic/classic-resolver.ts @@ -1,7 +1,11 @@ import type { DeterministicResolver } from '../../domains/engine/resolver.js'; import type { ClassicEvidence } from './classic-evidence.js'; import { evidenceSatisfied } from './classic-evidence.js'; -import type { ClassicProfile, ClassicState } from './classic-state.js'; +import { + PRESET_ALLOWED_ISOLATIONS, + type ClassicProfile, + type ClassicState, +} from './classic-state.js'; export interface ClassicResolverContext { classic: ClassicState; @@ -27,7 +31,8 @@ function presetBuildConfigured(classic: ClassicState): boolean { return Boolean( classic.buildMode === 'direct' && classic.tddMode === 'direct' && - classic.isolation === 'branch' && + classic.isolation !== null && + PRESET_ALLOWED_ISOLATIONS.includes(classic.isolation) && classic.verifyMode === 'light', ); } diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 15717b4d..b91b77a2 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -16,6 +16,7 @@ import { transitionClassicRuntimeRun, validateClassicRuntimeRun } from './classi import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_WIRE_KEYS, + ISOLATIONS, RUN_WIRE_KEYS, parseClassicStateDocument, type ClassicState, @@ -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: ISOLATIONS, verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], @@ -272,7 +273,7 @@ function sparseClassicState(record: Record): ClassicState { ['off', 'standard', 'thorough'] as const, null, ), - isolation: enumRecordValue(record, 'isolation', ['branch', 'worktree'] as const, null), + isolation: enumRecordValue(record, 'isolation', ISOLATIONS, null), verifyMode: enumRecordValue(record, 'verify_mode', ['light', 'full'] as const, null), autoTransition: nullableRecordBoolean(record, 'auto_transition'), baseRef: nullableRecordString(record, 'base_ref'), @@ -491,7 +492,7 @@ 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: null, verify_mode: preset ? 'light' : null, auto_transition: (await autoTransition()) === 'true', base_ref: gitOutput(['rev-parse', '--verify', 'HEAD']), @@ -524,9 +525,9 @@ 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)) { + if (!(ISOLATIONS as readonly string[]).includes(isolation)) { fail( - `ERROR: Cannot transition '${name}': isolation must be branch or worktree, got '${isolation || 'null'}'`, + `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(', ')}, got '${isolation || 'null'}'`, ); } if (!['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) { @@ -1228,6 +1229,13 @@ async function currentChange(output: CommandOutput): Promise { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === 'selected') { output.stdout.push(resolution.selection.change); + const { directory } = await resolveClassicChangeDirectory(resolution.selection.change); + const projection = await readClassicState(directory, { migrate: false }); + const isolation = projection.classic?.isolation ?? null; + const branch = resolution.selection.branch ?? gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']); + output.stderr.push( + `[CURRENT] isolation: ${isolation ?? 'null'}${branch ? `, branch: ${branch}` : ''}`, + ); return; } if (resolution.status === 'missing') { diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index c4eb546f..646b310d 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -12,7 +12,16 @@ 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; +export const ISOLATION_MODES = [ + { value: 'branch', allowedInPreset: true }, + { value: 'worktree', allowedInPreset: false }, + { value: 'current', allowedInPreset: true }, +] as const; +type IsolationMode = (typeof ISOLATION_MODES)[number]['value']; +export const ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value) as IsolationMode[]; +export const PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.allowedInPreset).map( + (mode) => mode.value, +) as IsolationMode[]; const VERIFY_MODES = ['light', 'full'] as const; const VERIFY_RESULTS = ['pending', 'pass', 'fail'] as const; const BRANCH_STATUSES = ['pending', 'handled'] as const; diff --git a/domains/comet-classic/classic-validate-command.ts b/domains/comet-classic/classic-validate-command.ts index f54b3527..d00277f2 100644 --- a/domains/comet-classic/classic-validate-command.ts +++ b/domains/comet-classic/classic-validate-command.ts @@ -3,7 +3,7 @@ import path from 'path'; import { isMap, parseDocument } from 'yaml'; import type { ClassicCommandHandler } from './classic-cli.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; -import { CLASSIC_WIRE_KEYS, RUN_WIRE_KEYS } from './classic-state.js'; +import { CLASSIC_WIRE_KEYS, ISOLATIONS, RUN_WIRE_KEYS } from './classic-state.js'; const GREEN = '\u001b[32m'; const RED = '\u001b[31m'; @@ -31,7 +31,7 @@ const ENUMS: Record = { subagent_dispatch: ['confirmed'], tdd_mode: ['tdd', 'direct'], review_mode: ['off', 'standard', 'thorough'], - isolation: ['branch', 'worktree'], + isolation: ISOLATIONS, verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], diff --git a/test/domains/comet-classic/classic-hook-guard.test.ts b/test/domains/comet-classic/classic-hook-guard.test.ts index 2b8e4534..304dad1d 100644 --- a/test/domains/comet-classic/classic-hook-guard.test.ts +++ b/test/domains/comet-classic/classic-hook-guard.test.ts @@ -238,13 +238,18 @@ describe('Classic hook guard command', () => { it('selects, reads, and clears the current change through the state launcher', async () => { const dir = await makeProject(); + await initializeGitProject(dir); expect(run(dir, 'state', ['init', 'demo', 'hotfix']).status).toBe(0); + expect(run(dir, 'state', ['set', 'demo', 'isolation', 'current']).status).toBe(0); const selected = run(dir, 'state', ['select', 'demo']); expect(selected.status).toBe(0); expect(selected.stderr).toContain('[SELECTED] current change: demo'); - expect(run(dir, 'state', ['current']).stdout.trim()).toBe('demo'); + const current = run(dir, 'state', ['current']); + expect(current.stdout.trim()).toBe('demo'); + expect(current.stderr).toContain('[CURRENT] isolation: current'); + expect(current.stderr).toContain('branch: main'); expect(run(dir, 'state', ['clear-selection']).status).toBe(0); expect(run(dir, 'state', ['clear-selection']).status).toBe(0); expect(run(dir, 'state', ['current']).status).not.toBe(0); diff --git a/test/domains/comet-classic/classic-resolver.test.ts b/test/domains/comet-classic/classic-resolver.test.ts index 77b86d3c..00a8d667 100644 --- a/test/domains/comet-classic/classic-resolver.test.ts +++ b/test/domains/comet-classic/classic-resolver.test.ts @@ -196,6 +196,18 @@ const cases: ResolverCase[] = [ evidence: evidence('build.tasks-complete'), expected: 'hotfix.build.complete', }, + { + name: 'hotfix build execution on current branch isolation', + classic: state({ + workflow: 'hotfix', + phase: 'build', + buildMode: 'direct', + tddMode: 'direct', + isolation: 'current', + verifyMode: 'light', + }), + expected: 'hotfix.build.execute', + }, { name: 'tweak verification', classic: state({ workflow: 'tweak', phase: 'verify' }), diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index 924d8e4f..b0851b67 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -162,6 +162,16 @@ describe('Classic state projection', () => { await expect(readClassicState(changeDir)).rejects.toThrow(`Invalid Classic state: ${field}`); }); + it('accepts isolation=current in persisted state', async () => { + const state = classicState(); + state.isolation = 'current'; + await writeClassicState(changeDir, { classic: state, run: runState() }); + + const projection = await readClassicState(changeDir); + + expect(projection.classic?.isolation).toBe('current'); + }); + it('rejects malformed YAML without replacing the original file', async () => { const malformed = 'workflow: [full\nphase: build\n'; await fs.writeFile(stateFile, malformed); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index dcfb4f52..99594bfd 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2041,10 +2041,49 @@ 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: ask the user to choose branch, worktree, or current'); 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'); + expect(transition.stderr).toContain('isolation must be one of branch, worktree, current'); + }, 20_000); + + it('allows full workflow build completion with isolation=current', async () => { + await createChange( + tmpDir, + 'current-isolation-build', + [ + 'workflow: full', + 'phase: build', + 'build_mode: executing-plans', + 'build_pause: null', + 'subagent_dispatch: null', + 'tdd_mode: tdd', + 'review_mode: standard', + 'isolation: current', + 'verify_mode: full', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + '- [x] done\n', + ); + await writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ scripts: { build: 'node -e \"process.exit(0)\"' } }), + ); + + const guard = runNode(tmpDir, guardScript, ['current-isolation-build', 'build']); + const transition = runNode(tmpDir, stateScript, [ + 'transition', + 'current-isolation-build', + 'build-complete', + ]); + + expect(guard.status).toBe(0); + expect(transition.status).toBe(0); }, 20_000); it('blocks build completion until tdd_mode is selected for full workflow', async () => { From eb164afabc5f25a67831a0f0f08d72e63f4fc86f Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 19:47:00 +0800 Subject: [PATCH 02/25] docs(comet): document current isolation flow --- assets/skills-zh/comet-archive/SKILL.md | 2 ++ assets/skills-zh/comet-build/SKILL.md | 18 ++++++++++++++---- assets/skills-zh/comet-hotfix/SKILL.md | 17 +++++++++++++++-- assets/skills-zh/comet-tweak/SKILL.md | 18 ++++++++++++++++-- assets/skills-zh/comet-verify/SKILL.md | 15 +++++++++++---- assets/skills-zh/comet/SKILL.md | 2 +- .../comet/reference/comet-yaml-fields.md | 4 ++-- assets/skills-zh/comet/reference/scripts.md | 2 +- assets/skills/comet-archive/SKILL.md | 2 ++ assets/skills/comet-build/SKILL.md | 18 ++++++++++++++---- assets/skills/comet-hotfix/SKILL.md | 17 +++++++++++++++-- assets/skills/comet-tweak/SKILL.md | 18 ++++++++++++++++-- assets/skills/comet-verify/SKILL.md | 15 +++++++++++---- assets/skills/comet/SKILL.md | 2 +- .../comet/reference/comet-yaml-fields.md | 4 ++-- assets/skills/comet/reference/scripts.md | 2 +- assets/skills/comet/rules/comet-phase-guard.md | 4 ++-- 17 files changed, 126 insertions(+), 34 deletions(-) diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 3c28df29..d37a7fe8 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -104,6 +104,8 @@ git commit -m "chore: archive " 如分支处理(阶段 4)选择尚未合并到主分支,提交后按所选方式(合并 / PR / 保持分支)一并收尾。 +如果本次 change 使用 `isolation: current`,这些 archive 改动会直接追加在当前分支上;若验证阶段已经按用户选择 push 过当前分支,需要明确告知用户 archive commit 仍需单独提交,并且通常还要再 push 一次。不要假设一定存在独立开发分支、合并动作或 PR 收尾。 + ## 退出条件 - 归档脚本执行成功(退出码 0) diff --git a/assets/skills-zh/comet-build/SKILL.md b/assets/skills-zh/comet-build/SKILL.md index 95fe1f3d..2a3f6929 100644 --- a/assets/skills-zh/comet-build/SKILL.md +++ b/assets/skills-zh/comet-build/SKILL.md @@ -116,10 +116,12 @@ comet state set build_pause null |------|------|------| | A | 创建分支 | 在当前仓库创建新分支,简单快速 | | B | 创建 Worktree | 隔离工作区,完全独立,适合并行开发 | +| C | 使用当前分支 | 不创建新分支或 worktree,直接在当前分支继续 | **推荐规则**: - 变更涉及 ≤ 3 个文件 → 推荐 A - 需要并行开发、当前分支有未提交工作 → 推荐 B +- 当前分支本身就是长期集成分支、子模块工作分支,或用户明确要求不新建分支/worktree → 推荐 C **执行方式**: @@ -133,12 +135,12 @@ comet state set build_pause null - 任务数 ≤ 2 且无跨模块依赖 → 推荐 B - 来自 hotfix 路径 → 推荐 B -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择隔离方式、执行方式、TDD 模式和代码审查模式**,不得根据推荐规则自行选择 `branch` 或 `worktree`,也不得根据推荐规则自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 +这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择隔离方式、执行方式、TDD 模式和代码审查模式**,不得根据推荐规则自行选择 `branch`、`worktree` 或 `current`,也不得根据推荐规则自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 用户选择后,更新 `isolation`、执行方式、TDD 模式和代码审查模式相关字段: ```bash -comet state set isolation +comet state set isolation ``` - 若用户选择 `executing-plans`:运行 `comet state set subagent_dispatch null`,再运行 `comet state set build_mode executing-plans` @@ -198,6 +200,14 @@ comet state set build_mode direct - **worktree**:必须使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能创建隔离工作区。禁止用普通 shell 命令或原生工具绕过该技能;如该技能不可用,停止流程并提示安装或启用 Superpowers 技能。 +- **current**:不创建新分支,也不创建 worktree。立即运行: + +```bash +comet state set isolation current +``` + +随后继续在当前分支执行,不要因为没有 branch/worktree 切换而额外重新选择 current change。只有当后续实际发生 branch 切换时,才按 stale selection 处理并重新绑定。 + 创建隔离后,确认计划文件可访问(分支方式天然可访问;worktree 方式需确认计划已提交)。若 worktree 模式下计划文件尚未提交,先提交计划文件再创建 worktree: ```bash @@ -211,7 +221,7 @@ git commit -m "chore: add implementation plan" comet state select ``` -重新绑定成功后才能开始源码写入。 +`current` 模式不发生工作区切换时,不需要为了这一步重复绑定;保留当前工作区的既有选择即可。重新绑定成功后才能开始源码写入。 **执行计划**:必须按 `build_mode` 的真实运行位置处理。 @@ -286,7 +296,7 @@ Build 是最长阶段,可能跨越大量任务。为支持上下文压缩后 - tasks.md 全部勾选 - 代码已提交 - 已显式运行项目对应的构建/测试命令并通过(不要只依赖 guard 自动猜测) -- `isolation` 已写为 `branch` 或 `worktree` +- `isolation` 已写为 `branch`、`worktree` 或 `current` - `build_mode` 已写为 `subagent-driven-development`、`executing-plans` 或带显式 override 的 `direct`;若为 `subagent-driven-development`,`subagent_dispatch` 必须为 `confirmed` - `tdd_mode` 已写为 `tdd` 或 `direct` - `review_mode` 已写为 `off`、`standard` 或 `thorough` diff --git a/assets/skills-zh/comet-hotfix/SKILL.md b/assets/skills-zh/comet-hotfix/SKILL.md index c9f17744..a9a791a7 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -69,7 +69,20 @@ node "$COMET_STATE" next ### 2. 直接构建(预设 build) -使用 hotfix 默认值:`build_mode: direct`,`review_mode: off`(hotfix/tweak 跳过 review_mode 选择——guard 不要求预设工作流选择此项)。跳过 Superpowers `brainstorming` 和 `writing-plans`(除非任务 > 3 个;若超过 3 个任务,转入 `/comet-build` 的计划与执行方式选择——注意这不触发 full workflow 升级,仅切换执行方式)。 +开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: + +- A. 创建分支(推荐默认) +- B. 使用当前分支 + +用户选择后立即写入: + +```bash +comet state set isolation +``` + +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change;若选择 `current`,不创建新分支,也不要因为没有切换工作区而额外重新选择。 + +使用 hotfix 默认值:`build_mode: direct`,`review_mode: off`(hotfix/tweak 跳过 review_mode 选择——guard 不要求预设工作流选择此项)。跳过 Superpowers `brainstorming` 和 `writing-plans`(除非任务 > 3 个;若超过 3 个任务,转入 `/comet-build` 的计划与执行方式选择——注意这不触发 full workflow 升级,仅切换执行方式;预设路径下可选隔离仍只允许 `branch` 或 `current`)。 继续或开始修改前,按 `comet/reference/dirty-worktree.md` 协议处理未提交改动。若归因后发现修复命中质变信号或文件数 tripwire,按本文件「升级判定」处理。 @@ -137,7 +150,7 @@ node "$COMET_GUARD" build --apply Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,agent 在 hotfix 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)停下,由用户手动运行下一阶段命令——此时连续执行降级为逐阶段手动推进,详见下方「自动衔接下一阶段」。但无论 `auto_transition` 取何值,以下情况都必须暂停等待用户确认: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 hotfix 流程,还是升级为完整 `/comet` 流程 -2. 任务超过 3 个转入 `/comet-build` 时的工作区隔离和执行方式选择 +2. 初始隔离选择(`branch` / `current`),以及任务超过 3 个转入 `/comet-build` 时的执行方式选择 3. 验证阶段(comet-verify)的验证失败决策和分支处理决策 4. 归档前最终确认(comet-archive 执行归档脚本前) diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 06101e14..478bac61 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -63,6 +63,19 @@ node "$COMET_GUARD" open --apply ### 2. OpenSpec apply 构建(tweak 专用预设 build) +开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: + +- A. 创建分支(推荐默认) +- B. 使用当前分支 + +用户选择后立即写入: + +```bash +comet state set isolation +``` + +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change;若选择 `current`,不创建新分支,也不要因为没有切换工作区而额外重新选择。 + 使用 tweak 默认值:`build_mode: direct`。跳过 Superpowers `brainstorming` 和 `writing-plans`,改由 OpenSpec 的 apply action 执行当前 change 的 tasks。 @@ -131,8 +144,9 @@ node "$COMET_STATE" set verify_mode full Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent 在 tweak 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)停下,由用户手动运行下一阶段命令——此时连续执行降级为逐阶段手动推进,详见下方「自动衔接下一阶段」。但无论 `auto_transition` 取何值,以下情况都必须暂停等待用户确认: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 tweak 轻量流程,还是升级为完整 `/comet` 流程 -2. 验证阶段(comet-verify)的验证失败决策和分支处理决策 -3. 归档前最终确认(comet-archive 执行归档脚本前) +2. 初始隔离选择(`branch` / `current`) +3. 验证阶段(comet-verify)的验证失败决策和分支处理决策 +4. 归档前最终确认(comet-archive 执行归档脚本前) 执行顺序:快速开启 → 构建(含升级判定检查)→ 验证 → 归档 → 完成 diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 414ca9aa..75376da9 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -175,17 +175,24 @@ comet state transition verify-fail ### 3. 收尾(Superpowers) -**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。禁止跳过此步骤。 +先读取 `isolation` 字段: -如 Superpowers `finishing-a-development-branch` 技能不可用,停止流程并提示安装或启用 Superpowers 技能,不要用普通对话替代该步骤。 +- 若 `isolation: branch` 或 `worktree`:**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。禁止跳过此步骤。 +- 若 `isolation: current`:**不得**加载 `finishing-a-development-branch`。当前模式下没有独立开发分支要收尾,直接进入当前分支决策。 -技能加载后,按其指引收尾。分支处理选项: +如 `isolation: branch` 或 `worktree` 且 Superpowers `finishing-a-development-branch` 技能不可用,停止流程并提示安装或启用 Superpowers 技能,不要用普通对话替代该步骤。 + +`branch/worktree` 模式下,技能加载后按其指引收尾。分支处理选项: 1. 本地合并到主分支 2. 推送并创建 PR 3. 保持分支(稍后处理) 4. 丢弃工作 -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式**,不得根据推荐、默认值或当前分支状态自行选择。只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 +`current` 模式下,这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择当前分支处理方式**: +1. 立即 push 当前分支 +2. 暂不 push,保留本地分支状态 + +无论哪种隔离模式,只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 **确认项**: - 全部测试通过 diff --git a/assets/skills-zh/comet/SKILL.md b/assets/skills-zh/comet/SKILL.md index 5a463243..2a5d0fa0 100644 --- a/assets/skills-zh/comet/SKILL.md +++ b/assets/skills-zh/comet/SKILL.md @@ -254,7 +254,7 @@ agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须 ### 状态机硬约束 -- `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree` +- `build → verify` 前,`isolation` 必须是 `branch`、`worktree` 或 `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/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index 5fee7953..bbdcceaf 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -45,7 +45,7 @@ archived: false | `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` | | `review_mode` | `off`、`standard` 或 `thorough`。full workflow 离开 build 阶段前必须已选择;hotfix/tweak 默认 `off` | -| `isolation` | `branch` 或 `worktree`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 默认 `branch` | +| `isolation` | `branch`、`worktree` 或 `current`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 必须显式选择 `branch` 或 `current`,不再默认写入 | | `verify_mode` | `light` 或 `full`,可为空 | | `auto_transition` | `true` 或 `false`。只控制阶段守卫推进 phase 后是否自动调用下一个 skill;`false` 时由 `comet-state next` 输出 `manual`,暂停下一 skill 调用,但不阻止 phase 字段更新 | | `verify_result` | `pending`、`pass` 或 `fail` | @@ -64,7 +64,7 @@ archived: false ## 状态机硬约束 -- `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree` +- `build → verify` 前,`isolation` 必须是 `branch`、`worktree` 或 `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/scripts.md b/assets/skills-zh/comet/reference/scripts.md index bc0f9043..d779f9f9 100644 --- a/assets/skills-zh/comet/reference/scripts.md +++ b/assets/skills-zh/comet/reference/scripts.md @@ -18,7 +18,7 @@ comet handoff comet archive ``` -当多个 active change 共存时,进入明确的 change 后先运行 `comet state select `。普通源码写入只受该选择管辖;尚未选择时 hook 会阻塞并要求选择。单 active change 可继续自动归属。切换 branch/worktree 或选择失效后必须重新运行 `select`。 +当多个 active change 共存时,进入明确的 change 后先运行 `comet state select `。普通源码写入只受该选择管辖;尚未选择时 hook 会阻塞并要求选择。单 active change 可继续自动归属。切换 branch/worktree 或选择失效后必须重新运行 `select`。`isolation: current` 模式下如果后续真的发生 branch 切换,这属于异常偏离,原选择同样视为 stale,必须先确认目标 change 再重新选择。 guard 的 `--apply` 在检查通过后推进状态。需要直接表达状态事件时使用 `comet state transition`;阶段推进后使用 `comet state next` 解析是否自动调用下一 Skill。 diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 3da26d89..efa9c521 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -104,6 +104,8 @@ 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. +If this change used `isolation: current`, these archive edits are committed directly on the current branch. If verification already pushed that branch by user choice, explicitly tell the user the archive commit still needs its own commit and will usually require another push. Do not assume there is always a separate development branch, merge action, or PR cleanup step. + ## Exit Conditions - Archive script executed successfully (exit code 0) diff --git a/assets/skills/comet-build/SKILL.md b/assets/skills/comet-build/SKILL.md index ce28368e..af192154 100644 --- a/assets/skills/comet-build/SKILL.md +++ b/assets/skills/comet-build/SKILL.md @@ -116,10 +116,12 @@ Plan has been written to the current branch. Before starting execution, **ask th |--------|--------|-------------| | A | Create branch | Create a new branch in the current repo, simple and fast | | B | Create Worktree | Isolated workspace, fully independent, suitable for parallel development | +| C | Use current branch | Create neither a new branch nor a worktree; continue on the current branch | **Recommendation rules**: - Change involves ≤ 3 files → Recommend A - Need parallel development, current branch has uncommitted work → Recommend B +- The current branch is already the long-lived integration branch, a submodule work branch, or the user explicitly does not want a new branch/worktree → Recommend C **Execution Method**: @@ -133,12 +135,12 @@ 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. +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`, `worktree`, or `current` 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. After user selection, update `isolation`, execution method, TDD mode, and code review mode fields: ```bash -comet state set isolation +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` @@ -198,6 +200,14 @@ Without `direct_override: true`, `build_mode=direct` in full workflow is blocked - **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. +- **current**: Create neither a new branch nor a worktree. Immediately run: + +```bash +comet state set isolation current +``` + +Then continue execution on the current branch. Do not force a re-selection simply because no branch/worktree switch occurred. Only if a later real branch switch happens should the selection be treated as stale and rebound. + After creating isolation, confirm plan file is accessible (naturally accessible with branch method; for worktree method, confirm plan has been committed). If the plan file has not been committed under worktree mode, commit it first before creating the worktree: ```bash @@ -211,7 +221,7 @@ After entering the final execution branch or worktree, bind the current change a comet state select ``` -Do not begin source writes until this binding succeeds. +In `current` mode with no workspace switch, do not repeat this step just for formality; keep the current workspace selection. Do not begin source writes until the required binding is valid. **Execute plan**: Must handle execution according to the actual runtime of `build_mode`. @@ -286,7 +296,7 @@ Build is the longest phase and may span many tasks. To support resume after cont - All tasks.md checked - Code committed - Project-specific build/tests explicitly run and pass; do not rely only on guard auto-detection -- `isolation` has been written as `branch` or `worktree` +- `isolation` has been written as `branch`, `worktree`, or `current` - `build_mode` has been written as `subagent-driven-development`, `executing-plans`, or `direct` with explicit override; if `subagent-driven-development`, `subagent_dispatch` must be `confirmed` - `tdd_mode` has been written as `tdd` or `direct` - `review_mode` has been written as `off`, `standard`, or `thorough` diff --git a/assets/skills/comet-hotfix/SKILL.md b/assets/skills/comet-hotfix/SKILL.md index 35e5244a..cae107c4 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -69,7 +69,20 @@ node "$COMET_STATE" next ### 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). +Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: + +- A. create a branch (recommended default) +- B. use the current branch + +After the choice, immediately write: + +```bash +comet state set isolation +``` + +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change; if `current` is chosen, create no new branch and do not force an extra re-selection when no workspace switch happened. + +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; preset flows still allow only `branch` or `current` isolation there). 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". @@ -141,7 +154,7 @@ Exception: when `.comet.yaml` has `auto_transition: false`, after each phase gua The following situations must also pause and wait for user confirmation: 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` +2. initial isolation choice (`branch` / `current`), and execution-method selection if 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) diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 9e905acd..3587cb34 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -63,6 +63,19 @@ node "$COMET_GUARD" open --apply ### 2. OpenSpec Apply Build (tweak-only preset build) +Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: + +- A. create a branch (recommended default) +- B. use the current branch + +After the choice, immediately write: + +```bash +comet state set isolation +``` + +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change; if `current` is chosen, create no new branch and do not force an extra re-selection when no workspace switch happened. + Use tweak defaults: `build_mode: direct`. Skip Superpowers `brainstorming` and `writing-plans`, and let OpenSpec's apply action execute the current change's tasks. @@ -134,8 +147,9 @@ Exception: when `.comet.yaml` has `auto_transition: false`, after each phase gua The following situations must pause and wait for user confirmation: 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. initial isolation choice (`branch` / `current`) +3. verify phase (comet-verify) verification-failure and branch-handling decisions +4. Final archive confirmation (before comet-archive runs the archive script) Execution order: quick open → build (with upgrade assessment) → verification → archive → complete diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index a80c9050..9a433b27 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -175,17 +175,24 @@ comet state transition verify-fail ### 3. Finishing (Superpowers) -**Immediately execute:** Use the Skill tool to load the Superpowers `finishing-a-development-branch` skill. Skipping this step is prohibited. +Read the `isolation` field first: -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. +- If `isolation: branch` or `worktree`: **Immediately execute:** Use the Skill tool to load the Superpowers `finishing-a-development-branch` skill. Skipping this step is prohibited. +- If `isolation: current`: **Do not** load `finishing-a-development-branch`. There is no separate development branch to finish, so go directly to the current-branch decision. -After the skill loads, follow its guidance to finish. Branch handling options: +If `isolation: branch` or `worktree` and 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. + +In `branch/worktree` mode, 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 -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. +In `current` mode, this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to choose current-branch handling**: +1. Push the current branch now +2. Leave the current branch local for now + +Under either isolation path, `branch_status: handled` may be written only after the user completes the choice and the corresponding action finishes. **Confirmation items**: - All tests pass diff --git a/assets/skills/comet/SKILL.md b/assets/skills/comet/SKILL.md index 5e12120c..5d92561c 100644 --- a/assets/skills/comet/SKILL.md +++ b/assets/skills/comet/SKILL.md @@ -246,7 +246,7 @@ 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 `build → verify`, `isolation` must be `branch`, `worktree`, or `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` diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index 556e3b33..30ae5b0b 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -46,7 +46,7 @@ archived: false | `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` | | `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` | `branch`, `worktree`, or `current`, workspace isolation mode. Full init may be `null` but only until `/comet-build` Step 3; hotfix/tweak must explicitly choose `branch` or `current` and no longer default it | | `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` | @@ -65,7 +65,7 @@ archived: false ## State Machine Hard Constraints -- Before `build → verify`, `isolation` must be `branch` or `worktree` +- Before `build → verify`, `isolation` must be `branch`, `worktree`, or `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/scripts.md b/assets/skills/comet/reference/scripts.md index 093c9c5b..3c3fc745 100644 --- a/assets/skills/comet/reference/scripts.md +++ b/assets/skills/comet/reference/scripts.md @@ -18,7 +18,7 @@ comet handoff comet archive ``` -When multiple active changes coexist, run `comet state select ` after resolving the intended change. Ordinary source writes are governed only by that selection; without one, the hook blocks and asks for a choice. A single active change retains automatic routing. Select again after switching branch/worktree or when the recorded selection becomes stale. +When multiple active changes coexist, run `comet state select ` after resolving the intended change. Ordinary source writes are governed only by that selection; without one, the hook blocks and asks for a choice. A single active change retains automatic routing. Select again after switching branch/worktree or when the recorded selection becomes stale. In `isolation: current` mode, if a real branch switch later happens, treat that as abnormal drift: confirm the intended change again and re-select before continuing. Guard `--apply` advances state after checks pass. Use `comet state transition` when expressing a state event directly, and `comet state next` after phase advancement to determine whether to invoke the next Skill automatically. diff --git a/assets/skills/comet/rules/comet-phase-guard.md b/assets/skills/comet/rules/comet-phase-guard.md index 5c401c99..2239e72a 100644 --- a/assets/skills/comet/rules/comet-phase-guard.md +++ b/assets/skills/comet/rules/comet-phase-guard.md @@ -25,7 +25,7 @@ comet state select | `open` | 创建 proposal/design/tasks, 运行 guard | 写源代码 | | `design` | brainstorming, 创建 Design Doc, 运行 guard | 写源代码 | | `build` | 写源代码、测试、执行计划 | 跳过用户确认点 | -| `verify` | 验证、branch handling | 跳过失败处理 | +| `verify` | 验证、branch handling / current-branch handling | 跳过失败处理 | | `archive` | 确认归档、运行归档脚本 | 写源代码 | Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpowers/*`、`.claude/*` 和 `.comet/*` 等流程/平台工作区;这些路径可写不代表可以跳过当前阶段的产物和确认要求。 @@ -73,7 +73,7 @@ Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpower - **open**: 需求澄清完成确认、artifact 评审确认 - **design**: brainstorming 方案确认(确认前不得创建 Design Doc) - **build**: plan-ready 暂停、`isolation` / `build_mode` / `tdd_mode` / `review_mode` 四项选择(工作区隔离、执行方式、TDD 模式、代码审查模式)、spec 大规模变更确认、预设(hotfix/tweak)升级判定二选一(命中质变信号或文件数 tripwire 时,交用户决定继续预设流程还是升级 full) -- **verify**: 验证失败处理策略、branch handling 选择 +- **verify**: 验证失败处理策略、branch handling / current-branch handling 选择 - **archive**: 归档前最终确认 ## Design 阶段专项 From bc55eabab6df35bf5ba333c351be0b615b787ad7 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 19:48:01 +0800 Subject: [PATCH 03/25] chore: bump version to 0.4.0-beta.5 --- CHANGELOG.md | 10 ++++++++++ assets/manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- test/app/cli-help.test.ts | 6 +++--- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed265e7..a45503d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.5] - 2026-07-13 + +### Added + +- **Current-branch isolation**: Classic workflows now support `isolation: current`, letting users continue on the current branch without creating a new branch or worktree while still enforcing explicit isolation selection before build. + +### Changed + +- **Preset isolation decisions**: `/comet-hotfix` and `/comet-tweak` now require an explicit `branch` or `current` isolation choice instead of silently defaulting to a new branch, and `/comet-verify` / `/comet-archive` now document the current-branch handoff and archive commit path consistently in both Chinese and English Skills. + ## What's Changed [0.4.0-beta.4] - 2026-07-11 ### Added 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/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/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index 36b6c821..3db804db 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -32,9 +32,9 @@ describe('CLI help text', () => { 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'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { From 735a8680ad92f554b419f1446cf01871098f31f4 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 19:48:41 +0800 Subject: [PATCH 04/25] chore(comet-classic): sync runtime bundle --- assets/skills/comet/scripts/comet-runtime.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index f42dfc93..b2931a6f 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7741,9 +7741,9 @@ var ISOLATION_MODES = [ { value: "current", allowedInPreset: true } ]; var ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value); -var PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter( - (mode) => mode.allowedInPreset -).map((mode) => mode.value); +var PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.allowedInPreset).map( + (mode) => mode.value +); var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; From 6dbfc1f42f9ef2ffdf9fc1becd8180bfd93c6ce7 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 20:42:56 +0800 Subject: [PATCH 05/25] fix(comet-classic): repair current-branch isolation regressions - resolveBuild() no longer throws when a preset (hotfix/tweak) enters build phase with isolation unset; it now routes to `.build.execute` the same way `full` routes to `full.build.configure`. Previously any hotfix/tweak guard call crashed the CLI right after init, since presets stopped defaulting isolation to 'branch'. - Enforce PRESET_ALLOWED_ISOLATIONS (branch/current only) at the guard build check and at `state transition build-complete`, so hotfix/tweak can no longer silently pick up isolation: worktree. - resolveCurrentChange()/currentChange() reuse the already-resolved Classic state and branch instead of re-reading the same .comet.yaml and re-spawning git; also fixes a detached-HEAD display bug where the fallback branch lookup printed the literal 'HEAD'. - Restore the branch/worktree decision-point trigger sentence in comet-verify SKILL.md (en/zh) that was dropped when the current-mode variant was added. - Update stale context-recovery text and test fixtures/assertions (classic-contract, classic-guard, skills.test.ts) to match the intentional removal of the preset isolation default. Co-Authored-By: Claude Sonnet 5 --- assets/skills-zh/comet-verify/SKILL.md | 4 ++- assets/skills/comet-verify/SKILL.md | 4 ++- assets/skills/comet/scripts/comet-runtime.mjs | 33 ++++++++++++++----- .../comet-classic/classic-current-change.ts | 19 ++++++++--- domains/comet-classic/classic-guard.ts | 21 +++++++++++- domains/comet-classic/classic-resolver.ts | 7 +++- .../comet-classic/classic-state-command.ts | 17 +++++++--- .../comet-classic/classic-contract.test.ts | 4 +++ .../comet-classic/classic-guard.test.ts | 4 ++- test/domains/skill/skills.test.ts | 10 +++--- 10 files changed, 97 insertions(+), 26 deletions(-) diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 75376da9..19a04b8e 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -188,11 +188,13 @@ comet state transition verify-fail 3. 保持分支(稍后处理) 4. 丢弃工作 +这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式**,不得根据推荐、默认值或当前分支状态自行选择。只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 + `current` 模式下,这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择当前分支处理方式**: 1. 立即 push 当前分支 2. 暂不 push,保留本地分支状态 -无论哪种隔离模式,只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 +同样地,只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 **确认项**: - 全部测试通过 diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index 9a433b27..99ee5dd3 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -188,11 +188,13 @@ In `branch/worktree` mode, after the skill loads, follow its guidance to finish. 3. Keep branch (handle later) 4. Discard work +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. + In `current` mode, this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to choose current-branch handling**: 1. Push the current branch now 2. Leave the current branch local for now -Under either isolation path, `branch_status: handled` may be written only after the user completes the choice and the corresponding action finishes. +The same requirement applies here: only after the user completes the choice and the corresponding action finishes may `branch_status: handled` be written. **Confirmation items**: - All tests pass diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index b2931a6f..7e721b88 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7980,7 +7980,7 @@ function resolveBuild(profile, classic, evidence) { if (classic.buildPause === "plan-ready") return "full.build.plan-ready"; if (!fullBuildConfigured(classic)) return "full.build.configure"; } else if (!presetBuildConfigured(classic)) { - throw new Error(`${profile} build configuration is incomplete`); + return `${profile}.build.execute`; } return evidenceSatisfied(evidence, "build.tasks-complete") ? `${profile}.build.complete` : `${profile}.build.execute`; } @@ -10657,6 +10657,17 @@ Next: ask the user to choose branch, worktree, or current, create the chosen iso node "$COMET_STATE" set ${change} isolation <${ISOLATIONS.join("|")}>` ); } +async function isolationAllowedForWorkflow(changeDir, change) { + const workflow = await readField(changeDir, "workflow"); + const isolation = await readField(changeDir, "isolation"); + if (workflow !== "hotfix" && workflow !== "tweak") return pass(); + if (PRESET_ALLOWED_ISOLATIONS.includes(isolation)) return pass(); + return fail( + `isolation=${isolation || "null"} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(" or ")}) +Next: ask the user to choose ${PRESET_ALLOWED_ISOLATIONS.join(" or ")}, then run: + node "$COMET_STATE" set ${change} isolation <${PRESET_ALLOWED_ISOLATIONS.join("|")}>` + ); +} async function buildModeSelected(changeDir, change) { const buildMode = await readField(changeDir, "build_mode"); if (["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) @@ -10968,6 +10979,7 @@ async function guardDesignChecks(output, changeDir, change) { async function guardBuildChecks(output, changeDir, change, run) { return runChecks(output, [ check("isolation selected", () => isolationSelected(changeDir, change)), + check("isolation allowed for workflow", () => isolationAllowedForWorkflow(changeDir, change)), check("build_mode selected", () => buildModeSelected(changeDir, change)), check("build_mode allowed for workflow", () => buildModeAllowedForWorkflow(changeDir)), check("subagent dispatch confirmed", () => subagentDispatchConfirmed(changeDir, change)), @@ -11636,6 +11648,7 @@ async function validateActiveChange(projectRoot2, changeName) { if (projection.classic.archived) { throw new Error(`Cannot select current change '${changeName}': change is archived`); } + return projection.classic; } function parseSelection(source) { let value; @@ -11698,9 +11711,10 @@ async function resolveCurrentChange(projectRoot2) { }; } let selection; + let classic; try { selection = parseSelection(source); - await validateActiveChange(projectRoot2, selection.change); + classic = await validateActiveChange(projectRoot2, selection.change); } catch (error) { return { status: "stale", @@ -11714,7 +11728,7 @@ async function resolveCurrentChange(projectRoot2) { reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${branch ?? "detached HEAD"}'` }; } - return { status: "selected", selection }; + return { status: "selected", selection, classic, branch }; } async function clearCurrentChange(projectRoot2) { await fs17.rm(currentChangeFile(projectRoot2), { force: true }); @@ -13357,6 +13371,11 @@ async function requireBuildDecisions(name) { `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(", ")}, got '${isolation || "null"}'` ); } + if (["hotfix", "tweak"].includes(workflow) && !PRESET_ALLOWED_ISOLATIONS.includes(isolation)) { + fail2( + `ERROR: Cannot transition '${name}': isolation=${isolation} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(" or ")})` + ); + } if (!["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) { fail2( `ERROR: Cannot transition '${name}': build_mode must be selected before leaving build, got '${buildMode || "null"}'` @@ -13745,7 +13764,7 @@ function resolveBuildRecoveryAction(workflow, isolation, buildMode, pause, subag return "Recovery action: Plan-ready pause is stale and all tasks are done. Clear build_pause to null, then run guard to transition to verify."; } if (isMissingStateValue(isolation)) { - return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree choice."; + return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree/current choice."; } if (isMissingStateValue(buildMode)) { return "Recovery action: Build mode not selected. Use the current platform's user confirmation mechanism to ask user for execution method."; @@ -13933,10 +13952,8 @@ async function currentChange(output) { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === "selected") { output.stdout.push(resolution.selection.change); - const { directory } = await resolveClassicChangeDirectory(resolution.selection.change); - const projection = await readClassicState(directory, { migrate: false }); - const isolation = projection.classic?.isolation ?? null; - const branch = resolution.selection.branch ?? gitOutput(["rev-parse", "--abbrev-ref", "HEAD"]); + const isolation = resolution.classic.isolation ?? null; + const branch = resolution.selection.branch ?? resolution.branch; output.stderr.push( `[CURRENT] isolation: ${isolation ?? "null"}${branch ? `, branch: ${branch}` : ""}` ); diff --git a/domains/comet-classic/classic-current-change.ts b/domains/comet-classic/classic-current-change.ts index ce781c05..7df93ab6 100644 --- a/domains/comet-classic/classic-current-change.ts +++ b/domains/comet-classic/classic-current-change.ts @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { assertOpenSpecChangeName } from './classic-paths.js'; import { readClassicState } from './classic-store.js'; +import type { ClassicState } from './classic-state.js'; export interface CurrentChangeSelection { version: 1; @@ -12,7 +13,12 @@ export interface CurrentChangeSelection { } export type CurrentChangeResolution = - | { status: 'selected'; selection: CurrentChangeSelection } + | { + status: 'selected'; + selection: CurrentChangeSelection; + classic: ClassicState; + branch: string | null; + } | { status: 'missing' } | { status: 'stale'; reason: string }; @@ -37,7 +43,10 @@ function changeDirectory(projectRoot: string, changeName: string): string { return path.join(projectRoot, 'openspec', 'changes', changeName); } -async function validateActiveChange(projectRoot: string, changeName: string): Promise { +async function validateActiveChange( + projectRoot: string, + changeName: string, +): Promise { assertOpenSpecChangeName(changeName); const changeDir = changeDirectory(projectRoot, changeName); try { @@ -61,6 +70,7 @@ async function validateActiveChange(projectRoot: string, changeName: string): Pr if (projection.classic.archived) { throw new Error(`Cannot select current change '${changeName}': change is archived`); } + return projection.classic; } function parseSelection(source: string): CurrentChangeSelection { @@ -130,9 +140,10 @@ export async function resolveCurrentChange(projectRoot: string): Promise { diff --git a/domains/comet-classic/classic-guard.ts b/domains/comet-classic/classic-guard.ts index 4354e38e..1402b478 100644 --- a/domains/comet-classic/classic-guard.ts +++ b/domains/comet-classic/classic-guard.ts @@ -13,7 +13,12 @@ import { inspectClassicChange } from './classic-diagnostics.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; import { ensureClassicRuntimeRun, transitionClassicRuntimeRun } from './classic-runtime-run.js'; import type { ClassicRunContext } from './classic-migrate.js'; -import { ISOLATIONS, type ClassicPhase, type ClassicState } from './classic-state.js'; +import { + ISOLATIONS, + PRESET_ALLOWED_ISOLATIONS, + type ClassicPhase, + type ClassicState, +} from './classic-state.js'; import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_GUARD_TRANSITION_EVENT, applyClassicTransition } from './classic-transitions.js'; import { classicValidateCommand } from './classic-validate-command.js'; @@ -493,6 +498,19 @@ async function isolationSelected(changeDir: string, change: string): Promise { + const workflow = await readField(changeDir, 'workflow'); + const isolation = await readField(changeDir, 'isolation'); + if (workflow !== 'hotfix' && workflow !== 'tweak') return pass(); + if ((PRESET_ALLOWED_ISOLATIONS as readonly string[]).includes(isolation)) return pass(); + return fail( + `isolation=${isolation || 'null'} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(' or ')})\nNext: ask the user to choose ${PRESET_ALLOWED_ISOLATIONS.join(' or ')}, then run:\n node "$COMET_STATE" set ${change} isolation <${PRESET_ALLOWED_ISOLATIONS.join('|')}>`, + ); +} + async function buildModeSelected(changeDir: string, change: string): Promise { const buildMode = await readField(changeDir, 'build_mode'); if (['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) @@ -809,6 +827,7 @@ async function guardBuildChecks( ): Promise { return runChecks(output, [ check('isolation selected', () => isolationSelected(changeDir, change)), + check('isolation allowed for workflow', () => isolationAllowedForWorkflow(changeDir, change)), check('build_mode selected', () => buildModeSelected(changeDir, change)), check('build_mode allowed for workflow', () => buildModeAllowedForWorkflow(changeDir)), check('subagent dispatch confirmed', () => subagentDispatchConfirmed(changeDir, change)), diff --git a/domains/comet-classic/classic-resolver.ts b/domains/comet-classic/classic-resolver.ts index d6a8d810..790c888f 100644 --- a/domains/comet-classic/classic-resolver.ts +++ b/domains/comet-classic/classic-resolver.ts @@ -51,7 +51,12 @@ function resolveBuild( if (classic.buildPause === 'plan-ready') return 'full.build.plan-ready'; if (!fullBuildConfigured(classic)) return 'full.build.configure'; } else if (!presetBuildConfigured(classic)) { - throw new Error(`${profile} build configuration is incomplete`); + // Presets no longer default isolation at init (the user must choose branch/current + // explicitly), so build configuration can be incomplete the instant phase becomes + // 'build' — same situation `full` handles via `full.build.configure`. Presets have + // no dedicated configure step, so route to `.build.execute` (guard's build-phase + // checks already block leaving build until configuration is complete). + return `${profile}.build.execute`; } return evidenceSatisfied(evidence, 'build.tasks-complete') diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index b91b77a2..004ef27f 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -17,6 +17,7 @@ import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_WIRE_KEYS, ISOLATIONS, + PRESET_ALLOWED_ISOLATIONS, RUN_WIRE_KEYS, parseClassicStateDocument, type ClassicState, @@ -530,6 +531,14 @@ async function requireBuildDecisions(name: string): Promise { `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(', ')}, got '${isolation || 'null'}'`, ); } + if ( + ['hotfix', 'tweak'].includes(workflow) && + !(PRESET_ALLOWED_ISOLATIONS as readonly string[]).includes(isolation) + ) { + fail( + `ERROR: Cannot transition '${name}': isolation=${isolation} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(' or ')})`, + ); + } if (!['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) { fail( `ERROR: Cannot transition '${name}': build_mode must be selected before leaving build, got '${buildMode || 'null'}'`, @@ -1009,7 +1018,7 @@ function resolveBuildRecoveryAction( return 'Recovery action: Plan-ready pause is stale and all tasks are done. Clear build_pause to null, then run guard to transition to verify.'; } if (isMissingStateValue(isolation)) { - return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree choice."; + return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree/current choice."; } if (isMissingStateValue(buildMode)) { return "Recovery action: Build mode not selected. Use the current platform's user confirmation mechanism to ask user for execution method."; @@ -1229,10 +1238,8 @@ async function currentChange(output: CommandOutput): Promise { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === 'selected') { output.stdout.push(resolution.selection.change); - const { directory } = await resolveClassicChangeDirectory(resolution.selection.change); - const projection = await readClassicState(directory, { migrate: false }); - const isolation = projection.classic?.isolation ?? null; - const branch = resolution.selection.branch ?? gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']); + const isolation = resolution.classic.isolation ?? null; + const branch = resolution.selection.branch ?? resolution.branch; output.stderr.push( `[CURRENT] isolation: ${isolation ?? 'null'}${branch ? `, branch: ${branch}` : ''}`, ); diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index 67efd88a..07339513 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -202,6 +202,10 @@ function legacyProjection(document: Record): Record !runKeys.has(key))); } diff --git a/test/domains/comet-classic/classic-guard.test.ts b/test/domains/comet-classic/classic-guard.test.ts index 1c36e285..75948ac8 100644 --- a/test/domains/comet-classic/classic-guard.test.ts +++ b/test/domains/comet-classic/classic-guard.test.ts @@ -81,7 +81,9 @@ describe('Classic guard command', () => { const runState = await readRunState(changeDir); expect(runState).not.toBeNull(); expect(runState!.skill).toBe('comet-classic'); - expect(runState!.currentStep).toBe('hotfix.build.complete'); + // isolation is no longer defaulted for hotfix/tweak at init, so build + // configuration is still incomplete here even though tasks.md is checked off. + expect(runState!.currentStep).toBe('hotfix.build.execute'); expect(runState!.iteration).toBe(1); const eventLog = await fs.readFile( path.join(changeDir, '.comet', 'state-events.jsonl'), diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index 64fa5240..ed5d3583 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -786,7 +786,7 @@ describe('skills', () => { '不得用“跳过重复上下文探索”削弱 Superpowers `brainstorming` 的澄清流程', ); expect(zhDesign).not.toContain('跳过重复上下文探索,直接进入设计提问'); - expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch` 或 `worktree`'); + expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch`、`worktree` 或 `current`'); expect(zhBuild).toContain('不得根据推荐规则自行选择执行方式'); expect(zhBuild).toContain('`comet/reference/decision-point.md`'); expect(zhVerify).toContain( @@ -892,7 +892,9 @@ describe('skills', () => { expect(zhHotfix).toContain('默认 `review_mode: off`'); // MEDIUM: hotfix IMPORTANT covers >3-tasks comet-build decision points - expect(zhHotfix).toContain('任务超过 3 个转入 `/comet-build` 时的工作区隔离和执行方式选择'); + expect(zhHotfix).toContain( + '初始隔离选择(`branch` / `current`),以及任务超过 3 个转入 `/comet-build` 时的执行方式选择', + ); // LOW: comet-build "中" level requires user confirmation before brainstorming expect(zhBuild).toContain( @@ -1170,7 +1172,7 @@ describe('skills', () => { 'Then continue this step to choose workspace isolation, execution method, TDD mode, and code review mode', ); expect(enBuild).toContain( - 'Must not choose `branch` or `worktree` based on recommendation rules', + 'Must not choose `branch`, `worktree`, or `current` based on recommendation rules', ); expect(enBuild).toContain( 'must not choose the execution method, TDD mode, or code review mode based on recommendation rules', @@ -1296,7 +1298,7 @@ describe('skills', () => { ); expect(enHotfix).toContain('6 quick checks'); expect(enHotfix).toContain( - 'workspace isolation and execution-method selection when tasks exceed 3 and transfer to `/comet-build`', + 'initial isolation choice (`branch` / `current`), and execution-method selection if tasks exceed 3 and transfer 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', From 83c651dbe232ecb2eae5f38c1c1361f10d1dbfdb Mon Sep 17 00:00:00 2001 From: frizz19 Date: Mon, 13 Jul 2026 20:51:40 +0800 Subject: [PATCH 06/25] docs: mention isolation: current in README isolation field docs README.md/README-zh.md's isolation field docs and hard-constraint bullet still only listed branch/worktree after the current-branch isolation feature was added. Co-Authored-By: Claude Sonnet 5 --- README-zh.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README-zh.md b/README-zh.md index a8435ca5..9291e2d4 100644 --- a/README-zh.md +++ b/README-zh.md @@ -494,7 +494,7 @@ build_mode: subagent-driven-development # 构建方式:subage build_pause: null # `build_pause` 记录 build 阶段内部暂停点:null 无暂停,`plan-ready` 表示 plan 已生成 subagent_dispatch: null # subagent 分派确认;进入 verify 前需 confirmed tdd_mode: null # full workflow 的 build 选择:tdd | direct -isolation: branch # 隔离方式:branch | worktree +isolation: branch # 隔离方式:branch | worktree | current verify_mode: null # 验证模式:light | full design_doc: docs/superpowers/specs/.md # 设计文档路径 plan: docs/superpowers/plans/YYYY-MM-DD-feature.md # 实现计划路径 @@ -536,7 +536,7 @@ Comet 通过自动化状态转换确保 agent 执行可靠性: - 检测未知/拼写错误字段 4. **Build 决策强制** — Guard 和状态转换同时拦截跳过关键选择 - - `isolation` 必须是 `branch` 或 `worktree` + - `isolation` 必须是 `branch`、`worktree` 或 `current` - `build_mode` 必须已选择 - `build_pause: plan-ready` 是 plan 生成后的可恢复暂停点,不是 `build_mode` - full workflow 的 `build_mode: direct` 必须有 `direct_override: true` diff --git a/README.md b/README.md index 60fb2359..8abe7fe7 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,7 @@ build_mode: subagent-driven-development # Build mode: subagent- build_pause: null # `build_pause` records an internal build-phase pause point: null none, `plan-ready` means the plan has been generated subagent_dispatch: null # Dispatch confirmation; confirm before verify tdd_mode: null # Full-workflow build choice: tdd | direct -isolation: branch # Isolation mode: branch | worktree +isolation: branch # Isolation mode: branch | worktree | current verify_mode: null # Verification mode: light | full design_doc: docs/superpowers/specs/.md # Design doc path plan: docs/superpowers/plans/YYYY-MM-DD-feature.md # Implementation plan path @@ -576,7 +576,7 @@ Comet ensures agent execution reliability through automated state transitions: - Detects unknown/typos fields 4. **Build Decision Enforcement** — Guard and state transitions both block skipped build choices - - `isolation` must be `branch` or `worktree` + - `isolation` must be `branch`, `worktree`, or `current` - `build_mode` must be selected before leaving build - `build_pause: plan-ready` is a recoverable pause after plan generation, not a `build_mode` - Full workflow `build_mode: direct` requires `direct_override: true` From 9ff7b93cf3c4df3fef2eb5811033c669da1e9a87 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:10:35 +0800 Subject: [PATCH 07/25] test: drop useless escape in comet-scripts build script string --- test/domains/comet-classic/comet-scripts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 99594bfd..5869d5d6 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2072,7 +2072,7 @@ describe('comet scripts', () => { ); await writeFile( path.join(tmpDir, 'package.json'), - JSON.stringify({ scripts: { build: 'node -e \"process.exit(0)\"' } }), + JSON.stringify({ scripts: { build: 'node -e "process.exit(0)"' } }), ); const guard = runNode(tmpDir, guardScript, ['current-isolation-build', 'build']); From c01f48728b97971d555ec4fae37d2bc51332b75a Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:13:14 +0800 Subject: [PATCH 08/25] fix(comet-classic): allow worktree isolation for preset workflows --- CHANGELOG.md | 6 +++++ assets/skills/comet/scripts/comet-runtime.mjs | 2 +- domains/comet-classic/classic-state.ts | 2 +- package.json | 2 +- .../comet-classic/comet-scripts.test.ts | 26 +++++++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45503d9..5b89044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.6] - 2026-07-16 + +### Fixed + +- **Preset isolation**: hotfix and tweak workflows can again select `isolation: worktree`; a prior change had incorrectly restricted preset workflows to `branch` and `current` only. + ## What's Changed [0.4.0-beta.5] - 2026-07-13 ### Added diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 7e721b88..4d15e567 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7737,7 +7737,7 @@ var TDD_MODES = ["tdd", "direct"]; var REVIEW_MODES = ["off", "standard", "thorough"]; var ISOLATION_MODES = [ { value: "branch", allowedInPreset: true }, - { value: "worktree", allowedInPreset: false }, + { value: "worktree", allowedInPreset: true }, { value: "current", allowedInPreset: true } ]; var ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value); diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index 646b310d..0451856d 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -14,7 +14,7 @@ const TDD_MODES = ['tdd', 'direct'] as const; const REVIEW_MODES = ['off', 'standard', 'thorough'] as const; export const ISOLATION_MODES = [ { value: 'branch', allowedInPreset: true }, - { value: 'worktree', allowedInPreset: false }, + { value: 'worktree', allowedInPreset: true }, { value: 'current', allowedInPreset: true }, ] as const; type IsolationMode = (typeof ISOLATION_MODES)[number]['value']; diff --git a/package.json b/package.json index 916eacc5..ea44029f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 5869d5d6..544d92ff 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -3385,6 +3385,32 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }); + it('allows build-complete for hotfix workflow with isolation worktree', async () => { + await createChange( + tmpDir, + 'hotfix-worktree', + [ + 'workflow: hotfix', + 'phase: build', + 'build_mode: direct', + 'build_pause: null', + 'tdd_mode: direct', + 'isolation: worktree', + 'verify_mode: light', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'archived: false', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, ['transition', 'hotfix-worktree', 'build-complete']); + + expect(result.status).toBe(0); + }, 20_000); + it('transitions full workflow from open to design', async () => { await createChange( tmpDir, From bad8660be4c00b2f4c078bf56be4b5f818cfc953 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:17:16 +0800 Subject: [PATCH 09/25] feat(comet-classic): add branch_action state field --- assets/skills/comet/scripts/comet-runtime.mjs | 13 ++++ .../comet-classic/classic-state-command.ts | 8 +++ domains/comet-classic/classic-state.ts | 5 ++ .../comet-classic/classic-validate-command.ts | 1 + .../comet-classic/comet-scripts.test.ts | 71 +++++++++++++++++++ 5 files changed, 98 insertions(+) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 4d15e567..ab37681c 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7747,6 +7747,7 @@ var PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.allowedInP var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; +var BRANCH_ACTIONS = ["push", "keep-local", "merged-locally", "pushed-pr"]; var ARCHIVE_CONFIRMATIONS = ["pending", "confirmed"]; var CLASSIC_WIRE_KEYS = [ "workflow", @@ -7767,6 +7768,7 @@ var CLASSIC_WIRE_KEYS = [ "verify_result", "verification_report", "branch_status", + "branch_action", "created_at", "verified_at", "archive_confirmation", @@ -7876,6 +7878,7 @@ function classicStateFromDocument(doc) { verifyResult: enumValue(doc, "verify_result", VERIFY_RESULTS, false), verificationReport: relativePath(doc, "verification_report"), branchStatus: enumValue(doc, "branch_status", BRANCH_STATUSES), + branchAction: enumValue(doc, "branch_action", BRANCH_ACTIONS), createdAt: nullableString(doc, "created_at"), verifiedAt: nullableString(doc, "verified_at"), archiveConfirmation: enumValue(doc, "archive_confirmation", ARCHIVE_CONFIRMATIONS), @@ -7940,6 +7943,7 @@ function classicStateToDocument(state) { verify_result: state.verifyResult, verification_report: state.verificationReport, branch_status: state.branchStatus, + branch_action: state.branchAction, created_at: state.createdAt, verified_at: state.verifiedAt, archive_confirmation: state.archiveConfirmation, @@ -10106,6 +10110,7 @@ var ENUMS = { auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + branch_action: ["push", "keep-local", "merged-locally", "pushed-pr"], archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], @@ -12968,6 +12973,7 @@ var FIELD_ENUMS = { auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + branch_action: ["push", "keep-local", "merged-locally", "pushed-pr"], archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], @@ -12978,6 +12984,7 @@ var PATH_FIELDS = /* @__PURE__ */ new Set(["design_doc", "plan", "verification_r var CLASSIC_FIELD_WIRE_NAMES2 = { archived: "archived", branchStatus: "branch_status", + branchAction: "branch_action", classicProfile: "classic_profile", designDoc: "design_doc", language: "language", @@ -13161,6 +13168,12 @@ function sparseClassicState(record) { ), verificationReport: nullableRecordString(record, "verification_report"), branchStatus: enumRecordValue(record, "branch_status", ["pending", "handled"], null), + branchAction: enumRecordValue( + record, + "branch_action", + ["push", "keep-local", "merged-locally", "pushed-pr"], + null + ), createdAt: nullableRecordString(record, "created_at"), verifiedAt: nullableRecordString(record, "verified_at"), archiveConfirmation: enumRecordValue( diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 004ef27f..b8c33408 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -65,6 +65,7 @@ const FIELD_ENUMS: Record = { auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + branch_action: ['push', 'keep-local', 'merged-locally', 'pushed-pr'], archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], @@ -76,6 +77,7 @@ const PATH_FIELDS = new Set(['design_doc', 'plan', 'verification_report', 'hando const CLASSIC_FIELD_WIRE_NAMES: Partial> = { archived: 'archived', branchStatus: 'branch_status', + branchAction: 'branch_action', classicProfile: 'classic_profile', designDoc: 'design_doc', language: 'language', @@ -288,6 +290,12 @@ function sparseClassicState(record: Record): ClassicState { )!, verificationReport: nullableRecordString(record, 'verification_report'), branchStatus: enumRecordValue(record, 'branch_status', ['pending', 'handled'] as const, null), + branchAction: enumRecordValue( + record, + 'branch_action', + ['push', 'keep-local', 'merged-locally', 'pushed-pr'] as const, + null, + ), createdAt: nullableRecordString(record, 'created_at'), verifiedAt: nullableRecordString(record, 'verified_at'), archiveConfirmation: enumRecordValue( diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index 0451856d..8ccd38a5 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -25,6 +25,7 @@ export const PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.a const VERIFY_MODES = ['light', 'full'] as const; const VERIFY_RESULTS = ['pending', 'pass', 'fail'] as const; const BRANCH_STATUSES = ['pending', 'handled'] as const; +const BRANCH_ACTIONS = ['push', 'keep-local', 'merged-locally', 'pushed-pr'] as const; const ARCHIVE_CONFIRMATIONS = ['pending', 'confirmed'] as const; export type ClassicProfile = (typeof CLASSIC_PROFILES)[number]; @@ -50,6 +51,7 @@ export interface ClassicState { verifyResult: (typeof VERIFY_RESULTS)[number]; verificationReport: string | null; branchStatus: (typeof BRANCH_STATUSES)[number] | null; + branchAction: (typeof BRANCH_ACTIONS)[number] | null; createdAt: string | null; verifiedAt: string | null; archiveConfirmation: (typeof ARCHIVE_CONFIRMATIONS)[number] | null; @@ -86,6 +88,7 @@ export const CLASSIC_WIRE_KEYS = [ 'verify_result', 'verification_report', 'branch_status', + 'branch_action', 'created_at', 'verified_at', 'archive_confirmation', @@ -217,6 +220,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { verifyResult: enumValue(doc, 'verify_result', VERIFY_RESULTS, false)!, verificationReport: relativePath(doc, 'verification_report'), branchStatus: enumValue(doc, 'branch_status', BRANCH_STATUSES), + branchAction: enumValue(doc, 'branch_action', BRANCH_ACTIONS), createdAt: nullableString(doc, 'created_at'), verifiedAt: nullableString(doc, 'verified_at'), archiveConfirmation: enumValue(doc, 'archive_confirmation', ARCHIVE_CONFIRMATIONS), @@ -305,6 +309,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { verify_result: state.verifyResult, verification_report: state.verificationReport, branch_status: state.branchStatus, + branch_action: state.branchAction, created_at: state.createdAt, verified_at: state.verifiedAt, archive_confirmation: state.archiveConfirmation, diff --git a/domains/comet-classic/classic-validate-command.ts b/domains/comet-classic/classic-validate-command.ts index d00277f2..647e0a72 100644 --- a/domains/comet-classic/classic-validate-command.ts +++ b/domains/comet-classic/classic-validate-command.ts @@ -36,6 +36,7 @@ const ENUMS: Record = { auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + branch_action: ['push', 'keep-local', 'merged-locally', 'pushed-pr'], archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 544d92ff..d321817e 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -649,6 +649,77 @@ describe('comet scripts', () => { expect(setInvalid.stderr).toContain('Invalid value'); }, 20_000); + it('sets each branch_action value and rejects invalid branch_action values', async () => { + await createChange( + tmpDir, + 'branch-action-set', + [ + 'workflow: full', + 'phase: verify', + 'build_mode: executing-plans', + 'build_pause: null', + 'tdd_mode: tdd', + 'isolation: branch', + 'verify_mode: full', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'branch_status: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + for (const value of ['push', 'keep-local', 'merged-locally', 'pushed-pr']) { + const set = runNode(tmpDir, stateScript, ['set', 'branch-action-set', 'branch_action', value]); + const get = runNode(tmpDir, stateScript, ['get', 'branch-action-set', 'branch_action']); + expect(set.status).toBe(0); + expect(get.stdout.trim()).toBe(value); + } + + const setInvalid = runNode(tmpDir, stateScript, [ + 'set', + 'branch-action-set', + 'branch_action', + 'bogus', + ]); + expect(setInvalid.status).not.toBe(0); + expect(setInvalid.stderr).toContain('Invalid value'); + }, 20_000); + + it('rejects invalid branch_action values during schema validation', async () => { + await createChange( + tmpDir, + 'invalid-branch-action', + [ + 'workflow: full', + 'phase: verify', + 'build_mode: executing-plans', + 'build_pause: null', + 'tdd_mode: tdd', + 'isolation: branch', + 'verify_mode: full', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'branch_status: pending', + 'branch_action: bogus', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, guardScript, ['invalid-branch-action', 'verify']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("branch_action='bogus' is not valid"); + expect(result.stderr).toContain('FATAL: .comet.yaml schema validation failed'); + }, 20_000); + it('validates the language field in .comet.yaml', async () => { await createChange( tmpDir, From e1ef44b662eb1214c890576950ec5a1b92338dbc Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:21:20 +0800 Subject: [PATCH 10/25] docs(comet-verify): record branch_action during branch handling (zh) --- assets/skills-zh/comet-verify/SKILL.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 19a04b8e..2569b53c 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -182,19 +182,19 @@ comet state transition verify-fail 如 `isolation: branch` 或 `worktree` 且 Superpowers `finishing-a-development-branch` 技能不可用,停止流程并提示安装或启用 Superpowers 技能,不要用普通对话替代该步骤。 -`branch/worktree` 模式下,技能加载后按其指引收尾。分支处理选项: -1. 本地合并到主分支 -2. 推送并创建 PR -3. 保持分支(稍后处理) -4. 丢弃工作 +`branch/worktree` 模式下,技能加载后按其指引收尾。分支处理选项及对应 `branch_action`: +1. 本地合并到主分支 → `branch_action: merged-locally` +2. 推送并创建 PR → `branch_action: pushed-pr` +3. 保持分支(稍后处理)→ `branch_action: keep-local` +4. 丢弃工作 → 不写 `branch_action`,也不写 `branch_status: handled`;change 视为放弃,不进入 archive -这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式**,不得根据推荐、默认值或当前分支状态自行选择。只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 +这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户选择分支处理方式**,不得根据推荐、默认值或当前分支状态自行选择。只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled` 与对应的 `branch_action`。 -`current` 模式下,这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择当前分支处理方式**: -1. 立即 push 当前分支 -2. 暂不 push,保留本地分支状态 +`current` 模式下,这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择当前分支处理方式**及对应 `branch_action`: +1. 立即 push 当前分支 → `branch_action: push` +2. 暂不 push,保留本地分支状态 → `branch_action: keep-local` -同样地,只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled`。 +同样地,只有在用户完成选择且对应操作完成后,才允许写入 `branch_status: handled` 与对应的 `branch_action`。 **确认项**: - 全部测试通过 @@ -211,6 +211,10 @@ mkdir -p docs/superpowers/reports comet state set verification_report docs/superpowers/reports/YYYY-MM-DD--verify.md comet state set branch_status handled +# 按 Step 3 用户实际选择写入 branch_action(映射见 Step 3): +# current 模式:push | keep-local +# branch/worktree 模式:merged-locally | pushed-pr | keep-local +comet state set branch_action ``` ## 退出条件 From 353a9311449bd8e013029abea25660232f06dcf2 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:24:26 +0800 Subject: [PATCH 11/25] docs(comet-archive): drive archive push by branch_action (zh) --- assets/skills-zh/comet-archive/SKILL.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index d37a7fe8..eeab1d18 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -102,9 +102,15 @@ git add -A git commit -m "chore: archive " ``` -如分支处理(阶段 4)选择尚未合并到主分支,提交后按所选方式(合并 / PR / 保持分支)一并收尾。 +提交归档 commit 后,读取 `isolation` 与 `branch_action`,按下列场景处理这个新 commit 是否需要推送: -如果本次 change 使用 `isolation: current`,这些 archive 改动会直接追加在当前分支上;若验证阶段已经按用户选择 push 过当前分支,需要明确告知用户 archive commit 仍需单独提交,并且通常还要再 push 一次。不要假设一定存在独立开发分支、合并动作或 PR 收尾。 +- **场景 A — `branch_action: push`**(`current` 模式,验证阶段已 push 过当前分支):验证阶段已授权 push 这条分支,archive commit 落在同一分支上,**直接执行 `git push`**(无需 `-u`,tracking 已存在),并告知用户"archive commit 已随之前的 push 一起同步到远端"。这是延续同一个已授权动作,不再二次确认。 +- **场景 B — `branch_action: pushed-pr`**(`branch`/`worktree` 模式,之前选了"推送并创建 PR"):PR 分支已 `push -u` 且有 tracking,archive commit 落在同一条分支上,性质同场景 A,**直接执行 `git push`**(无需 `-u`),告知用户"archive commit 已追加推送到 PR 分支 ``"。 +- **场景 C — `branch_action: merged-locally`**(`branch`/`worktree` 模式,之前选了"本地合并到主分支"):合并动作从未 push 过 base 分支,base 本地领先 `origin/`(含合并内容与本次 archive commit)。**不自动 push**:按 `comet/reference/decision-point.md` 协议暂停,询问用户"base 分支本地已领先远端,是否现在一起 push",由用户现场决定。不持久化这次决策。 +- **场景 D — `branch_action: keep-local`**(`current` 或 `branch`/`worktree` 均可能):不执行 push,只在回复里明确提示"archive commit 目前只在本地,尚未推送",尊重用户"稍后自己处理"的原始选择。 +- **兜底 — `branch_action` 为空**(升级前创建、未走过新版 verify 流程的存量 change):退回散文提示,询问用户要不要 push;不因字段缺失报错或阻塞。 + +不要假设一定存在独立开发分支、合并动作或 PR 收尾——以 `isolation` 与 `branch_action` 的实际取值为准。 ## 退出条件 From ec03b9f7c312f5051303564f046ab8b3a60afcd2 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:31:27 +0800 Subject: [PATCH 12/25] test: reconcile branch_action field and beta.6 version with existing fixtures --- package-lock.json | 4 ++-- test/app/cli-help.test.ts | 6 +++--- test/domains/comet-classic/classic-contract.test.ts | 3 +++ test/domains/comet-classic/classic-state.test.ts | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9916db76..a90b000c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index 3db804db..b485104f 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -32,9 +32,9 @@ describe('CLI help text', () => { 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.5'); - expect(packageLock.version).toBe('0.4.0-beta.5'); - expect(packageLock.packages[''].version).toBe('0.4.0-beta.5'); + expect(packageJson.version).toBe('0.4.0-beta.6'); + expect(packageLock.version).toBe('0.4.0-beta.6'); + expect(packageLock.packages[''].version).toBe('0.4.0-beta.6'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index 07339513..3b04eec4 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -206,6 +206,9 @@ function legacyProjection(document: Record): Record !runKeys.has(key))); } diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index b0851b67..b4a22e58 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -29,6 +29,7 @@ function classicState(): ClassicState { verifyResult: 'fail', verificationReport: 'docs/verification.md', branchStatus: 'handled', + branchAction: null, createdAt: '2026-06-01', verifiedAt: '2026-06-02', archiveConfirmation: null, From 901c2439c932ce50858e8af700c43d28e5e74c88 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:42:14 +0800 Subject: [PATCH 13/25] feat: add bound_branch field to classic state model --- assets/skills/comet/scripts/comet-runtime.mjs | 4 +++ .../comet-classic/classic-state-command.ts | 1 + domains/comet-classic/classic-state.ts | 4 +++ .../comet-classic/classic-state.test.ts | 1 + .../comet-classic/comet-scripts.test.ts | 27 +++++++++++++++++++ 5 files changed, 37 insertions(+) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index ab37681c..58d52e03 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7760,6 +7760,7 @@ var CLASSIC_WIRE_KEYS = [ "tdd_mode", "review_mode", "isolation", + "bound_branch", "verify_mode", "auto_transition", "base_ref", @@ -7870,6 +7871,7 @@ function classicStateFromDocument(doc) { tddMode: enumValue(doc, "tdd_mode", TDD_MODES), reviewMode: enumValue(doc, "review_mode", REVIEW_MODES), isolation: enumValue(doc, "isolation", ISOLATIONS), + boundBranch: nullableString(doc, "bound_branch"), verifyMode: enumValue(doc, "verify_mode", VERIFY_MODES), autoTransition: booleanValue(doc, "auto_transition"), baseRef: nullableString(doc, "base_ref"), @@ -7935,6 +7937,7 @@ function classicStateToDocument(state) { tdd_mode: state.tddMode, review_mode: state.reviewMode, isolation: state.isolation, + bound_branch: state.boundBranch, verify_mode: state.verifyMode, auto_transition: state.autoTransition, base_ref: state.baseRef, @@ -13155,6 +13158,7 @@ function sparseClassicState(record) { null ), isolation: enumRecordValue(record, "isolation", ISOLATIONS, null), + boundBranch: nullableRecordString(record, "bound_branch"), verifyMode: enumRecordValue(record, "verify_mode", ["light", "full"], null), autoTransition: nullableRecordBoolean(record, "auto_transition"), baseRef: nullableRecordString(record, "base_ref"), diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index b8c33408..55dc377d 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -277,6 +277,7 @@ function sparseClassicState(record: Record): ClassicState { null, ), isolation: enumRecordValue(record, 'isolation', ISOLATIONS, null), + boundBranch: nullableRecordString(record, 'bound_branch'), verifyMode: enumRecordValue(record, 'verify_mode', ['light', 'full'] as const, null), autoTransition: nullableRecordBoolean(record, 'auto_transition'), baseRef: nullableRecordString(record, 'base_ref'), diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index 8ccd38a5..39f2259d 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -43,6 +43,7 @@ export interface ClassicState { tddMode: (typeof TDD_MODES)[number] | null; reviewMode: (typeof REVIEW_MODES)[number] | null; isolation: (typeof ISOLATIONS)[number] | null; + boundBranch: string | null; verifyMode: (typeof VERIFY_MODES)[number] | null; autoTransition: boolean | null; baseRef: string | null; @@ -80,6 +81,7 @@ export const CLASSIC_WIRE_KEYS = [ 'tdd_mode', 'review_mode', 'isolation', + 'bound_branch', 'verify_mode', 'auto_transition', 'base_ref', @@ -212,6 +214,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { tddMode: enumValue(doc, 'tdd_mode', TDD_MODES), reviewMode: enumValue(doc, 'review_mode', REVIEW_MODES), isolation: enumValue(doc, 'isolation', ISOLATIONS), + boundBranch: nullableString(doc, 'bound_branch'), verifyMode: enumValue(doc, 'verify_mode', VERIFY_MODES), autoTransition: booleanValue(doc, 'auto_transition'), baseRef: nullableString(doc, 'base_ref'), @@ -301,6 +304,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { tdd_mode: state.tddMode, review_mode: state.reviewMode, isolation: state.isolation, + bound_branch: state.boundBranch, verify_mode: state.verifyMode, auto_transition: state.autoTransition, base_ref: state.baseRef, diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index b4a22e58..59df5e8e 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -21,6 +21,7 @@ function classicState(): ClassicState { tddMode: 'tdd', reviewMode: null, isolation: 'worktree', + boundBranch: null, verifyMode: 'full', autoTransition: false, baseRef: 'abc123', diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index d321817e..a9998e0c 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2992,6 +2992,33 @@ describe('comet scripts', () => { ); }, 20_000); + it('accepts bound_branch as a known optional field in validation', async () => { + await createChange( + tmpDir, + 'bound-branch-known', + [ + 'workflow: full', + 'phase: build', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const valid = runNode(tmpDir, validateScript, ['bound-branch-known']); + + expect(valid.status).toBe(0); + expect(valid.stderr).toContain('validation PASSED'); + expect(valid.stderr).not.toContain("unknown field 'bound_branch'"); + }); + it('reports accurate archive step counts when syncing and annotating', async () => { const archiveScript = path.join(tmpDir, 'scripts', 'comet-archive.mjs'); const { command, logFile } = await createFakeOpenSpecArchive(tmpDir); From 8471bf09faef43f6ad9d684f260b02a6705783c9 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:46:46 +0800 Subject: [PATCH 14/25] feat: add shared branch-binding verdict module --- .../comet-classic/classic-branch-binding.ts | 85 +++++++++++++++++++ .../classic-branch-binding.test.ts | 73 ++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 domains/comet-classic/classic-branch-binding.ts create mode 100644 test/domains/comet-classic/classic-branch-binding.test.ts diff --git a/domains/comet-classic/classic-branch-binding.ts b/domains/comet-classic/classic-branch-binding.ts new file mode 100644 index 00000000..daa3e57d --- /dev/null +++ b/domains/comet-classic/classic-branch-binding.ts @@ -0,0 +1,85 @@ +import { execFileSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { parseDocument } from 'yaml'; + +export function liveGitBranch(cwd: string): string | null { + try { + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return branch && branch !== 'HEAD' ? branch : null; + } catch { + return null; + } +} + +export type BranchBindingVerdict = + | { status: 'not-applicable' } + | { status: 'ok' } + | { status: 'needs-heal'; branch: string } + | { status: 'unbound-detached' } + | { status: 'drift'; boundBranch: string; currentBranch: string | null }; + +export function evaluateBranchBinding(input: { + isolation: string | null; + boundBranch: string | null; + currentBranch: string | null; +}): BranchBindingVerdict { + if (input.isolation !== 'current') return { status: 'not-applicable' }; + if (input.boundBranch === null) { + return input.currentBranch === null + ? { status: 'unbound-detached' } + : { status: 'needs-heal', branch: input.currentBranch }; + } + if (input.currentBranch === input.boundBranch) return { status: 'ok' }; + return { + status: 'drift', + boundBranch: input.boundBranch, + currentBranch: input.currentBranch, + }; +} + +export async function healBoundBranch(changeDir: string, branch: string): Promise { + const file = path.join(changeDir, '.comet.yaml'); + const document = parseDocument(await fs.readFile(file, 'utf8'), { uniqueKeys: false }); + document.set('bound_branch', branch); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporary, document.toString(), 'utf8'); + await fs.rename(temporary, file); + } catch (error) { + await fs.rm(temporary, { force: true }); + throw error; + } +} + +function branchLabel(currentBranch: string | null): string { + return currentBranch ?? 'detached HEAD'; +} + +export function driftBlockedMessage( + change: string, + boundBranch: string, + currentBranch: string | null, +): string { + return ( + `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'.\n` + + `Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.` + ); +} + +export function driftStaleReason( + change: string, + boundBranch: string, + currentBranch: string | null, +): string { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; +} + +export function unboundDetachedMessage(change: string): string { + return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} diff --git a/test/domains/comet-classic/classic-branch-binding.test.ts b/test/domains/comet-classic/classic-branch-binding.test.ts new file mode 100644 index 00000000..a5261935 --- /dev/null +++ b/test/domains/comet-classic/classic-branch-binding.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + evaluateBranchBinding, + driftBlockedMessage, + driftStaleReason, +} from '../../../domains/comet-classic/classic-branch-binding.js'; + +describe('evaluateBranchBinding', () => { + it('is not applicable when isolation is not current', () => { + expect( + evaluateBranchBinding({ isolation: 'branch', boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'not-applicable' }); + }); + + it('passes when the bound branch matches the current branch', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: 'feature-A', + }), + ).toEqual({ status: 'ok' }); + }); + + it('reports drift when the current branch differs', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: 'feature-B', + }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: 'feature-B' }); + }); + + it('reports drift (never a skip) when bound but HEAD is detached', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: null, + }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: null }); + }); + + it('requests a lazy heal when unbound on a real branch', () => { + expect( + evaluateBranchBinding({ isolation: 'current', boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'needs-heal', branch: 'feature-A' }); + }); + + it('refuses to lazy-bind when unbound and detached', () => { + expect( + evaluateBranchBinding({ isolation: 'current', boundBranch: null, currentBranch: null }), + ).toEqual({ status: 'unbound-detached' }); + }); +}); + +describe('drift messages', () => { + it('renders the blocked message with a detached-HEAD label', () => { + expect(driftBlockedMessage('my-change', 'feature-A', null)).toContain( + "bound to branch 'feature-A', but current branch is 'detached HEAD'", + ); + expect(driftBlockedMessage('my-change', 'feature-A', null)).toContain( + 'comet state rebind my-change', + ); + }); + + it('renders the stale reason with the current branch name', () => { + expect(driftStaleReason('my-change', 'feature-A', 'feature-B')).toBe( + "change 'my-change' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }); +}); From 3018872a03eb4c5e3b9b06871d5d660e5f67be17 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Thu, 16 Jul 2026 23:55:58 +0800 Subject: [PATCH 15/25] feat: bind current-isolation drift detection to bound_branch --- assets/skills/comet/scripts/comet-runtime.mjs | 427 ++++++++++-------- .../comet-classic/classic-current-change.ts | 57 ++- .../comet-classic/classic-state-command.ts | 8 +- .../classic-current-change.test.ts | 45 +- .../comet-classic/comet-scripts.test.ts | 54 ++- 5 files changed, 354 insertions(+), 237 deletions(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 58d52e03..4a63ebaf 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -290,17 +290,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path22) { - const ctrl = callVisitor(key, node, visitor, path22); + function visit_(key, node, visitor, path23) { + const ctrl = callVisitor(key, node, visitor, path23); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path22, ctrl); - return visit_(key, ctrl, visitor, path22); + replaceNode(key, path23, ctrl); + return visit_(key, ctrl, visitor, path23); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path22 = Object.freeze(path22.concat(node)); + path23 = Object.freeze(path23.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path22); + const ci = visit_(i, node.items[i], visitor, path23); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -311,13 +311,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path22 = Object.freeze(path22.concat(node)); - const ck = visit_("key", node.key, visitor, path22); + path23 = Object.freeze(path23.concat(node)); + const ck = visit_("key", node.key, visitor, path23); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path22); + const cv = visit_("value", node.value, visitor, path23); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -338,17 +338,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path22) { - const ctrl = await callVisitor(key, node, visitor, path22); + async function visitAsync_(key, node, visitor, path23) { + const ctrl = await callVisitor(key, node, visitor, path23); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path22, ctrl); - return visitAsync_(key, ctrl, visitor, path22); + replaceNode(key, path23, ctrl); + return visitAsync_(key, ctrl, visitor, path23); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path22 = Object.freeze(path22.concat(node)); + path23 = Object.freeze(path23.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path22); + const ci = await visitAsync_(i, node.items[i], visitor, path23); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -359,13 +359,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path22 = Object.freeze(path22.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path22); + path23 = Object.freeze(path23.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path23); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path22); + const cv = await visitAsync_("value", node.value, visitor, path23); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -392,23 +392,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path22) { + function callVisitor(key, node, visitor, path23) { if (typeof visitor === "function") - return visitor(key, node, path22); + return visitor(key, node, path23); if (identity.isMap(node)) - return visitor.Map?.(key, node, path22); + return visitor.Map?.(key, node, path23); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path22); + return visitor.Seq?.(key, node, path23); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path22); + return visitor.Pair?.(key, node, path23); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path22); + return visitor.Scalar?.(key, node, path23); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path22); + return visitor.Alias?.(key, node, path23); return void 0; } - function replaceNode(key, path22, node) { - const parent = path22[path22.length - 1]; + function replaceNode(key, path23, node) { + const parent = path23[path23.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -1018,10 +1018,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path22, value) { + function collectionFromPath(schema, path23, value) { let v = value; - for (let i = path22.length - 1; i >= 0; --i) { - const k = path22[i]; + for (let i = path23.length - 1; i >= 0; --i) { + const k = path23[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -1040,7 +1040,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path22) => path22 == null || typeof path22 === "object" && !!path22[Symbol.iterator]().next().done; + var isEmptyPath = (path23) => path23 == null || typeof path23 === "object" && !!path23[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -1070,11 +1070,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path22, value) { - if (isEmptyPath(path22)) + addIn(path23, value) { + if (isEmptyPath(path23)) this.add(value); else { - const [key, ...rest] = path22; + const [key, ...rest] = path23; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -1088,8 +1088,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path22) { - const [key, ...rest] = path22; + deleteIn(path23) { + const [key, ...rest] = path23; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -1103,8 +1103,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path22, keepScalar) { - const [key, ...rest] = path22; + getIn(path23, keepScalar) { + const [key, ...rest] = path23; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -1122,8 +1122,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path22) { - const [key, ...rest] = path22; + hasIn(path23) { + const [key, ...rest] = path23; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -1133,8 +1133,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path22, value) { - const [key, ...rest] = path22; + setIn(path23, value) { + const [key, ...rest] = path23; if (rest.length === 0) { this.set(key, value); } else { @@ -3649,9 +3649,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path22, value) { + addIn(path23, value) { if (assertCollection(this.contents)) - this.contents.addIn(path22, value); + this.contents.addIn(path23, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3726,14 +3726,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path22) { - if (Collection.isEmptyPath(path22)) { + deleteIn(path23) { + if (Collection.isEmptyPath(path23)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path22) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path23) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3748,10 +3748,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path22, keepScalar) { - if (Collection.isEmptyPath(path22)) + getIn(path23, keepScalar) { + if (Collection.isEmptyPath(path23)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path22, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path23, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3762,10 +3762,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path22) { - if (Collection.isEmptyPath(path22)) + hasIn(path23) { + if (Collection.isEmptyPath(path23)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path22) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path23) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3782,13 +3782,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path22, value) { - if (Collection.isEmptyPath(path22)) { + setIn(path23, value) { + if (Collection.isEmptyPath(path23)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path22), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path23), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path22, value); + this.contents.setIn(path23, value); } } /** @@ -5748,9 +5748,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path22) => { + visit.itemAtPath = (cst, path23) => { let item = cst; - for (const [field2, index] of path22) { + for (const [field2, index] of path23) { const tok = item?.[field2]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5759,23 +5759,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path22) => { - const parent = visit.itemAtPath(cst, path22.slice(0, -1)); - const field2 = path22[path22.length - 1][0]; + visit.parentCollection = (cst, path23) => { + const parent = visit.itemAtPath(cst, path23.slice(0, -1)); + const field2 = path23[path23.length - 1][0]; const coll = parent?.[field2]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path22, item, visitor) { - let ctrl = visitor(item, path22); + function _visit(path23, item, visitor) { + let ctrl = visitor(item, path23); if (typeof ctrl === "symbol") return ctrl; for (const field2 of ["key", "value"]) { const token = item[field2]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path22.concat([[field2, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path23.concat([[field2, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5786,10 +5786,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field2 === "key") - ctrl = ctrl(item, path22); + ctrl = ctrl(item, path23); } } - return typeof ctrl === "function" ? ctrl(item, path22) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path23) : ctrl; } exports.visit = visit; } @@ -7091,14 +7091,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs21 = this.flowScalar(this.type); + const fs22 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs21, sep: [] }); + map.items.push({ start, key: fs22, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs21); + this.stack.push(fs22); } else { - Object.assign(it, { key: fs21, sep: [] }); + Object.assign(it, { key: fs22, sep: [] }); this.onKeyLine = true; } return; @@ -7226,13 +7226,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs21 = this.flowScalar(this.type); + const fs22 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs21, sep: [] }); + fc.items.push({ start: [], key: fs22, sep: [] }); else if (it.sep) - this.stack.push(fs21); + this.stack.push(fs22); else - Object.assign(it, { key: fs21, sep: [] }); + Object.assign(it, { key: fs22, sep: [] }); return; } case "flow-map-end": @@ -7421,7 +7421,7 @@ var require_public_api = __commonJS({ return docs; return Object.assign([], { empty: true }, composer$1.streamInfo()); } - function parseDocument7(source, options = {}) { + function parseDocument8(source, options = {}) { const { lineCounter: lineCounter2, prettyErrors } = parseOptions(options); const parser$1 = new parser.Parser(lineCounter2?.addNewLine); const composer$1 = new composer.Composer(options); @@ -7447,7 +7447,7 @@ var require_public_api = __commonJS({ } else if (options === void 0 && reviver && typeof reviver === "object") { options = reviver; } - const doc = parseDocument7(src, options); + const doc = parseDocument8(src, options); if (!doc) return null; doc.warnings.forEach((warning) => log.warn(doc.options.logLevel, warning)); @@ -7483,7 +7483,7 @@ var require_public_api = __commonJS({ } exports.parse = parse2; exports.parseAllDocuments = parseAllDocuments; - exports.parseDocument = parseDocument7; + exports.parseDocument = parseDocument8; exports.stringify = stringify; } }); @@ -11607,21 +11607,24 @@ var classicHandoffCommand = async (args) => { }; // domains/comet-classic/classic-hook-guard.ts -import { existsSync as existsSync2, promises as fs18, readFileSync as readFileSync3 } from "fs"; -import path19 from "path"; +import { existsSync as existsSync2, promises as fs19, readFileSync as readFileSync3 } from "fs"; +import path20 from "path"; // domains/comet-classic/classic-current-change.ts +import { randomUUID as randomUUID7 } from "crypto"; +import { promises as fs18 } from "fs"; +import path19 from "path"; + +// domains/comet-classic/classic-branch-binding.ts +var import_yaml7 = __toESM(require_dist(), 1); import { execFileSync } from "child_process"; import { randomUUID as randomUUID6 } from "crypto"; import { promises as fs17 } from "fs"; import path18 from "path"; -function currentChangeFile(projectRoot2) { - return path18.join(projectRoot2, ".comet", "current-change.json"); -} -function currentBranch(projectRoot2) { +function liveGitBranch(cwd) { try { const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: projectRoot2, + cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); @@ -11630,14 +11633,53 @@ function currentBranch(projectRoot2) { return null; } } +function evaluateBranchBinding(input) { + if (input.isolation !== "current") return { status: "not-applicable" }; + if (input.boundBranch === null) { + return input.currentBranch === null ? { status: "unbound-detached" } : { status: "needs-heal", branch: input.currentBranch }; + } + if (input.currentBranch === input.boundBranch) return { status: "ok" }; + return { + status: "drift", + boundBranch: input.boundBranch, + currentBranch: input.currentBranch + }; +} +async function healBoundBranch(changeDir, branch) { + const file = path18.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml7.parseDocument)(await fs17.readFile(file, "utf8"), { uniqueKeys: false }); + document.set("bound_branch", branch); + const temporary = `${file}.${randomUUID6()}.tmp`; + try { + await fs17.writeFile(temporary, document.toString(), "utf8"); + await fs17.rename(temporary, file); + } catch (error) { + await fs17.rm(temporary, { force: true }); + throw error; + } +} +function branchLabel(currentBranch) { + return currentBranch ?? "detached HEAD"; +} +function driftStaleReason(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; +} +function unboundDetachedMessage(change) { + return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} + +// domains/comet-classic/classic-current-change.ts +function currentChangeFile(projectRoot2) { + return path19.join(projectRoot2, ".comet", "current-change.json"); +} function changeDirectory(projectRoot2, changeName) { - return path18.join(projectRoot2, "openspec", "changes", changeName); + return path19.join(projectRoot2, "openspec", "changes", changeName); } async function validateActiveChange(projectRoot2, changeName) { assertOpenSpecChangeName(changeName); const changeDir = changeDirectory(projectRoot2, changeName); try { - await fs17.access(path18.join(changeDir, ".comet.yaml")); + await fs18.access(path19.join(changeDir, ".comet.yaml")); } catch (error) { if (error.code === "ENOENT") { throw new Error( @@ -11679,30 +11721,19 @@ function parseSelection(source) { throw new Error("current change selection change must be a string"); } assertOpenSpecChangeName(record.change); - if (record.branch !== null && typeof record.branch !== "string") { - throw new Error("current change selection branch must be a string or null"); - } - return { - version: 1, - change: record.change, - branch: record.branch - }; + return { version: 1, change: record.change }; } async function selectCurrentChange(projectRoot2, changeName) { await validateActiveChange(projectRoot2, changeName); - const selection = { - version: 1, - change: changeName, - branch: currentBranch(projectRoot2) - }; + const selection = { version: 1, change: changeName }; const file = currentChangeFile(projectRoot2); - const temporary = `${file}.${randomUUID6()}.tmp`; - await fs17.mkdir(path18.dirname(file), { recursive: true }); + const temporary = `${file}.${randomUUID7()}.tmp`; + await fs18.mkdir(path19.dirname(file), { recursive: true }); try { - await fs17.writeFile(temporary, JSON.stringify(selection, null, 2) + "\n", "utf8"); - await fs17.rename(temporary, file); + await fs18.writeFile(temporary, JSON.stringify(selection, null, 2) + "\n", "utf8"); + await fs18.rename(temporary, file); } catch (error) { - await fs17.rm(temporary, { force: true }); + await fs18.rm(temporary, { force: true }); throw error; } return selection; @@ -11710,7 +11741,7 @@ async function selectCurrentChange(projectRoot2, changeName) { async function resolveCurrentChange(projectRoot2) { let source; try { - source = await fs17.readFile(currentChangeFile(projectRoot2), "utf8"); + source = await fs18.readFile(currentChangeFile(projectRoot2), "utf8"); } catch (error) { if (error.code === "ENOENT") return { status: "missing" }; return { @@ -11729,17 +11760,31 @@ async function resolveCurrentChange(projectRoot2) { reason: error instanceof Error ? error.message : String(error) }; } - const branch = currentBranch(projectRoot2); - if (selection.branch !== null && branch !== selection.branch) { + const branch = liveGitBranch(projectRoot2); + const verdict = evaluateBranchBinding({ + isolation: classic.isolation, + boundBranch: classic.boundBranch, + currentBranch: branch + }); + if (verdict.status === "drift") { return { status: "stale", - reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${branch ?? "detached HEAD"}'` + reason: driftStaleReason(selection.change, verdict.boundBranch, verdict.currentBranch) }; } + if (verdict.status === "unbound-detached") { + return { status: "stale", reason: unboundDetachedMessage(selection.change) }; + } + if (verdict.status === "needs-heal") { + await healBoundBranch( + path19.join(projectRoot2, "openspec", "changes", selection.change), + verdict.branch + ); + } return { status: "selected", selection, classic, branch }; } async function clearCurrentChange(projectRoot2) { - await fs17.rm(currentChangeFile(projectRoot2), { force: true }); + await fs18.rm(currentChangeFile(projectRoot2), { force: true }); } // domains/comet-classic/classic-hook-guard.ts @@ -11767,52 +11812,52 @@ function normalized(value) { function parseProjectRoot(args) { const index = args.indexOf("--project-root"); const value = index >= 0 ? args[index + 1] : void 0; - return path19.resolve(value && !value.startsWith("--") ? value : process.cwd()); + return path20.resolve(value && !value.startsWith("--") ? value : process.cwd()); } function relativeToProjectRoot(target, projectRoot2) { - const relative = normalized(path19.relative(projectRoot2, target)); + const relative = normalized(path20.relative(projectRoot2, target)); if (relative === "") return ""; - if (relative.startsWith("../") || relative === ".." || path19.isAbsolute(relative)) return null; + if (relative.startsWith("../") || relative === ".." || path20.isAbsolute(relative)) return null; return relative; } async function physicalPathForPossiblyMissingTarget(target) { - const resolved = path19.resolve(target); - const root = path19.parse(resolved).root; + const resolved = path20.resolve(target); + const root = path20.parse(resolved).root; const missingSegments = []; let cursor = resolved; while (cursor && cursor !== root) { try { - const physicalBase = await fs18.realpath(cursor); - return path19.join(physicalBase, ...missingSegments.reverse()); + const physicalBase = await fs19.realpath(cursor); + return path20.join(physicalBase, ...missingSegments.reverse()); } catch (error) { const code = error.code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; - missingSegments.push(path19.basename(cursor)); - cursor = path19.dirname(cursor); + missingSegments.push(path20.basename(cursor)); + cursor = path20.dirname(cursor); } } try { - const physicalRoot = await fs18.realpath(root); - return path19.join(physicalRoot, ...missingSegments.reverse()); + const physicalRoot = await fs19.realpath(root); + return path20.join(physicalRoot, ...missingSegments.reverse()); } catch { return null; } } async function projectRelative(target, projectRoot2) { - const rawCandidate = path19.isAbsolute(target) ? target : path19.resolve(process.cwd(), target); + const rawCandidate = path20.isAbsolute(target) ? target : path20.resolve(process.cwd(), target); let candidate = normalized(rawCandidate); const rootRelative = relativeToProjectRoot(rawCandidate, projectRoot2); if (rootRelative !== null) return rootRelative; try { const physicalCandidate = await physicalPathForPossiblyMissingTarget(rawCandidate); - const physicalRoot = await fs18.realpath(projectRoot2); + const physicalRoot = await fs19.realpath(projectRoot2); if (physicalCandidate) { const physicalRootRelative = relativeToProjectRoot(physicalCandidate, physicalRoot); if (physicalRootRelative !== null) return physicalRootRelative; candidate = normalized(physicalCandidate); } } catch { - if (!path19.isAbsolute(target)) return normalized(target).replace(/^\.\//u, ""); + if (!path20.isAbsolute(target)) return normalized(target).replace(/^\.\//u, ""); } return candidate.replace(/^\.\//u, ""); } @@ -11837,15 +11882,15 @@ async function loadGoverningChange(changeDir) { } } async function activeChanges(projectRoot2) { - const changesDir = path19.join(projectRoot2, "openspec", "changes"); + const changesDir = path20.join(projectRoot2, "openspec", "changes"); const governingChanges = []; if (!existsSync2(changesDir)) return governingChanges; - for (const entry2 of (await fs18.readdir(changesDir, { withFileTypes: true })).sort( + for (const entry2 of (await fs19.readdir(changesDir, { withFileTypes: true })).sort( (left, right) => left.name.localeCompare(right.name) )) { if (!entry2.isDirectory() || entry2.name === "archive") continue; - const changeDir = path19.join(changesDir, entry2.name); - if (!existsSync2(path19.join(changeDir, ".comet.yaml"))) continue; + const changeDir = path20.join(changesDir, entry2.name); + if (!existsSync2(path20.join(changeDir, ".comet.yaml"))) continue; const governing = await loadGoverningChange(changeDir); if (!governing || governing.archived) continue; governingChanges.push(governing); @@ -11859,7 +11904,7 @@ function allowsSuperpowersArtifacts(governing) { return governing.phase === "design" || governing.phase === "build" || governing.phase === "verify"; } function governingChangeName(governing) { - return governing.changeDir ? path19.basename(governing.changeDir) : null; + return governing.changeDir ? path20.basename(governing.changeDir) : null; } var SUPERPOWERS_ARTIFACT_SUFFIXES = /* @__PURE__ */ new Set([ "design", @@ -11939,8 +11984,8 @@ async function governingChange(relativePath2, projectRoot2) { const rest = relativePath2.slice(prefix.length); const [name] = rest.split("/"); if (name && name !== "archive") { - const changeDir = path19.join(projectRoot2, "openspec", "changes", name); - const stateFile2 = path19.join(changeDir, ".comet.yaml"); + const changeDir = path20.join(projectRoot2, "openspec", "changes", name); + const stateFile2 = path20.join(changeDir, ".comet.yaml"); if (existsSync2(stateFile2)) { const governing = await loadGoverningChange(changeDir); if (governing) return governing; @@ -12513,8 +12558,8 @@ var classicIntentCommand = async (args, _options) => { }; // domains/comet-classic/classic-resume-probe.ts -import path20 from "path"; -import { promises as fs19 } from "fs"; +import path21 from "path"; +import { promises as fs20 } from "fs"; import { spawn } from "child_process"; var COMET_RESUME_PROBE_SCHEMA_VERSION = "comet.resume_probe.v1"; function isRecord2(value) { @@ -12557,13 +12602,13 @@ function result3(action, change, confidence, reason, evidence = []) { } async function readIfExists(filePath) { if (!await fileExists3(filePath)) return ""; - return fs19.readFile(filePath, "utf8"); + return fs20.readFile(filePath, "utf8"); } async function changeSearchText(changeDir, classic) { const files = ["proposal.md", "design.md", "tasks.md"]; const parts = [classic.name, classic.workflow, classic.phase]; for (const file of files) { - parts.push(await readIfExists(path20.join(changeDir, file))); + parts.push(await readIfExists(path21.join(changeDir, file))); } return parts.join("\n").toLowerCase(); } @@ -12627,19 +12672,19 @@ function diagnosticFromProjection(changeDir, name, projection) { }; } async function hasOpenSpecChangeFiles(changeDir) { - return await fileExists3(path20.join(changeDir, "proposal.md")) || await fileExists3(path20.join(changeDir, "design.md")) || await fileExists3(path20.join(changeDir, "tasks.md")); + return await fileExists3(path21.join(changeDir, "proposal.md")) || await fileExists3(path21.join(changeDir, "design.md")) || await fileExists3(path21.join(changeDir, "tasks.md")); } async function discoverActiveChanges(projectRoot2) { - const changesDir = path20.join(projectRoot2, "openspec", "changes"); + const changesDir = path21.join(projectRoot2, "openspec", "changes"); if (!await fileExists3(changesDir)) return []; const entries = await readDir(changesDir); const changes = []; for (const entry2 of entries) { if (entry2 === "archive") continue; - const changeDir = path20.join(changesDir, entry2); - const stat = await fs19.stat(changeDir).catch(() => null); + const changeDir = path21.join(changesDir, entry2); + const stat = await fs20.stat(changeDir).catch(() => null); if (!stat?.isDirectory()) continue; - const hasCometState = await fileExists3(path20.join(changeDir, ".comet.yaml")); + const hasCometState = await fileExists3(path21.join(changeDir, ".comet.yaml")); if (!hasCometState) { if (!await hasOpenSpecChangeFiles(changeDir)) continue; const missingStateChange = { @@ -12939,11 +12984,11 @@ var classicResumeProbeCommand = async (args) => { }; // domains/comet-classic/classic-state-command.ts -var import_yaml7 = __toESM(require_dist(), 1); +var import_yaml8 = __toESM(require_dist(), 1); import { spawnSync as spawnSync3 } from "child_process"; -import { randomUUID as randomUUID7 } from "crypto"; -import { existsSync as existsSync3, promises as fs20 } from "fs"; -import path21 from "path"; +import { randomUUID as randomUUID8 } from "crypto"; +import { existsSync as existsSync3, promises as fs21 } from "fs"; +import path22 from "path"; init_state(); var GREEN5 = "\x1B[32m"; var RED5 = "\x1B[31m"; @@ -13056,7 +13101,7 @@ function validateRelativePath(value, field2) { } async function exists6(file) { try { - await fs20.access(file); + await fs21.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -13065,7 +13110,7 @@ async function exists6(file) { } async function nonempty3(file) { try { - return (await fs20.stat(file)).size > 0; + return (await fs21.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -13077,27 +13122,27 @@ async function changeDirectory2(name) { async function readDocument2(file) { let source; try { - source = await fs20.readFile(file, "utf8"); + source = await fs21.readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail2( - `ERROR: .comet.yaml not found at ${path21.relative(process.cwd(), file).replaceAll("\\", "/")}` + `ERROR: .comet.yaml not found at ${path22.relative(process.cwd(), file).replaceAll("\\", "/")}` ); } throw error; } - const document = (0, import_yaml7.parseDocument)(source, { uniqueKeys: false }); + const document = (0, import_yaml8.parseDocument)(source, { uniqueKeys: false }); if (document.errors.length > 0) fail2(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); return document; } async function atomicWrite2(file, content) { - await fs20.mkdir(path21.dirname(file), { recursive: true }); - const temporary = `${file}.${randomUUID7()}.tmp`; + await fs21.mkdir(path22.dirname(file), { recursive: true }); + const temporary = `${file}.${randomUUID8()}.tmp`; try { - await fs20.writeFile(temporary, content, "utf8"); - await fs20.rename(temporary, file); + await fs21.writeFile(temporary, content, "utf8"); + await fs21.rename(temporary, file); } catch (error) { - await fs20.rm(temporary, { force: true }); + await fs21.rm(temporary, { force: true }); throw error; } } @@ -13236,7 +13281,7 @@ async function stateFile(name) { const change = await changeDirectory2(name); return { ...change, - file: path21.join(change.directory, ".comet.yaml") + file: path22.join(change.directory, ".comet.yaml") }; } async function readField3(name, field2) { @@ -13254,7 +13299,7 @@ async function readField3(name, field2) { return scalar(value); } function parsedValue(field2, value) { - const document = (0, import_yaml7.parseDocument)(`${field2}: ${value} + const document = (0, import_yaml8.parseDocument)(`${field2}: ${value} `); if (document.errors.length > 0) fail2(`ERROR: Invalid value: '${value}'`); return document.get(field2); @@ -13339,10 +13384,10 @@ async function init(output, name, workflow) { validateEnum(workflow, PROFILES); const { file, label, directory } = await stateFile(name); if (await exists6(file)) fail2(`ERROR: .comet.yaml already exists at ${label}/.comet.yaml`); - await fs20.mkdir(directory, { recursive: true }); + await fs21.mkdir(directory, { recursive: true }); const preset = workflow !== "full"; const reviewMode = preset ? "off" : await reviewModeDefault(); - const document = new import_yaml7.Document({ + const document = new import_yaml8.Document({ workflow, language: await projectLanguageDefault(), phase: "open", @@ -13423,13 +13468,13 @@ async function requireOpenArtifacts(name) { const { directory } = await stateFile(name); const workflow = await readField3(name, "workflow"); for (const artifact of ["proposal.md", "tasks.md"]) { - if (!await nonempty3(path21.join(directory, artifact))) { + if (!await nonempty3(path22.join(directory, artifact))) { fail2( `ERROR: Cannot transition '${name}': ${artifact} must exist and be non-empty before leaving open` ); } } - if (workflow === "full" && !await nonempty3(path21.join(directory, "design.md"))) { + if (workflow === "full" && !await nonempty3(path22.join(directory, "design.md"))) { fail2( `ERROR: Cannot transition '${name}': design.md must exist and be non-empty before leaving open` ); @@ -13437,14 +13482,14 @@ async function requireOpenArtifacts(name) { } async function requireDesignEvidence(name) { const designDoc = await readField3(name, "design_doc"); - if (!designDoc || designDoc === "null" || !await nonempty3(path21.resolve(designDoc))) { + if (!designDoc || designDoc === "null" || !await nonempty3(path22.resolve(designDoc))) { fail2( `ERROR: Cannot transition '${name}': design_doc must point to an existing Design Doc before leaving design` ); } } async function writeSparseTransitionEffects(directory, effects) { - const file = path21.join(directory, ".comet.yaml"); + const file = path22.join(directory, ".comet.yaml"); const document = await readDocument2(file); for (const effect of effects) { const field2 = wireField2(effect.field); @@ -13459,7 +13504,7 @@ async function applyTransitionEvent(output, name, event) { let sparse = false; if (!classic) { if (projection.run) fail2("ERROR: Classic state projection is missing"); - const document = await readDocument2(path21.join(directory, ".comet.yaml")); + const document = await readDocument2(path22.join(directory, ".comet.yaml")); classic = sparseClassicState(document.toJS()); sparse = true; } @@ -13506,7 +13551,7 @@ async function transition(output, name, event) { } else if (event === "verify-pass") { await requirePhase(name, "verify"); const report = await readField3(name, "verification_report"); - if (!report || !await exists6(path21.resolve(report))) { + if (!report || !await exists6(path22.resolve(report))) { fail2( `ERROR: Cannot transition '${name}': verification_report must point to an existing report file` ); @@ -13573,9 +13618,9 @@ async function next(output, name) { async function taskCheckoff(output, taskFile, taskText) { validateRelativePath(taskFile, "task file"); if (!taskText) fail2("ERROR: Task text cannot be empty"); - const file = path21.resolve(taskFile); + const file = path22.resolve(taskFile); if (!await exists6(file)) fail2(`ERROR: Task file not found: ${taskFile}`); - const lines = (await fs20.readFile(file, "utf8")).split(/\r?\n/u); + const lines = (await fs21.readFile(file, "utf8")).split(/\r?\n/u); const matches = lines.filter( (line) => [`- [ ] ${taskText}`, `- [x] ${taskText}`, `- [X] ${taskText}`].includes(line) ); @@ -13613,21 +13658,21 @@ async function check2(output, name, phase) { designDoc ? `design_doc=${designDoc} (expected: empty/null)` : "design_doc is empty/null" ); for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { - (await nonempty3(path21.join(directory, artifact)) ? pass2 : reject)( - `${artifact} ${await nonempty3(path21.join(directory, artifact)) ? "non-empty" : "missing or empty"}` + (await nonempty3(path22.join(directory, artifact)) ? pass2 : reject)( + `${artifact} ${await nonempty3(path22.join(directory, artifact)) ? "non-empty" : "missing or empty"}` ); } } else if (phase === "build") { const workflow = await readField3(name, "workflow"); const designDoc = await readField3(name, "design_doc"); if (workflow === "full") { - (designDoc && designDoc !== "null" && await exists6(path21.resolve(designDoc)) ? pass2 : reject)(`design_doc=${designDoc} (expected: non-null and file exists)`); + (designDoc && designDoc !== "null" && await exists6(path22.resolve(designDoc)) ? pass2 : reject)(`design_doc=${designDoc} (expected: non-null and file exists)`); } else { pass2(`workflow=${workflow} (design_doc not required)`); } for (const artifact of ["proposal.md", "tasks.md"]) { - (await nonempty3(path21.join(directory, artifact)) ? pass2 : reject)( - `${artifact} ${await nonempty3(path21.join(directory, artifact)) ? "non-empty" : "missing or empty"}` + (await nonempty3(path22.join(directory, artifact)) ? pass2 : reject)( + `${artifact} ${await nonempty3(path22.join(directory, artifact)) ? "non-empty" : "missing or empty"}` ); } } else if (phase === "verify") { @@ -13649,7 +13694,7 @@ async function check2(output, name, phase) { } function fieldStatus(field2, value, file) { if (!value || value === "null") return ` - ${field2}: PENDING`; - if (file && !existsSync3(path21.resolve(file))) { + if (file && !existsSync3(path22.resolve(file))) { return ` - ${field2}: BROKEN (path ${value} does not exist)`; } return ` - ${field2}: DONE (${value})`; @@ -13658,7 +13703,7 @@ async function recoverOpen(output, directory) { output.stdout.push(" Artifacts:"); let complete = 0; for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { - const done = await nonempty3(path21.join(directory, artifact)); + const done = await nonempty3(path22.join(directory, artifact)); if (done) complete += 1; output.stdout.push(` - ${artifact}: ${done ? "DONE" : "PENDING"}`); } @@ -13671,7 +13716,7 @@ async function recoverDesign(output, name, directory) { output.stdout.push(" Artifacts:"); for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { output.stdout.push( - ` - ${artifact}: ${await nonempty3(path21.join(directory, artifact)) ? "DONE" : "MISSING (unexpected in design phase)"}` + ` - ${artifact}: ${await nonempty3(path22.join(directory, artifact)) ? "DONE" : "MISSING (unexpected in design phase)"}` ); } const handoff = await readField3(name, "handoff_context"); @@ -13685,11 +13730,11 @@ async function recoverDesign(output, name, directory) { fieldStatus("design_doc", design, design), "" ); - if (design && design !== "null" && await exists6(path21.resolve(design))) { + if (design && design !== "null" && await exists6(path22.resolve(design))) { output.stdout.push( "Recovery action: Design Doc already created and linked. Run guard to transition to build." ); - } else if (handoff && handoff !== "null" && await exists6(path21.resolve(handoff))) { + } else if (handoff && handoff !== "null" && await exists6(path22.resolve(handoff))) { output.stdout.push( "Recovery action: Handoff generated but Design Doc not yet created. Resume from brainstorming confirmation (Step 1c)." ); @@ -13719,7 +13764,7 @@ async function recoverBuild(output, name, directory, workflow) { decisions.push(fieldStatus("subagent_dispatch", subagentDispatch)); } output.stdout.push(...decisions, "", " Plan:", fieldStatus("plan", plan, plan), ""); - const tasks = path21.join(directory, "tasks.md"); + const tasks = path22.join(directory, "tasks.md"); if (!await exists6(tasks)) { output.stdout.push( " Tasks: tasks.md MISSING", @@ -13728,14 +13773,14 @@ async function recoverBuild(output, name, directory, workflow) { ); return; } - const lines = (await fs20.readFile(tasks, "utf8")).split(/\r?\n/u); + const lines = (await fs21.readFile(tasks, "utf8")).split(/\r?\n/u); const total = lines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; const done = lines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; const pending = total - done; let planTotal = 0; let planDone = 0; - if (plan && plan !== "null" && await exists6(path21.resolve(plan))) { - const planLines = (await fs20.readFile(path21.resolve(plan), "utf8")).split(/\r?\n/u); + if (plan && plan !== "null" && await exists6(path22.resolve(plan))) { + const planLines = (await fs21.readFile(path22.resolve(plan), "utf8")).split(/\r?\n/u); planTotal = planLines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; planDone = planLines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; } @@ -13864,19 +13909,19 @@ async function scale(output, name) { validateChangeName4(name); const { file, directory, label } = await stateFile(name); if (!await exists6(file)) fail2(`ERROR: .comet.yaml not found at ${label}/.comet.yaml`); - const tasksFile = path21.join(directory, "tasks.md"); - const taskCount = await exists6(tasksFile) ? (await fs20.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; - const specs = path21.join(directory, "specs"); + const tasksFile = path22.join(directory, "tasks.md"); + const taskCount = await exists6(tasksFile) ? (await fs21.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; + const specs = path22.join(directory, "specs"); let deltaSpecs = 0; if (await exists6(specs)) { - for (const entry2 of await fs20.readdir(specs)) { - if (await exists6(path21.join(specs, entry2, "spec.md"))) deltaSpecs += 1; + for (const entry2 of await fs21.readdir(specs)) { + if (await exists6(path22.join(specs, entry2, "spec.md"))) deltaSpecs += 1; } } const plan = await readField3(name, "plan"); let baseRef = ""; - if (plan && plan !== "null" && await exists6(path21.resolve(plan))) { - const match = (await fs20.readFile(path21.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); + if (plan && plan !== "null" && await exists6(path22.resolve(plan))) { + const match = (await fs21.readFile(path22.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); baseRef = match?.[1].trim() ?? ""; } if (!baseRef) baseRef = await readField3(name, "base_ref"); @@ -13956,11 +14001,7 @@ async function selectChange(output, name) { validateChangeName4(name); try { const selection = await selectCurrentChange(process.cwd(), name); - output.stderr.push( - green4( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ""}` - ) - ); + output.stderr.push(green4(`[SELECTED] current change: ${selection.change}`)); } catch (error) { fail2(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } @@ -13970,7 +14011,7 @@ async function currentChange(output) { if (resolution.status === "selected") { output.stdout.push(resolution.selection.change); const isolation = resolution.classic.isolation ?? null; - const branch = resolution.selection.branch ?? resolution.branch; + const branch = resolution.classic.boundBranch ?? resolution.branch; output.stderr.push( `[CURRENT] isolation: ${isolation ?? "null"}${branch ? `, branch: ${branch}` : ""}` ); diff --git a/domains/comet-classic/classic-current-change.ts b/domains/comet-classic/classic-current-change.ts index 7df93ab6..82501fa0 100644 --- a/domains/comet-classic/classic-current-change.ts +++ b/domains/comet-classic/classic-current-change.ts @@ -1,15 +1,20 @@ -import { execFileSync } from 'child_process'; import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import { assertOpenSpecChangeName } from './classic-paths.js'; import { readClassicState } from './classic-store.js'; import type { ClassicState } from './classic-state.js'; +import { + driftStaleReason, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; export interface CurrentChangeSelection { version: 1; change: string; - branch: string | null; } export type CurrentChangeResolution = @@ -26,19 +31,6 @@ export function currentChangeFile(projectRoot: string): string { return path.join(projectRoot, '.comet', 'current-change.json'); } -function currentBranch(projectRoot: string): string | null { - try { - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: projectRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return branch && branch !== 'HEAD' ? branch : null; - } catch { - return null; - } -} - function changeDirectory(projectRoot: string, changeName: string): string { return path.join(projectRoot, 'openspec', 'changes', changeName); } @@ -94,14 +86,7 @@ function parseSelection(source: string): CurrentChangeSelection { throw new Error('current change selection change must be a string'); } assertOpenSpecChangeName(record.change); - if (record.branch !== null && typeof record.branch !== 'string') { - throw new Error('current change selection branch must be a string or null'); - } - return { - version: 1, - change: record.change, - branch: record.branch as string | null, - }; + return { version: 1, change: record.change }; } export async function selectCurrentChange( @@ -109,11 +94,7 @@ export async function selectCurrentChange( changeName: string, ): Promise { await validateActiveChange(projectRoot, changeName); - const selection: CurrentChangeSelection = { - version: 1, - change: changeName, - branch: currentBranch(projectRoot), - }; + const selection: CurrentChangeSelection = { version: 1, change: changeName }; const file = currentChangeFile(projectRoot); const temporary = `${file}.${randomUUID()}.tmp`; await fs.mkdir(path.dirname(file), { recursive: true }); @@ -151,13 +132,27 @@ export async function resolveCurrentChange(projectRoot: string): Promise validateChangeName(name); try { const selection = await selectCurrentChange(process.cwd(), name); - output.stderr.push( - green( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ''}`, - ), - ); + output.stderr.push(green(`[SELECTED] current change: ${selection.change}`)); } catch (error) { fail(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } @@ -1248,7 +1244,7 @@ async function currentChange(output: CommandOutput): Promise { if (resolution.status === 'selected') { output.stdout.push(resolution.selection.change); const isolation = resolution.classic.isolation ?? null; - const branch = resolution.selection.branch ?? resolution.branch; + const branch = resolution.classic.boundBranch ?? resolution.branch; output.stderr.push( `[CURRENT] isolation: ${isolation ?? 'null'}${branch ? `, branch: ${branch}` : ''}`, ); diff --git a/test/domains/comet-classic/classic-current-change.test.ts b/test/domains/comet-classic/classic-current-change.test.ts index 3d7f2b5f..31cf9efd 100644 --- a/test/domains/comet-classic/classic-current-change.test.ts +++ b/test/domains/comet-classic/classic-current-change.test.ts @@ -45,6 +45,32 @@ async function seedActiveChange(root: string, name: string, archived: boolean): ); } +async function seedCurrentIsolationChange( + root: string, + name: string, + boundBranch: string, +): Promise { + const changeDir = path.join(root, 'openspec', 'changes', name); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, '.comet.yaml'), + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/design.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${boundBranch}`, + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); +} + describe('Classic current change selection', () => { let root: string; @@ -62,12 +88,12 @@ describe('Classic current change selection', () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); - it('atomically selects an active change with the current branch', async () => { + it('atomically selects an active change without recording a branch', async () => { await seedActiveChange(root, 'change-a', false); const selected = await selectCurrentChange(root, 'change-a'); - expect(selected).toEqual({ version: 1, change: 'change-a', branch: 'main' }); + expect(selected).toEqual({ version: 1, change: 'change-a' }); expect(JSON.parse(await fs.readFile(currentChangeFile(root), 'utf8'))).toEqual(selected); expect((await fs.readdir(path.join(root, '.comet'))).sort()).toEqual(['current-change.json']); }); @@ -79,18 +105,27 @@ describe('Classic current change selection', () => { await expect(selectCurrentChange(root, 'archived-change')).rejects.toThrow('archived'); }); - it('marks a selection stale after the branch changes', async () => { - await seedActiveChange(root, 'change-a', false); + it('marks a current-isolation selection stale when the bound branch drifts', async () => { + await seedCurrentIsolationChange(root, 'change-a', 'main'); await selectCurrentChange(root, 'change-a'); git(root, 'switch', '-c', 'other'); expect(await resolveCurrentChange(root)).toEqual({ status: 'stale', - reason: "current change 'change-a' was selected on branch 'main', current branch is 'other'", + reason: "change 'change-a' is bound to branch 'main', but current branch is 'other'", }); }); + it('keeps a branch-isolation selection valid across branch switches', async () => { + await seedActiveChange(root, 'change-a', false); + await selectCurrentChange(root, 'change-a'); + + git(root, 'switch', '-c', 'other'); + + expect(await resolveCurrentChange(root)).toMatchObject({ status: 'selected' }); + }); + it('reports malformed selection data as stale instead of missing', async () => { await fs.mkdir(path.dirname(currentChangeFile(root)), { recursive: true }); await fs.writeFile(currentChangeFile(root), '{not-json\n'); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index a9998e0c..be0e3a82 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -673,7 +673,12 @@ describe('comet scripts', () => { ); for (const value of ['push', 'keep-local', 'merged-locally', 'pushed-pr']) { - const set = runNode(tmpDir, stateScript, ['set', 'branch-action-set', 'branch_action', value]); + const set = runNode(tmpDir, stateScript, [ + 'set', + 'branch-action-set', + 'branch_action', + value, + ]); const get = runNode(tmpDir, stateScript, ['get', 'branch-action-set', 'branch_action']); expect(set.status).toBe(0); expect(get.stdout.trim()).toBe(value); @@ -3504,7 +3509,11 @@ describe('comet scripts', () => { ].join('\n'), ); - const result = runNode(tmpDir, stateScript, ['transition', 'hotfix-worktree', 'build-complete']); + const result = runNode(tmpDir, stateScript, [ + 'transition', + 'hotfix-worktree', + 'build-complete', + ]); expect(result.status).toBe(0); }, 20_000); @@ -5760,5 +5769,46 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }, 20_000); + + it('blocks a repo source write when the current-isolation change drifted off its bound branch', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await createChange( + tmpDir, + 'drift-hook', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/drift-hook.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + await writeFile( + path.join(tmpDir, 'docs', 'superpowers', 'specs', 'drift-hook.md'), + '# Design\n', + ); + expect(runNode(tmpDir, stateScript, ['select', 'drift-hook']).status).toBe(0); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const targetFile = path.join(tmpDir, 'src', 'feature.ts'); + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('current change selection is stale or invalid'); + expect(result.stderr).toContain( + "change 'drift-hook' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); }); }); From edad77fbd8eb8487d6d310a3e60eea28124b4e57 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 00:36:25 +0800 Subject: [PATCH 16/25] fix(comet-classic): restore generic branch-switch check for non-current isolation --- assets/skills/comet/scripts/comet-runtime.mjs | 50 +++++++++++------- .../comet-classic/classic-current-change.ts | 51 ++++++++++++------- .../classic-current-change.test.ts | 11 ++-- 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 4a63ebaf..469e54be 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -11721,11 +11721,18 @@ function parseSelection(source) { throw new Error("current change selection change must be a string"); } assertOpenSpecChangeName(record.change); - return { version: 1, change: record.change }; + if (record.branch !== null && typeof record.branch !== "string") { + throw new Error("current change selection branch must be a string or null"); + } + return { version: 1, change: record.change, branch: record.branch }; } async function selectCurrentChange(projectRoot2, changeName) { await validateActiveChange(projectRoot2, changeName); - const selection = { version: 1, change: changeName }; + const selection = { + version: 1, + change: changeName, + branch: liveGitBranch(projectRoot2) + }; const file = currentChangeFile(projectRoot2); const temporary = `${file}.${randomUUID7()}.tmp`; await fs18.mkdir(path19.dirname(file), { recursive: true }); @@ -11761,26 +11768,33 @@ async function resolveCurrentChange(projectRoot2) { }; } const branch = liveGitBranch(projectRoot2); - const verdict = evaluateBranchBinding({ - isolation: classic.isolation, - boundBranch: classic.boundBranch, - currentBranch: branch - }); - if (verdict.status === "drift") { + if (classic.isolation === "current") { + const verdict = evaluateBranchBinding({ + isolation: classic.isolation, + boundBranch: classic.boundBranch, + currentBranch: branch + }); + if (verdict.status === "drift") { + return { + status: "stale", + reason: driftStaleReason(selection.change, verdict.boundBranch, verdict.currentBranch) + }; + } + if (verdict.status === "unbound-detached") { + return { status: "stale", reason: unboundDetachedMessage(selection.change) }; + } + if (verdict.status === "needs-heal") { + await healBoundBranch( + path19.join(projectRoot2, "openspec", "changes", selection.change), + verdict.branch + ); + } + } else if (selection.branch !== null && branch !== selection.branch) { return { status: "stale", - reason: driftStaleReason(selection.change, verdict.boundBranch, verdict.currentBranch) + reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${branch ?? "detached HEAD"}'` }; } - if (verdict.status === "unbound-detached") { - return { status: "stale", reason: unboundDetachedMessage(selection.change) }; - } - if (verdict.status === "needs-heal") { - await healBoundBranch( - path19.join(projectRoot2, "openspec", "changes", selection.change), - verdict.branch - ); - } return { status: "selected", selection, classic, branch }; } async function clearCurrentChange(projectRoot2) { diff --git a/domains/comet-classic/classic-current-change.ts b/domains/comet-classic/classic-current-change.ts index 82501fa0..5bd35325 100644 --- a/domains/comet-classic/classic-current-change.ts +++ b/domains/comet-classic/classic-current-change.ts @@ -15,6 +15,7 @@ import { export interface CurrentChangeSelection { version: 1; change: string; + branch: string | null; } export type CurrentChangeResolution = @@ -86,7 +87,10 @@ function parseSelection(source: string): CurrentChangeSelection { throw new Error('current change selection change must be a string'); } assertOpenSpecChangeName(record.change); - return { version: 1, change: record.change }; + if (record.branch !== null && typeof record.branch !== 'string') { + throw new Error('current change selection branch must be a string or null'); + } + return { version: 1, change: record.change, branch: record.branch as string | null }; } export async function selectCurrentChange( @@ -94,7 +98,11 @@ export async function selectCurrentChange( changeName: string, ): Promise { await validateActiveChange(projectRoot, changeName); - const selection: CurrentChangeSelection = { version: 1, change: changeName }; + const selection: CurrentChangeSelection = { + version: 1, + change: changeName, + branch: liveGitBranch(projectRoot), + }; const file = currentChangeFile(projectRoot); const temporary = `${file}.${randomUUID()}.tmp`; await fs.mkdir(path.dirname(file), { recursive: true }); @@ -133,26 +141,33 @@ export async function resolveCurrentChange(projectRoot: string): Promise { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); - it('atomically selects an active change without recording a branch', async () => { + it('atomically selects an active change and records the current branch', async () => { await seedActiveChange(root, 'change-a', false); const selected = await selectCurrentChange(root, 'change-a'); - expect(selected).toEqual({ version: 1, change: 'change-a' }); + expect(selected).toEqual({ version: 1, change: 'change-a', branch: 'main' }); expect(JSON.parse(await fs.readFile(currentChangeFile(root), 'utf8'))).toEqual(selected); expect((await fs.readdir(path.join(root, '.comet'))).sort()).toEqual(['current-change.json']); }); @@ -117,13 +117,16 @@ describe('Classic current change selection', () => { }); }); - it('keeps a branch-isolation selection valid across branch switches', async () => { + it('marks a branch-isolation selection stale when the branch changes', async () => { await seedActiveChange(root, 'change-a', false); await selectCurrentChange(root, 'change-a'); git(root, 'switch', '-c', 'other'); - expect(await resolveCurrentChange(root)).toMatchObject({ status: 'selected' }); + expect(await resolveCurrentChange(root)).toEqual({ + status: 'stale', + reason: "current change 'change-a' was selected on branch 'main', current branch is 'other'", + }); }); it('reports malformed selection data as stale instead of missing', async () => { From aa0598d2d9ab9b700ce4e86a06f1e37cfe722c4d Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 00:59:50 +0800 Subject: [PATCH 17/25] feat: capture bound_branch on set isolation current --- assets/skills/comet/scripts/comet-runtime.mjs | 21 +++- .../comet-classic/classic-state-command.ts | 20 ++++ .../comet-classic/comet-scripts.test.ts | 113 ++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 469e54be..00692e6b 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -13016,7 +13016,8 @@ var MACHINE_OWNED_FIELDS = /* @__PURE__ */ new Set([ ...RUN_WIRE_KEYS, "archive_confirmation", "classic_profile", - "classic_migration" + "classic_migration", + "bound_branch" ]); var SETTABLE_FIELDS = new Set( CLASSIC_WIRE_KEYS.filter((field2) => !MACHINE_OWNED_FIELDS.has(field2)) @@ -13349,6 +13350,24 @@ async function setField2(output, name, field2, value, options = {}) { const { file, directory } = await stateFile(name); const document = await readDocument2(file); document.set(field2, parsedValue(field2, value)); + if (field2 === "isolation") { + if (value === "current") { + const record = document.toJS(); + const existing = record.bound_branch; + const alreadyBound = typeof existing === "string" && existing !== ""; + if (!alreadyBound) { + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail2( + "ERROR: cannot bind isolation=current while HEAD is detached; checkout a branch first" + ); + } + document.set("bound_branch", branch); + } + } else { + document.set("bound_branch", null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS(), run); if (projection.run) { diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 5c01b743..2a69bb2c 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -23,6 +23,7 @@ import { type ClassicState, } from './classic-state.js'; import { readClassicState, writeClassicState } from './classic-store.js'; +import { liveGitBranch } from './classic-branch-binding.js'; import { CLASSIC_TRANSITION_EVENTS, applyClassicTransition, @@ -46,6 +47,7 @@ const MACHINE_OWNED_FIELDS = new Set([ 'archive_confirmation', 'classic_profile', 'classic_migration', + 'bound_branch', ]); const SETTABLE_FIELDS = new Set( CLASSIC_WIRE_KEYS.filter((field) => !MACHINE_OWNED_FIELDS.has(field)), @@ -438,6 +440,24 @@ async function setField( const { file, directory } = await stateFile(name); const document = await readDocument(file); document.set(field, parsedValue(field, value)); + if (field === 'isolation') { + if (value === 'current') { + const record = document.toJS() as Record; + const existing = record.bound_branch; + const alreadyBound = typeof existing === 'string' && existing !== ''; + if (!alreadyBound) { + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail( + 'ERROR: cannot bind isolation=current while HEAD is detached; checkout a branch first', + ); + } + document.set('bound_branch', branch); + } + } else { + document.set('bound_branch', null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS() as Record, run); if (projection.run) { diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index be0e3a82..6c64e155 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2162,6 +2162,119 @@ describe('comet scripts', () => { expect(transition.status).toBe(0); }, 20_000); + describe('isolation=current branch binding', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + async function readBoundBranch(name: string): Promise { + const get = runNode(tmpDir, stateScript, ['get', name, 'bound_branch']); + return get.stdout.trim(); + } + + it('captures the current branch when isolation is first set to current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'bind-once', + ['workflow: full', 'phase: build', 'isolation: null', 'archived: false', ''].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'bind-once', 'isolation', 'current']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('bind-once')).toBe('feature-A'); + }); + + it('does not overwrite an existing bound_branch on a repeated set isolation current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'bind-idempotent', + [ + 'workflow: full', + 'phase: build', + 'isolation: current', + 'bound_branch: feature-A', + 'archived: false', + '', + ].join('\n'), + ); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const set = runNode(tmpDir, stateScript, ['set', 'bind-idempotent', 'isolation', 'current']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('bind-idempotent')).toBe('feature-A'); + }); + + it('clears bound_branch when isolation switches away from current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'switch-away', + [ + 'workflow: full', + 'phase: build', + 'isolation: current', + 'bound_branch: feature-A', + 'archived: false', + '', + ].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'switch-away', 'isolation', 'branch']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('switch-away')).toBe('null'); + }); + + it('rejects set isolation current while HEAD is detached', async () => { + await gitInit('feature-A'); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + await createChange( + tmpDir, + 'detached-bind', + [ + 'workflow: full', + 'phase: build', + 'isolation: null', + 'bound_branch: null', + 'archived: false', + '', + ].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'detached-bind', 'isolation', 'current']); + + expect(set.status).not.toBe(0); + expect(set.stderr).toContain('HEAD is detached'); + expect(await readBoundBranch('detached-bind')).toBe('null'); + }); + + it('rejects a direct set of the machine-owned bound_branch field', async () => { + await createChange( + tmpDir, + 'direct-bound', + ['workflow: full', 'phase: build', 'isolation: current', 'archived: false', ''].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'direct-bound', 'bound_branch', 'x']); + + expect(set.status).not.toBe(0); + expect(set.stderr).toContain('machine-owned'); + }); + }); + it('blocks build completion until tdd_mode is selected for full workflow', async () => { await createChange( tmpDir, From 3042fe5539a055ce958fd7250c62f9f83f809f1f Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:23:59 +0800 Subject: [PATCH 18/25] feat: enforce bound_branch drift in state check --- assets/skills/comet/scripts/comet-runtime.mjs | 23 +++++ .../comet-classic/classic-state-command.ts | 27 +++++- .../comet-classic/comet-scripts.test.ts | 83 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 00692e6b..e614b623 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -11661,6 +11661,10 @@ async function healBoundBranch(changeDir, branch) { function branchLabel(currentBranch) { return currentBranch ?? "detached HEAD"; } +function driftBlockedMessage(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'. +Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.`; +} function driftStaleReason(change, boundBranch, currentBranch) { return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; } @@ -13718,6 +13722,25 @@ async function check2(output, name, phase) { const archived = await readField3(name, "archived"); (archived !== "true" ? pass2 : reject)(`archived=${archived} (expected: not true)`); } + const isolation = await readField3(name, "isolation"); + if (isolation === "current") { + const boundBranch = await readField3(name, "bound_branch"); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== "null" ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()) + }); + if (verdict.status === "drift") { + reject(driftBlockedMessage(name, verdict.boundBranch, verdict.currentBranch)); + } else if (verdict.status === "unbound-detached") { + reject(unboundDetachedMessage(name)); + } else if (verdict.status === "needs-heal") { + await healBoundBranch(directory, verdict.branch); + pass2(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } else { + pass2(`bound_branch matches current branch (isolation=current)`); + } + } output.stdout.push(""); if (blocked2) { output.stderr.push(red4("BLOCKED — fix failing checks before proceeding")); diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 2a69bb2c..adb3ba79 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -23,7 +23,13 @@ import { type ClassicState, } from './classic-state.js'; import { readClassicState, writeClassicState } from './classic-store.js'; -import { liveGitBranch } from './classic-branch-binding.js'; +import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { CLASSIC_TRANSITION_EVENTS, applyClassicTransition, @@ -863,6 +869,25 @@ async function check(output: CommandOutput, name: string, phase: string): Promis const archived = await readField(name, 'archived'); (archived !== 'true' ? pass : reject)(`archived=${archived} (expected: not true)`); } + const isolation = await readField(name, 'isolation'); + if (isolation === 'current') { + const boundBranch = await readField(name, 'bound_branch'); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== 'null' ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()), + }); + if (verdict.status === 'drift') { + reject(driftBlockedMessage(name, verdict.boundBranch, verdict.currentBranch)); + } else if (verdict.status === 'unbound-detached') { + reject(unboundDetachedMessage(name)); + } else if (verdict.status === 'needs-heal') { + await healBoundBranch(directory, verdict.branch); + pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } else { + pass(`bound_branch matches current branch (isolation=current)`); + } + } output.stdout.push(''); if (blocked) { output.stderr.push(red('BLOCKED — fix failing checks before proceeding')); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 6c64e155..9be6bf4c 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2275,6 +2275,89 @@ describe('comet scripts', () => { }); }); + describe('state check current-isolation drift', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + function currentChangeYaml(bound: string | null): string { + return [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${bound ?? 'null'}`, + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'); + } + + it('blocks the verify entry check after the branch drifts off bound_branch', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'verify-drift', currentChangeYaml('feature-A')); + runNode(tmpDir, stateScript, ['select', 'verify-drift']); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const check = runNode(tmpDir, stateScript, ['check', 'verify-drift', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout + check.stderr).toContain('BLOCKED'); + expect(check.stdout).toContain( + "change 'verify-drift' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }); + + it('still detects drift after the sidecar is deleted and re-selected', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'sidecar-loss', currentChangeYaml('feature-A')); + runNode(tmpDir, stateScript, ['select', 'sidecar-loss']); + await fs.rm(path.join(tmpDir, '.comet'), { recursive: true, force: true }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + runNode(tmpDir, stateScript, ['select', 'sidecar-loss']); + + const check = runNode(tmpDir, stateScript, ['check', 'sidecar-loss', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout).toContain("bound to branch 'feature-A'"); + }); + + it('blocks the check when a bound change is inspected while HEAD is detached', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'detached-check', currentChangeYaml('feature-A')); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + + const check = runNode(tmpDir, stateScript, ['check', 'detached-check', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout).toContain('detached HEAD'); + }); + + it('lazily binds a legacy unbound current-isolation change and passes', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'legacy-unbound', currentChangeYaml(null)); + + const check = runNode(tmpDir, stateScript, ['check', 'legacy-unbound', 'verify']); + const bound = runNode(tmpDir, stateScript, ['get', 'legacy-unbound', 'bound_branch']); + + expect(check.status).toBe(0); + expect(bound.stdout.trim()).toBe('feature-A'); + }); + }); + it('blocks build completion until tdd_mode is selected for full workflow', async () => { await createChange( tmpDir, From 9ff5e46fba92657cc7095928736af887027ae331 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:30:26 +0800 Subject: [PATCH 19/25] feat: add state rebind subcommand with audit trail --- assets/skills/comet/scripts/comet-runtime.mjs | 30 ++++++++ .../comet-classic/classic-state-command.ts | 31 ++++++++ domains/comet-classic/classic-state-events.ts | 2 +- .../comet-classic/comet-scripts.test.ts | 74 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index e614b623..2d5c6cc6 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -14053,6 +14053,33 @@ function required(args, count, usage3) { function requiredExact(args, count, usage3) { if (args.length !== count) fail2(usage3); } +async function rebind(output, name) { + validateChangeName4(name); + const { directory } = await stateFile(name); + const boundBranch = await readField3(name, "bound_branch"); + if (!boundBranch || boundBranch === "null") { + fail2( + `ERROR: '${name}' is not yet bound; use 'comet state set ${name} isolation current' to establish the first binding` + ); + } + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail2("ERROR: cannot rebind while HEAD is detached; checkout a branch first"); + } + const before = await readClassicState(directory); + if (!before.classic) fail2("ERROR: Classic state projection is missing"); + await healBoundBranch(directory, branch); + const after = { ...before.classic, boundBranch: branch }; + await appendClassicStateEvent(directory, { + change: name, + event: "rebind", + source: "comet-state", + from: before.classic, + to: after, + effects: [{ field: "boundBranch", from: boundBranch, to: branch }] + }); + output.stderr.push(green4(`[REBIND] bound_branch: ${boundBranch} → ${branch}`)); +} async function selectChange(output, name) { validateChangeName4(name); try { @@ -14120,6 +14147,9 @@ var classicStateCommand = async (args) => { } else if (subcommand === "task-checkoff") { required(rest, 2, "Usage: comet-state.mjs task-checkoff "); await taskCheckoff(output, rest[0], rest[1]); + } else if (subcommand === "rebind") { + requiredExact(rest, 1, "Usage: comet-state.mjs rebind "); + await rebind(output, rest[0]); } else if (subcommand === "select") { requiredExact(rest, 1, "Usage: comet-state.mjs select "); await selectChange(output, rest[0]); diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index adb3ba79..47e329b0 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -1274,6 +1274,34 @@ function requiredExact(args: string[], count: number, usage: string): void { if (args.length !== count) fail(usage); } +async function rebind(output: CommandOutput, name: string): Promise { + validateChangeName(name); + const { directory } = await stateFile(name); + const boundBranch = await readField(name, 'bound_branch'); + if (!boundBranch || boundBranch === 'null') { + fail( + `ERROR: '${name}' is not yet bound; use 'comet state set ${name} isolation current' to establish the first binding`, + ); + } + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail('ERROR: cannot rebind while HEAD is detached; checkout a branch first'); + } + const before = await readClassicState(directory); + if (!before.classic) fail('ERROR: Classic state projection is missing'); + await healBoundBranch(directory, branch); + const after: ClassicState = { ...before.classic, boundBranch: branch }; + await appendClassicStateEvent(directory, { + change: name, + event: 'rebind', + source: 'comet-state', + from: before.classic, + to: after, + effects: [{ field: 'boundBranch', from: boundBranch, to: branch }], + }); + output.stderr.push(green(`[REBIND] bound_branch: ${boundBranch} → ${branch}`)); +} + async function selectChange(output: CommandOutput, name: string): Promise { validateChangeName(name); try { @@ -1343,6 +1371,9 @@ export const classicStateCommand: ClassicCommandHandler = async (args) => { } else if (subcommand === 'task-checkoff') { required(rest, 2, 'Usage: comet-state.mjs task-checkoff '); await taskCheckoff(output, rest[0], rest[1]); + } else if (subcommand === 'rebind') { + requiredExact(rest, 1, 'Usage: comet-state.mjs rebind '); + await rebind(output, rest[0]); } else if (subcommand === 'select') { requiredExact(rest, 1, 'Usage: comet-state.mjs select '); await selectChange(output, rest[0]); diff --git a/domains/comet-classic/classic-state-events.ts b/domains/comet-classic/classic-state-events.ts index 1ec744f4..ae9d23f0 100644 --- a/domains/comet-classic/classic-state-events.ts +++ b/domains/comet-classic/classic-state-events.ts @@ -7,7 +7,7 @@ export type ClassicStateEventSource = 'comet-state' | 'comet-guard' | 'comet-arc export interface ClassicStateEventInput { change: string; - event: ClassicTransitionEvent; + event: ClassicTransitionEvent | 'rebind'; source: ClassicStateEventSource; from: ClassicState; to: ClassicState; diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 9be6bf4c..1cbf7fb0 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2358,6 +2358,80 @@ describe('comet scripts', () => { }); }); + describe('state rebind', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + function boundYaml(bound: string | null): string { + return [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${bound ?? 'null'}`, + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'); + } + + it('rebinds to the current branch, passes a later check, and records an audit event', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-ok', boundYaml('feature-A')); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-ok']); + const bound = runNode(tmpDir, stateScript, ['get', 'rebind-ok', 'bound_branch']); + const check = runNode(tmpDir, stateScript, ['check', 'rebind-ok', 'verify']); + + expect(rebind.status).toBe(0); + expect(bound.stdout.trim()).toBe('feature-B'); + expect(check.status).toBe(0); + + const log = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'rebind-ok', '.comet', 'state-events.jsonl'), + 'utf8', + ); + const record = JSON.parse(log.trim().split('\n').at(-1)!); + expect(record.event).toBe('rebind'); + expect(record.effects).toContainEqual( + expect.objectContaining({ field: 'boundBranch', from: 'feature-A', to: 'feature-B' }), + ); + }); + + it('refuses to rebind a change that has no bound_branch', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-unbound', boundYaml(null)); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-unbound']); + + expect(rebind.status).not.toBe(0); + expect(rebind.stderr).toContain('not yet bound'); + }); + + it('refuses to rebind while HEAD is detached', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-detached', boundYaml('feature-A')); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-detached']); + + expect(rebind.status).not.toBe(0); + expect(rebind.stderr).toContain('HEAD is detached'); + }); + }); + it('blocks build completion until tdd_mode is selected for full workflow', async () => { await createChange( tmpDir, From 4100b6bdb0c0126908d99b0bc84362142446a227 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:39:04 +0800 Subject: [PATCH 20/25] feat: mirror bound_branch drift check in phase guard Co-Authored-By: Claude Sonnet 5 --- assets/skills/comet/scripts/comet-runtime.mjs | 283 ++++++++++-------- domains/comet-classic/classic-guard.ts | 37 ++- .../comet-classic/comet-scripts.test.ts | 46 ++- 3 files changed, 231 insertions(+), 135 deletions(-) diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 2d5c6cc6..925f47c1 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -9895,11 +9895,11 @@ var classicArchiveCommand = async (args) => { }; // domains/comet-classic/classic-guard.ts -var import_yaml5 = __toESM(require_dist(), 1); +var import_yaml6 = __toESM(require_dist(), 1); import { spawnSync as spawnSync2 } from "child_process"; import { createHash as createHash4 } from "crypto"; -import { existsSync, promises as fs15, readFileSync } from "fs"; -import path16 from "path"; +import { existsSync, promises as fs16, readFileSync } from "fs"; +import path17 from "path"; // domains/comet-classic/classic-command-checks.ts import path13 from "path"; @@ -10078,10 +10078,67 @@ async function inspectClassicChange(changeDir, name) { } } -// domains/comet-classic/classic-validate-command.ts +// domains/comet-classic/classic-branch-binding.ts var import_yaml3 = __toESM(require_dist(), 1); +import { execFileSync } from "child_process"; +import { randomUUID as randomUUID6 } from "crypto"; import { promises as fs12 } from "fs"; import path14 from "path"; +function liveGitBranch(cwd) { + try { + const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + return branch && branch !== "HEAD" ? branch : null; + } catch { + return null; + } +} +function evaluateBranchBinding(input) { + if (input.isolation !== "current") return { status: "not-applicable" }; + if (input.boundBranch === null) { + return input.currentBranch === null ? { status: "unbound-detached" } : { status: "needs-heal", branch: input.currentBranch }; + } + if (input.currentBranch === input.boundBranch) return { status: "ok" }; + return { + status: "drift", + boundBranch: input.boundBranch, + currentBranch: input.currentBranch + }; +} +async function healBoundBranch(changeDir, branch) { + const file = path14.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml3.parseDocument)(await fs12.readFile(file, "utf8"), { uniqueKeys: false }); + document.set("bound_branch", branch); + const temporary = `${file}.${randomUUID6()}.tmp`; + try { + await fs12.writeFile(temporary, document.toString(), "utf8"); + await fs12.rename(temporary, file); + } catch (error) { + await fs12.rm(temporary, { force: true }); + throw error; + } +} +function branchLabel(currentBranch) { + return currentBranch ?? "detached HEAD"; +} +function driftBlockedMessage(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'. +Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.`; +} +function driftStaleReason(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; +} +function unboundDetachedMessage(change) { + return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} + +// domains/comet-classic/classic-validate-command.ts +var import_yaml4 = __toESM(require_dist(), 1); +import { promises as fs13 } from "fs"; +import path15 from "path"; var GREEN2 = "\x1B[32m"; var RED2 = "\x1B[31m"; var YELLOW2 = "\x1B[33m"; @@ -10132,7 +10189,7 @@ function color(code, message) { } async function exists3(file) { try { - await fs12.access(file); + await fs13.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10153,7 +10210,7 @@ var classicValidateCommand = async (args) => { }; } const { directory, label } = await resolveClassicChangeDirectory(name); - const yamlFile = path14.join(directory, ".comet.yaml"); + const yamlFile = path15.join(directory, ".comet.yaml"); const lines = [`[VALIDATE] ${label}/.comet.yaml`]; let errors = 0; let warnings = 0; @@ -10167,7 +10224,7 @@ var classicValidateCommand = async (args) => { }; let source; try { - source = await fs12.readFile(yamlFile, "utf8"); + source = await fs13.readFile(yamlFile, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail3(".comet.yaml does not exist"); @@ -10176,10 +10233,10 @@ var classicValidateCommand = async (args) => { } throw error; } - const document = (0, import_yaml3.parseDocument)(source); - if (document.errors.length > 0 || !(0, import_yaml3.isMap)(document.contents)) { + const document = (0, import_yaml4.parseDocument)(source); + if (document.errors.length > 0 || !(0, import_yaml4.isMap)(document.contents)) { for (const error of document.errors) fail3(error.message); - if (!(0, import_yaml3.isMap)(document.contents)) fail3("document root must be a mapping"); + if (!(0, import_yaml4.isMap)(document.contents)) fail3("document root must be a mapping"); lines.push("", color(RED2, `${errors} error(s), ${warnings} warning(s) — validation FAILED`)); return { exitCode: 1, stderr: lines.join("\n") }; } @@ -10204,7 +10261,7 @@ var classicValidateCommand = async (args) => { } for (const field2 of ["design_doc", "plan", "handoff_context"]) { const value = text(record[field2]); - if (value && !await exists3(path14.resolve(value))) { + if (value && !await exists3(path15.resolve(value))) { fail3(`${field2}='${value}' does not exist on disk`); } } @@ -10227,16 +10284,16 @@ var classicValidateCommand = async (args) => { }; // domains/comet-classic/classic-project-config.ts -var import_yaml4 = __toESM(require_dist(), 1); +var import_yaml5 = __toESM(require_dist(), 1); import os from "os"; -import { promises as fs14 } from "fs"; -import path15 from "path"; +import { promises as fs15 } from "fs"; +import path16 from "path"; // platform/fs/file-system.ts -import { promises as fs13 } from "fs"; +import { promises as fs14 } from "fs"; async function fileExists3(filePath) { try { - await fs13.access(filePath); + await fs14.access(filePath); return true; } catch { return false; @@ -10244,7 +10301,7 @@ async function fileExists3(filePath) { } async function readDir(dirPath) { try { - return await fs13.readdir(dirPath); + return await fs14.readdir(dirPath); } catch (error) { const code = error?.code; if (code === "ENOENT" || code === "ENOTDIR") { @@ -10259,9 +10316,9 @@ function configCandidates(options = {}) { const cwd = options.cwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); const candidates = [ - { file: path15.resolve(cwd, ".comet", "config.yaml"), source: ".comet/config.yaml" }, + { file: path16.resolve(cwd, ".comet", "config.yaml"), source: ".comet/config.yaml" }, { - file: path15.resolve(homeDir, ".comet", "config.yaml"), + file: path16.resolve(homeDir, ".comet", "config.yaml"), source: "~/.comet/config.yaml" } ]; @@ -10272,7 +10329,7 @@ function configCandidates(options = {}) { async function readClassicConfigValue(field2, options = {}) { for (const candidate of configCandidates(options)) { if (!await fileExists3(candidate.file)) continue; - const document = (0, import_yaml4.parseDocument)(await fs14.readFile(candidate.file, "utf8"), { + const document = (0, import_yaml5.parseDocument)(await fs15.readFile(candidate.file, "utf8"), { uniqueKeys: false }); const value = document.get(field2); @@ -10346,7 +10403,7 @@ var GuardOutput = class { }; async function exists4(file) { try { - await fs15.access(file); + await fs16.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10355,7 +10412,7 @@ async function exists4(file) { } async function nonempty(file) { try { - return (await fs15.stat(file)).size > 0; + return (await fs16.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -10369,8 +10426,8 @@ async function resolveChangeDir(name) { return (await resolveClassicChangeDirectory(name)).label; } async function readField(changeDir, field2) { - const file = path16.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml5.parseDocument)(await fs15.readFile(file, "utf8"), { uniqueKeys: false }); + const file = path17.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml6.parseDocument)(await fs16.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new GuardFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -10411,7 +10468,7 @@ function countEnglishWords(source) { } async function documentLanguageMatchesConfigured(changeDir, file) { const language = await configuredLanguage(changeDir); - const source = stripFencedCodeBlocks(await fs15.readFile(file, "utf8")); + const source = stripFencedCodeBlocks(await fs16.readFile(file, "utf8")); const cjk = countCjkChars(source); const englishWords = countEnglishWords(source); if (language === "zh-CN" && cjk < 20 && englishWords >= 20) { @@ -10435,7 +10492,7 @@ async function handoffSourceFiles(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists4(specs)) { - for (const entry2 of (await fs15.readdir(specs)).sort()) { + for (const entry2 of (await fs16.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists4(spec)) files.push(spec); } @@ -10455,7 +10512,7 @@ async function preflight(changeDir, name) { if (!await exists4(changeDir)) { throw new GuardFailure(red2(`FATAL: change directory not found: ${changeDir}`)); } - if (!await exists4(path16.join(changeDir, ".comet.yaml"))) { + if (!await exists4(path17.join(changeDir, ".comet.yaml"))) { throw new GuardFailure(red2(`FATAL: .comet.yaml not found in ${changeDir}`)); } const result5 = await classicValidateCommand([name], { json: false }); @@ -10525,9 +10582,9 @@ var INFERRED_COMMAND_SOURCES = [ "Cargo.toml" ]; async function removedProjectCommandField(field2) { - const config = path16.join(".comet", "config.yaml"); + const config = path17.join(".comet", "config.yaml"); if (!await exists4(config)) return false; - const document = (0, import_yaml5.parseDocument)(await fs15.readFile(config, "utf8")); + const document = (0, import_yaml6.parseDocument)(await fs16.readFile(config, "utf8")); if (document.errors.length > 0) { throw new Error( `.comet/config.yaml is invalid YAML (${document.errors[0].message}); cannot check for removed "${field2}" field. Fix the config and retry.` @@ -10608,14 +10665,14 @@ ${recoveryCommand(change, scope, recorded.command)}` return { status: 0, output: evidenceDetail(recorded) }; } async function tasksAllDone(changeDir) { - const tasks = path16.join(changeDir, "tasks.md"); + const tasks = path17.join(changeDir, "tasks.md"); if (!await exists4(tasks)) { return fail( `tasks.md is missing at ${tasks} Next: restore or create tasks.md for this change before leaving build.` ); } - const source = await fs15.readFile(tasks, "utf8"); + const source = await fs16.readFile(tasks, "utf8"); if (!/- \[x\]/u.test(source)) { return fail( "tasks.md has no completed tasks.\nNext: complete implementation tasks and mark them with '- [x]'." @@ -10632,9 +10689,9 @@ Next: complete or explicitly remove unfinished tasks, then mark tasks.md with '- return pass(); } async function tasksHasAny(changeDir) { - const tasks = path16.join(changeDir, "tasks.md"); + const tasks = path17.join(changeDir, "tasks.md"); if (!await exists4(tasks)) return false; - return /- \[/u.test(await fs15.readFile(tasks, "utf8")); + return /- \[/u.test(await fs16.readFile(tasks, "utf8")); } async function planTasksAllDone(changeDir) { const plan = await readField(changeDir, "plan"); @@ -10645,7 +10702,7 @@ async function planTasksAllDone(changeDir) { Next: restore the Superpowers plan file or update .comet.yaml plan before leaving build.` ); } - const source = await fs15.readFile(plan, "utf8"); + const source = await fs16.readFile(plan, "utf8"); const unfinished = source.split(/\r?\n/u).map((line, index) => ({ line, number: index + 1 })).filter((entry2) => /^\s*- \[ \]/u.test(entry2.line)); if (unfinished.length > 0) { return fail( @@ -10676,6 +10733,24 @@ Next: ask the user to choose ${PRESET_ALLOWED_ISOLATIONS.join(" or ")}, then run node "$COMET_STATE" set ${change} isolation <${PRESET_ALLOWED_ISOLATIONS.join("|")}>` ); } +async function boundBranchMatches(changeDir, change) { + const isolation = await readField(changeDir, "isolation"); + const boundBranch = await readField(changeDir, "bound_branch"); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== "null" ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()) + }); + if (verdict.status === "drift") { + return fail(driftBlockedMessage(change, verdict.boundBranch, verdict.currentBranch)); + } + if (verdict.status === "unbound-detached") return fail(unboundDetachedMessage(change)); + if (verdict.status === "needs-heal") { + await healBoundBranch(changeDir, verdict.branch); + return pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } + return pass(); +} async function buildModeSelected(changeDir, change) { const buildMode = await readField(changeDir, "build_mode"); if (["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) @@ -10745,7 +10820,7 @@ async function archivedIsTrue(changeDir) { return await readField(changeDir, "archived") === "true"; } async function designDocFrontmatterHas(designDoc, field2, expected) { - const source = (await fs15.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); + const source = (await fs16.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); let inFrontmatter = false; for (const line of source.split(/\r?\n/u)) { if (!inFrontmatter) { @@ -10810,7 +10885,7 @@ async function designHandoffMarkdownTraceable(changeDir) { const markdown = `${context.replace(/\.json$/u, "")}.md`; if (!await nonempty(markdown)) return fail(`design handoff markdown is missing or empty: ${markdown}`); - const source = await fs15.readFile(markdown, "utf8"); + const source = await fs16.readFile(markdown, "utf8"); const problems = []; if (!/^Generated-by: comet-handoff\.sh$/mu.test(source)) { problems.push("handoff markdown is missing Generated-by marker"); @@ -10837,7 +10912,7 @@ async function betaSpecJsonStructurallyValid(changeDir) { const context = await readField(changeDir, "handoff_context"); if (!context || context === "null") return fail("handoff_context is missing from .comet.yaml"); if (!await nonempty(context)) return fail(`spec-context.json is missing or empty: ${context}`); - const source = await fs15.readFile(context, "utf8"); + const source = await fs16.readFile(context, "utf8"); const problems = []; let parsed; try { @@ -10874,19 +10949,19 @@ async function guardOpenChecks(output, changeDir) { const checks = [ check( "proposal.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check( "tasks.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "tasks.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "tasks.md")) ? pass() : fail("") ), check( "tasks.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "tasks.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "tasks.md")) ), check( "tasks.md has at least one task", @@ -10899,11 +10974,11 @@ async function guardOpenChecks(output, changeDir) { 0, check( "design.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check( "design.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "design.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "design.md")) ) ); } @@ -10915,27 +10990,27 @@ async function guardDesignChecks(output, changeDir, change) { const builders = [ check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check( "design.md exists", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check( "design.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "design.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "design.md")) ), check( "tasks.md exists", - async () => await nonempty(path16.join(changeDir, "tasks.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "tasks.md")) ? pass() : fail("") ), check( "tasks.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "tasks.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "tasks.md")) ), check("design handoff context exists", () => designHandoffContextValid(changeDir, change)), check("design handoff markdown is traceable", () => designHandoffMarkdownTraceable(changeDir)) @@ -10986,6 +11061,7 @@ async function guardDesignChecks(output, changeDir, change) { } async function guardBuildChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("isolation selected", () => isolationSelected(changeDir, change)), check("isolation allowed for workflow", () => isolationAllowedForWorkflow(changeDir, change)), check("build_mode selected", () => buildModeSelected(changeDir, change)), @@ -10997,11 +11073,11 @@ async function guardBuildChecks(output, changeDir, change, run) { check("Superpowers plan all tasks checked", () => planTasksAllDone(changeDir)), check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check("Superpowers plan matches configured language", async () => { const plan = await readField(changeDir, "plan"); @@ -11018,6 +11094,7 @@ async function guardBuildChecks(output, changeDir, change, run) { } async function guardVerifyChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("tasks.md all tasks checked", () => tasksAllDone(changeDir)), // Verification command runs after tasks check — no point running tests // if tasks.md is incomplete. @@ -11040,16 +11117,17 @@ async function guardVerifyChecks(output, changeDir, change, run) { ) ]); } -async function guardArchiveChecks(output, changeDir) { +async function guardArchiveChecks(output, changeDir, change) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("archived is true", async () => await archivedIsTrue(changeDir) ? pass() : fail("")), check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "design.md exists", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check("tasks.md all tasks checked", () => tasksAllDone(changeDir)) ]); @@ -11110,7 +11188,7 @@ Valid phases: open, design, build, verify, archive` blocked2 = await guardBuildChecks(output, changeDir, change, runContext.run); else if (phase === "verify") blocked2 = await guardVerifyChecks(output, changeDir, change, runContext.run); - else blocked2 = await guardArchiveChecks(output, changeDir); + else blocked2 = await guardArchiveChecks(output, changeDir, change); if (blocked2) { output.stderr.push(""); output.stderr.push(red2("BLOCKED — fix failing checks before proceeding to next phase")); @@ -11132,10 +11210,10 @@ Valid phases: open, design, build, verify, archive` }; // domains/comet-classic/classic-handoff.ts -var import_yaml6 = __toESM(require_dist(), 1); +var import_yaml7 = __toESM(require_dist(), 1); import { createHash as createHash5 } from "crypto"; -import { promises as fs16, readFileSync as readFileSync2 } from "fs"; -import path17 from "path"; +import { promises as fs17, readFileSync as readFileSync2 } from "fs"; +import path18 from "path"; var GREEN4 = "\x1B[32m"; var RED4 = "\x1B[31m"; var YELLOW4 = "\x1B[33m"; @@ -11169,7 +11247,7 @@ var HandoffOutput = class { }; async function exists5(file) { try { - await fs16.access(file); + await fs17.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -11178,7 +11256,7 @@ async function exists5(file) { } async function nonempty2(file) { try { - return (await fs16.stat(file)).size > 0; + return (await fs17.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -11207,7 +11285,7 @@ async function handoffSourceFiles2(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists5(specs)) { - for (const entry2 of (await fs16.readdir(specs)).sort()) { + for (const entry2 of (await fs17.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists5(spec)) files.push(spec); } @@ -11255,7 +11333,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output ]; for (const file of await handoffSourceFiles2(changeDir)) { if (!await exists5(file)) continue; - const content = await fs16.readFile(file, "utf8"); + const content = await fs17.readFile(file, "utf8"); const total = lineCount(content); lines.push( `## ${file}`, @@ -11280,7 +11358,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output } lines.push(""); } - await fs16.writeFile(output, lines.join("\n")); + await fs17.writeFile(output, lines.join("\n")); } async function writeJsonContext(changeDir, change, mode, contextHash, output) { const entries = []; @@ -11303,7 +11381,7 @@ async function writeJsonContext(changeDir, change, mode, contextHash, output) { "}", "" ].join("\n"); - await fs16.writeFile(output, document); + await fs17.writeFile(output, document); } async function writeSpecProjectionForFile(file, content) { return [ @@ -11343,11 +11421,11 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) const specs = `${changeDir}/specs`; let projected = false; if (await exists5(specs)) { - for (const entry2 of (await fs16.readdir(specs)).sort()) { + for (const entry2 of (await fs17.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (!await exists5(spec)) continue; projected = true; - lines.push(...await writeSpecProjectionForFile(spec, await fs16.readFile(spec, "utf8"))); + lines.push(...await writeSpecProjectionForFile(spec, await fs17.readFile(spec, "utf8"))); } } if (!projected) { @@ -11356,7 +11434,7 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) lines.push( "Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only." ); - await fs16.writeFile(output, lines.join("\n")); + await fs17.writeFile(output, lines.join("\n")); } async function writeSpecJsonContext(changeDir, change, contextHash, output) { const entries = []; @@ -11365,7 +11443,7 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { const role = /\/specs\/[^/]+\/spec\.md$/u.test(file) ? "spec" : "supporting"; entries.push({ path: file, sha256: hashFile2(file), role }); } - await fs16.writeFile( + await fs17.writeFile( output, `${JSON.stringify( { @@ -11384,8 +11462,8 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { ); } async function readField2(changeDir, field2) { - const file = path17.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml6.parseDocument)(await fs16.readFile(file, "utf8"), { uniqueKeys: false }); + const file = path18.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml7.parseDocument)(await fs17.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new HandoffFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -11419,7 +11497,7 @@ async function completedHandoffIsCurrent(changeDir, run, contextHash, contextJso readCheckpoint(changeDir, run.checkpointRef) ]); if (!await exists5(contextJson) || !await exists5(contextMd)) return false; - if (context !== await fs16.readFile(contextMd, "utf8")) return false; + if (context !== await fs17.readFile(contextMd, "utf8")) return false; if (artifacts.handoff_context !== contextJson || artifacts.handoff_markdown !== contextMd) { return false; } @@ -11542,7 +11620,7 @@ var classicHandoffCommand = async (args) => { run: pendingRun, unknownKeys: (await readClassicState(changeDir)).unknownKeys }); - await fs16.mkdir(handoffDir, { recursive: true }); + await fs17.mkdir(handoffDir, { recursive: true }); if (handoffMode === "beta") { await writeSpecMarkdownContext(changeDir, change, contextHash, contextMd); await writeSpecJsonContext(changeDir, change, contextHash, contextJson); @@ -11550,7 +11628,7 @@ var classicHandoffCommand = async (args) => { await writeMarkdownContext(changeDir, change, handoffMode, contextHash, contextMd); await writeJsonContext(changeDir, change, handoffMode, contextHash, contextJson); } - const context = await fs16.readFile(contextMd, "utf8"); + const context = await fs17.readFile(contextMd, "utf8"); await writeContext(changeDir, pendingRun.contextRef, context); const artifacts = { ...await readArtifacts(changeDir, pendingRun.artifactsRef), @@ -11614,65 +11692,6 @@ import path20 from "path"; import { randomUUID as randomUUID7 } from "crypto"; import { promises as fs18 } from "fs"; import path19 from "path"; - -// domains/comet-classic/classic-branch-binding.ts -var import_yaml7 = __toESM(require_dist(), 1); -import { execFileSync } from "child_process"; -import { randomUUID as randomUUID6 } from "crypto"; -import { promises as fs17 } from "fs"; -import path18 from "path"; -function liveGitBranch(cwd) { - try { - const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }).trim(); - return branch && branch !== "HEAD" ? branch : null; - } catch { - return null; - } -} -function evaluateBranchBinding(input) { - if (input.isolation !== "current") return { status: "not-applicable" }; - if (input.boundBranch === null) { - return input.currentBranch === null ? { status: "unbound-detached" } : { status: "needs-heal", branch: input.currentBranch }; - } - if (input.currentBranch === input.boundBranch) return { status: "ok" }; - return { - status: "drift", - boundBranch: input.boundBranch, - currentBranch: input.currentBranch - }; -} -async function healBoundBranch(changeDir, branch) { - const file = path18.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml7.parseDocument)(await fs17.readFile(file, "utf8"), { uniqueKeys: false }); - document.set("bound_branch", branch); - const temporary = `${file}.${randomUUID6()}.tmp`; - try { - await fs17.writeFile(temporary, document.toString(), "utf8"); - await fs17.rename(temporary, file); - } catch (error) { - await fs17.rm(temporary, { force: true }); - throw error; - } -} -function branchLabel(currentBranch) { - return currentBranch ?? "detached HEAD"; -} -function driftBlockedMessage(change, boundBranch, currentBranch) { - return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'. -Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.`; -} -function driftStaleReason(change, boundBranch, currentBranch) { - return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; -} -function unboundDetachedMessage(change) { - return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; -} - -// domains/comet-classic/classic-current-change.ts function currentChangeFile(projectRoot2) { return path19.join(projectRoot2, ".comet", "current-change.json"); } diff --git a/domains/comet-classic/classic-guard.ts b/domains/comet-classic/classic-guard.ts index 1402b478..8187423c 100644 --- a/domains/comet-classic/classic-guard.ts +++ b/domains/comet-classic/classic-guard.ts @@ -13,6 +13,13 @@ import { inspectClassicChange } from './classic-diagnostics.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; import { ensureClassicRuntimeRun, transitionClassicRuntimeRun } from './classic-runtime-run.js'; import type { ClassicRunContext } from './classic-migrate.js'; +import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { ISOLATIONS, PRESET_ALLOWED_ISOLATIONS, @@ -511,6 +518,25 @@ async function isolationAllowedForWorkflow( ); } +async function boundBranchMatches(changeDir: string, change: string): Promise { + const isolation = await readField(changeDir, 'isolation'); + const boundBranch = await readField(changeDir, 'bound_branch'); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== 'null' ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()), + }); + if (verdict.status === 'drift') { + return fail(driftBlockedMessage(change, verdict.boundBranch, verdict.currentBranch)); + } + if (verdict.status === 'unbound-detached') return fail(unboundDetachedMessage(change)); + if (verdict.status === 'needs-heal') { + await healBoundBranch(changeDir, verdict.branch); + return pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } + return pass(); +} + async function buildModeSelected(changeDir: string, change: string): Promise { const buildMode = await readField(changeDir, 'build_mode'); if (['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) @@ -826,6 +852,7 @@ async function guardBuildChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('isolation selected', () => isolationSelected(changeDir, change)), check('isolation allowed for workflow', () => isolationAllowedForWorkflow(changeDir, change)), check('build_mode selected', () => buildModeSelected(changeDir, change)), @@ -862,6 +889,7 @@ async function guardVerifyChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('tasks.md all tasks checked', () => tasksAllDone(changeDir)), // Verification command runs after tasks check — no point running tests // if tasks.md is incomplete. @@ -883,8 +911,13 @@ async function guardVerifyChecks( ]); } -async function guardArchiveChecks(output: GuardOutput, changeDir: string): Promise { +async function guardArchiveChecks( + output: GuardOutput, + changeDir: string, + change: string, +): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('archived is true', async () => ((await archivedIsTrue(changeDir)) ? pass() : fail(''))), check('proposal.md exists', async () => (await nonempty(path.join(changeDir, 'proposal.md'))) ? pass() : fail(''), @@ -962,7 +995,7 @@ export const classicGuardCommand: ClassicCommandHandler = async (args, options) blocked = await guardBuildChecks(output, changeDir, change, runContext.run); else if (phase === 'verify') blocked = await guardVerifyChecks(output, changeDir, change, runContext.run); - else blocked = await guardArchiveChecks(output, changeDir); + else blocked = await guardArchiveChecks(output, changeDir, change); if (blocked) { output.stderr.push(''); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index 1cbf7fb0..f610eba5 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -2124,6 +2124,9 @@ describe('comet scripts', () => { }, 20_000); it('allows full workflow build completion with isolation=current', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); await createChange( tmpDir, 'current-isolation-build', @@ -2136,6 +2139,7 @@ describe('comet scripts', () => { 'tdd_mode: tdd', 'review_mode: standard', 'isolation: current', + 'bound_branch: feature-A', 'verify_mode: full', 'design_doc: null', 'plan: null', @@ -2150,6 +2154,8 @@ describe('comet scripts', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ scripts: { build: 'node -e "process.exit(0)"' } }), ); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); const guard = runNode(tmpDir, guardScript, ['current-isolation-build', 'build']); const transition = runNode(tmpDir, stateScript, [ @@ -2422,7 +2428,10 @@ describe('comet scripts', () => { it('refuses to rebind while HEAD is detached', async () => { await gitInit('feature-A'); await createChange(tmpDir, 'rebind-detached', boundYaml('feature-A')); - const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-detached']); @@ -3267,6 +3276,41 @@ describe('comet scripts', () => { ); }, 20_000); + it('guard blocks archive when a current-isolation change drifted off its bound branch', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await createChange( + tmpDir, + 'archive-drift', + [ + 'workflow: full', + 'phase: archive', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pass', + 'verified_at: 2026-07-16', + 'archived: true', + '', + ].join('\n'), + ); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const guard = runNode(tmpDir, guardScript, ['archive-drift', 'archive']); + + expect(guard.status).not.toBe(0); + expect(guard.stderr).toContain('BLOCKED'); + expect(guard.stderr).toContain( + "change 'archive-drift' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); + it('accepts bound_branch as a known optional field in validation', async () => { await createChange( tmpDir, From 7566093dc4a40553c9224d606d44a55eb66a757b Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:44:52 +0800 Subject: [PATCH 21/25] test: reconcile existing fixtures with bound_branch drift checks --- test/domains/comet-classic/classic-contract.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index 3b04eec4..c6c8598f 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -209,6 +209,9 @@ function legacyProjection(document: Record): Record !runKeys.has(key))); } From b8e8d19a070b8614ef4bc44296ee72ac6f328a8c Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:48:13 +0800 Subject: [PATCH 22/25] docs: add current-isolation drift decision point to Chinese skills --- assets/skills-zh/comet-archive/SKILL.md | 2 ++ assets/skills-zh/comet-build/SKILL.md | 2 ++ assets/skills-zh/comet-hotfix/SKILL.md | 2 ++ assets/skills-zh/comet-tweak/SKILL.md | 2 ++ assets/skills-zh/comet-verify/SKILL.md | 2 ++ 5 files changed, 10 insertions(+) diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index eeab1d18..941f3e88 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -26,6 +26,8 @@ comet state select comet state check archive ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 ### 1. 归档前最终确认(阻塞点) diff --git a/assets/skills-zh/comet-build/SKILL.md b/assets/skills-zh/comet-build/SKILL.md index 2a3f6929..63b55b33 100644 --- a/assets/skills-zh/comet-build/SKILL.md +++ b/assets/skills-zh/comet-build/SKILL.md @@ -21,6 +21,8 @@ comet state select comet state check build ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 **幂等性**:build 阶段所有操作可安全重复执行。读取 `.comet.yaml` 的 `phase` 字段确认仍在 build 阶段,读取 plan 文件头的 `base-ref`,再用 `grep -n '\- \[ \]' tasks.md | head -1` 找到第一个未勾选任务继续执行。已提交的任务不得重复提交。 diff --git a/assets/skills-zh/comet-hotfix/SKILL.md b/assets/skills-zh/comet-hotfix/SKILL.md index a9a791a7..2f4924a9 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -28,6 +28,8 @@ description: "Use when 用户要修复已有行为 bug,且不新增 capability 恢复已有 hotfix change 时,第一项状态操作必须是 `comet state select `;创建新 change 时,在 `.comet.yaml` 初始化成功后立即运行该命令,再进入源码写入步骤。 +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + ### 1. 快速开启(预设 open) 复用 Comet open 能力创建 change,但使用 hotfix 默认值:不执行 `openspec-explore` 长探索,直接进入精简 change 创建。 diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 478bac61..2c9957b7 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -31,6 +31,8 @@ Tweak 是 Comet 五阶段能力的预设工作流,不是独立的平行流程 恢复已有 tweak change 时,第一项状态操作必须是 `comet state select `;创建新 change 时,在 `.comet.yaml` 初始化成功后立即运行该命令,再进入源码写入步骤。 +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + ### 1. 快速开启(预设 open) 复用 Comet open 能力创建 change,但使用 tweak 默认值:不执行 `openspec-explore` 长探索,直接进入精简 change 创建。 diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 2569b53c..ffbe3445 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -25,6 +25,8 @@ comet state select comet state check verify ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 **幂等性**:verify 阶段所有检查可安全重复执行。如 `verify_result` 已为 `pass` 且 `branch_status` 已为 `handled`,说明验证已完成,直接执行 guard 流转。如 `verify_result` 为 `pending`,从头开始验证。 From 7b5219796f13609997f09766a772f51ea8721c67 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 07:50:45 +0800 Subject: [PATCH 23/25] docs: record bound_branch drift detection in changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b89044c..5eb7741d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ All notable changes to @rpamis/comet will be documented in this file. ## What's Changed [0.4.0-beta.6] - 2026-07-16 +### Added + +- **`comet state rebind`**: new command to explicitly re-bind an `isolation: current` change to the current branch after user confirmation, recording an audit event; refuses to run while HEAD is detached or before an initial binding exists. + ### Fixed - **Preset isolation**: hotfix and tweak workflows can again select `isolation: worktree`; a prior change had incorrectly restricted preset workflows to `branch` and `current` only. +- **Current-branch isolation drift detection**: `isolation: current` changes now persist their bound branch in the committed change state instead of a local sidecar, so switching branches mid-change is reliably detected at every build/verify/archive entry check and by the write guard, and is no longer silently reset by re-selecting the current change. Establishing `isolation: current` while on a detached HEAD is now rejected. ## What's Changed [0.4.0-beta.5] - 2026-07-13 From 69001fbca61a229d9ad77e7151b6891b239ffefd Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 12:52:13 +0800 Subject: [PATCH 24/25] docs: sync current-isolation drift decision point to English skills Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GhNAjfxS913K3JYqFaPABo --- assets/skills/comet-archive/SKILL.md | 2 ++ assets/skills/comet-build/SKILL.md | 2 ++ assets/skills/comet-hotfix/SKILL.md | 2 ++ assets/skills/comet-tweak/SKILL.md | 2 ++ assets/skills/comet-verify/SKILL.md | 2 ++ 5 files changed, 10 insertions(+) diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 848fc5e4..338944eb 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -26,6 +26,8 @@ comet state select comet state check archive ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. ### 1. Final Archive Confirmation (Blocking Point) diff --git a/assets/skills/comet-build/SKILL.md b/assets/skills/comet-build/SKILL.md index bae9a990..62ffd68f 100644 --- a/assets/skills/comet-build/SKILL.md +++ b/assets/skills/comet-build/SKILL.md @@ -21,6 +21,8 @@ comet state select comet state check build ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + 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` 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. diff --git a/assets/skills/comet-hotfix/SKILL.md b/assets/skills/comet-hotfix/SKILL.md index 9a5d9513..d36b6e61 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -28,6 +28,8 @@ Before starting, locate Comet scripts via `comet/reference/scripts.md`. When res When resuming an existing hotfix change, the first state operation must be `comet state select `. For a new change, run the command immediately after `.comet.yaml` initialization and before source writes. +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + ### 1. Quick Open (preset open) Reuse Comet open capability to create change, but use hotfix defaults: do not execute `openspec-explore` long exploration, directly enter streamlined change creation. diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 0d5ea63e..6ead6885 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -31,6 +31,8 @@ Before starting, locate Comet scripts via `comet/reference/scripts.md`. When res When resuming an existing tweak change, the first state operation must be `comet state select `. For a new change, run the command immediately after `.comet.yaml` initialization and before source writes. +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + ### 1. Quick Open (preset open) Reuse Comet open capability to create change, but use tweak defaults: do not execute `openspec-explore` long exploration, directly enter streamlined change creation. diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index c97c5d53..378ef898 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -25,6 +25,8 @@ comet state select comet state check verify ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. **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. From a13aa76924536041c6e1f2a6b087fcd5542faa53 Mon Sep 17 00:00:00 2001 From: frizz19 Date: Fri, 17 Jul 2026 14:02:16 +0800 Subject: [PATCH 25/25] docs: restore preset worktree isolation docs --- assets/skills-zh/comet-archive/SKILL.md | 2 +- assets/skills-zh/comet-hotfix/SKILL.md | 11 ++++++----- assets/skills-zh/comet-tweak/SKILL.md | 9 +++++---- assets/skills-zh/comet/reference/comet-yaml-fields.md | 2 +- assets/skills/comet-archive/SKILL.md | 2 +- assets/skills/comet-hotfix/SKILL.md | 11 ++++++----- assets/skills/comet-tweak/SKILL.md | 9 +++++---- assets/skills/comet/reference/comet-yaml-fields.md | 2 +- test/domains/skill/skills.test.ts | 4 ++-- 9 files changed, 28 insertions(+), 24 deletions(-) diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 9844870e..41702efc 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -122,8 +122,8 @@ git commit -m "chore: archive " 只有用户选择的操作成功完成(或明确选择保持分支)后,才运行: ```bash -comet state set branch_status handled comet state set branch_action +comet state set branch_status handled comet guard archive comet state clear-selection ``` diff --git a/assets/skills-zh/comet-hotfix/SKILL.md b/assets/skills-zh/comet-hotfix/SKILL.md index 43403d4f..1a73cc33 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -44,7 +44,7 @@ comet state select comet state check open ``` -hotfix 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能把它改为 `branch` 或 `worktree`。随后按指引创建精简版产物: +hotfix 初始保持 `isolation: null`。在 Step 2 明确选择隔离方式前,不要假定工作区。随后按指引创建精简版产物: - `proposal.md` — 问题描述 + 根因分析 + 修复目标(无需方案对比) - `design.md` — 修复方案(1 个即可,无需多方案对比) - `tasks.md` — 修复任务清单 @@ -70,15 +70,16 @@ comet state next 开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: - A. 创建分支(推荐默认) -- B. 使用当前分支 +- B. 使用 worktree +- C. 使用当前分支 用户选择后立即写入: ```bash -comet state set isolation +comet state set isolation ``` -若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change;若选择 `current`,不创建新分支,也不要因为没有切换工作区而额外重新选择。 +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change。若选择 `worktree`,按 `/comet-build` 中的 worktree 规则创建隔离工作区,并在 worktree 内重新执行 `comet state select `。若选择 `current`,不创建新分支或 worktree,也不要因为没有切换工作区而额外重新选择。 使用 hotfix 默认值:`build_mode: direct`、`tdd_mode: direct`、`review_mode: off`。`direct` 表示不进入完整规划/TDD 编排,不表示可以跳过复现、回归测试或验证。跳过 Superpowers `brainstorming` 和 `writing-plans`;**任务数量本身不触发 `/comet-build`**,任务较多时仍在当前 hotfix 的 tasks.md 中按顺序执行,只有命中后文质变信号或范围 tripwire 才交给用户决定是否升级 full。 @@ -154,7 +155,7 @@ comet guard build --apply Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,agent 在 hotfix 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 hotfix 流程,还是升级为完整 `/comet` 流程 -2. 初始隔离选择(`branch` / `current`) +2. 初始隔离选择(`branch` / `worktree` / `current`) 3. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 4. 归档前最终确认,以及归档提交后的分支处理决策 diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 6a1e254f..09138dcf 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -71,15 +71,16 @@ comet guard open --apply 开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: - A. 创建分支(推荐默认) -- B. 使用当前分支 +- B. 使用 worktree +- C. 使用当前分支 用户选择后立即写入: ```bash -comet state set isolation +comet state set isolation ``` -若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change;若选择 `current`,不创建新分支,也不要因为没有切换工作区而额外重新选择。 +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change。若选择 `worktree`,按 `/comet-build` 中的 worktree 规则创建隔离工作区,并在 worktree 内重新执行 `comet state select `。若选择 `current`,不创建新分支或 worktree,也不要因为没有切换工作区而额外重新选择。 使用 tweak 默认值:`build_mode: direct`。跳过 Superpowers `brainstorming` 和 `writing-plans`,改由 OpenSpec 的 apply action 执行当前 change 的 tasks。 @@ -149,7 +150,7 @@ comet state set verify_mode full Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent 在 tweak 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 tweak 轻量流程,还是升级为完整 `/comet` 流程 -2. 初始隔离选择(`branch` / `current`) +2. 初始隔离选择(`branch` / `worktree` / `current`) 3. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 4. 归档前最终确认,以及归档提交后的分支处理决策 diff --git a/assets/skills-zh/comet/reference/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index 93e2a509..a1503aaa 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -46,7 +46,7 @@ archived: false | `subagent_dispatch` | `null` 或 `confirmed`。仅当已确认当前平台存在真实后台 subagent / Task / multi-agent 调度能力时,`build_mode: subagent-driven-development` 才能写入并用于离开 build 阶段 | | `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` 或 `current`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 必须显式选择 `branch` 或 `current`,不再默认写入 | +| `isolation` | `branch`、`worktree` 或 `current`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 必须显式选择 `branch`、`worktree` 或 `current`,不再默认写入 | | `verify_mode` | `light` 或 `full`,可为空 | | `auto_transition` | `true` 或 `false`。只控制阶段守卫推进 phase 后是否自动调用下一个 skill;`false` 时由 `comet-state next` 输出 `manual`,暂停下一 skill 调用,但不阻止 phase 字段更新 | | `verify_result` | `pending`、`pass` 或 `fail` | diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 338944eb..8c6fc403 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -122,8 +122,8 @@ After the archive commit succeeds, read the `isolation` field: Only after the selected operation succeeds (or the user explicitly keeps the branch), run: ```bash -comet state set branch_status handled comet state set branch_action +comet state set branch_status handled comet guard archive comet state clear-selection ``` diff --git a/assets/skills/comet-hotfix/SKILL.md b/assets/skills/comet-hotfix/SKILL.md index d36b6e61..a26401dc 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -44,7 +44,7 @@ comet state select 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: +Hotfix starts with `isolation: null`. Do not assume a workspace until the explicit isolation decision in Step 2. 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 @@ -70,15 +70,16 @@ comet state next Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: - A. create a branch (recommended default) -- B. use the current branch +- B. use a worktree +- C. use the current branch After the choice, immediately write: ```bash -comet state set isolation +comet state set isolation ``` -If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change; if `current` is chosen, create no new branch and do not force an extra re-selection when no workspace switch happened. +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change. If `worktree` is chosen, follow `/comet-build`'s worktree rules and re-run `comet state select ` inside the worktree. If `current` is chosen, create no new branch or worktree and do not force an extra re-selection when no workspace switch happened. Use hotfix defaults: `build_mode: direct`, `tdd_mode: direct`, `review_mode: off`. 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. @@ -158,7 +159,7 @@ Exception: when `.comet.yaml` has `auto_transition: false`, end the current invo 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. initial isolation choice (`branch` / `current`) +2. initial isolation choice (`branch` / `worktree` / `current`) 3. 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 4. Final archive confirmation and the branch-handling decision after the archive commit diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 6ead6885..4168ddf8 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -71,15 +71,16 @@ comet guard open --apply Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: - A. create a branch (recommended default) -- B. use the current branch +- B. use a worktree +- C. use the current branch After the choice, immediately write: ```bash -comet state set isolation +comet state set isolation ``` -If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change; if `current` is chosen, create no new branch and do not force an extra re-selection when no workspace switch happened. +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change. If `worktree` is chosen, follow `/comet-build`'s worktree rules and re-run `comet state select ` inside the worktree. If `current` is chosen, create no new branch or worktree and do not force an extra re-selection when no workspace switch happened. Use tweak defaults: `build_mode: direct`. Skip Superpowers `brainstorming` and `writing-plans`, and let OpenSpec's apply action execute the current change's tasks. @@ -152,7 +153,7 @@ Exception: when `.comet.yaml` has `auto_transition: false`, end the current invo 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. initial isolation choice (`branch` / `current`) +2. initial isolation choice (`branch` / `worktree` / `current`) 3. 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 4. Final archive confirmation and the branch-handling decision after the archive commit diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index 915591da..8840963e 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -47,7 +47,7 @@ archived: false | `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 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`, `worktree`, or `current`, workspace isolation mode. Full init may be `null` but only until `/comet-build` Step 3; hotfix/tweak must explicitly choose `branch` or `current` and no longer default it | +| `isolation` | `branch`, `worktree`, or `current`, workspace isolation mode. Full init may be `null` but only until `/comet-build` Step 3; hotfix/tweak must explicitly choose `branch`, `worktree`, or `current` and no longer default it | | `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` | diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index 5f931d89..278344b3 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1489,7 +1489,7 @@ describe('skills', () => { // MEDIUM: hotfix task count alone does not escalate; only qualitative scope signals do. expect(zhHotfix).toContain('任务数量本身不触发 `/comet-build`'); // MEDIUM: hotfix requires an explicit isolation choice as a user decision point. - expect(zhHotfix).toContain('初始隔离选择(`branch` / `current`)'); + expect(zhHotfix).toContain('初始隔离选择(`branch` / `worktree` / `current`)'); // LOW: comet-build "中" level requires user confirmation before brainstorming expect(zhBuild).toContain( @@ -1888,7 +1888,7 @@ describe('skills', () => { ); expect(enHotfix).toContain('6 quick checks'); expect(enHotfix).toContain('task count alone does not route to `/comet-build`'); - expect(enHotfix).toContain('initial isolation choice (`branch` / `current`)'); + expect(enHotfix).toContain('initial isolation choice (`branch` / `worktree` / `current`)'); expect(enBuild).toContain( "Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly confirm", );