initial: pi-setup workspace with skill + extension plan
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: comet
|
||||
description: "Comet — OpenSpec + Superpowers 双星开发流程。用 /comet 启动,自动检测阶段并分发到子命令。五阶段:开启 → 深度设计 → 计划与构建 → 验证与收尾 → 归档。"
|
||||
---
|
||||
|
||||
# Comet — OpenSpec + Superpowers 双星开发流程
|
||||
|
||||
OpenSpec 与 Superpowers 如双星系统围绕同一目标运转。
|
||||
|
||||
```
|
||||
OpenSpec 负责 WHAT — 大纲、提案、spec 生命周期、归档
|
||||
Superpowers 负责 HOW — 技术设计、计划、执行、收尾
|
||||
```
|
||||
|
||||
**核心原则:brainstorming 必不可跳过。每次变更都必须经过深度设计(hotfix 和 tweak preset 除外)。**
|
||||
|
||||
---
|
||||
|
||||
## 决策核心(Decision Core)
|
||||
|
||||
agent 做决策只需读本节,参考附录按需查阅。
|
||||
|
||||
### 阶段自动检测
|
||||
|
||||
**Step 0: 活跃 Change 发现与意图判定**
|
||||
|
||||
1. 先做 Preset 检测;命中 hotfix/tweak 时直接调用对应 preset skill,不进入普通 open 分支
|
||||
2. 未命中 preset 时,运行 `openspec list --json` 获取所有活跃 change
|
||||
|
||||
**Preset 检测优先级最高**:
|
||||
- 用户明确描述为 bug fix / 热修复 + 满足 hotfix 条件 → 直接 `/comet-hotfix`
|
||||
- 用户明确描述为文案/配置/文档/prompt 小调整 + 满足 tweak 条件 → 直接 `/comet-tweak`
|
||||
- 未命中 preset → 按下表处理
|
||||
|
||||
| 活跃 change | 用户输入 | 行为 |
|
||||
|-------------|---------|------|
|
||||
| 无 | 非 preset 输入 | → 调用 `/comet-open` |
|
||||
| 恰好 1 个 | `/comet <描述>` | → **询问**:继续该变更 or 创建新变更 |
|
||||
| 多个 | `/comet <描述>` | → **询问**:继续现有变更 or 创建新变更;若选继续 → 列出清单让用户选择 |
|
||||
| 恰好 1 个 | `/comet`(无描述) | → 自动选中,进入 Step 1 |
|
||||
| 多个 | `/comet`(无描述) | → 列出清单让用户选择 |
|
||||
|
||||
<IMPORTANT>
|
||||
当用户选择「创建新变更」时,**必须调用 `/comet-open`**(禁止直接调用 `/opsx:new`)。
|
||||
`/comet-open` 负责完整双初始化:OpenSpec artifacts(由内部 `/opsx:new` 创建)+ `.comet.yaml` 状态文件。
|
||||
直接调用 `/opsx:new` 会缺失 `.comet.yaml`,导致后续阶段判定失败。
|
||||
</IMPORTANT>
|
||||
|
||||
**Step 1: 读取 `.comet.yaml` 状态元数据**
|
||||
|
||||
优先读取 `openspec/changes/<name>/.comet.yaml`。不存在时回退到 `openspec status --change "<name>" --json`、`tasks.md` 和 `docs/superpowers/` 文件检查。
|
||||
|
||||
**断点恢复规则**:
|
||||
- 每次恢复上下文时,先重新执行 Step 0 和 Step 1,不依赖对话历史判断阶段
|
||||
- 只要存在 active change 且工作区有未提交改动,必须按 `comet/reference/dirty-worktree.md` 协议处理。该协议定义了检查步骤、归因分类和禁令,本文件不重复
|
||||
- 若 `phase: build`,先检查 `build_mode` 和 `isolation` 是否已设置;若有未设置的字段,回到 `/comet-build` 对应步骤补充后再执行;若均已设置,读取 tasks.md 的下一个未勾选任务继续
|
||||
- 若 `phase: verify` 且 `verify_result: fail`,进入验证失败决策阻塞点:暂停并询问用户修复或接受偏差;用户选择修复后才运行 `bash "$COMET_STATE" transition <name> verify-fail` 并调用 `/comet-build`
|
||||
- 若 `phase: open` 但 proposal/design/tasks 已完整,先运行 `bash "$COMET_GUARD" <change-name> open --apply` 修正状态,再继续判定
|
||||
- 若 `phase: archive`,只允许调用 `/comet-archive`;归档成功后 change 会移动到 archive 目录,不再对原活跃目录运行 guard
|
||||
|
||||
**Step 2: 阶段判定**(按顺序,命中即停)
|
||||
|
||||
1. `archived: true` 或 change 已移入 archive → 流程已完成
|
||||
2. `verify_result: pass` 且 `archived` 不是 `true` → `/comet-archive`
|
||||
3. `verify_result: fail` → 进入验证失败决策阻塞点(暂停询问修复或接受偏差;用户选择修复后才 `verify-fail` 并 `/comet-build`)
|
||||
4. `phase: verify` 或 tasks.md 全部勾选 → `/comet-verify`
|
||||
5. `phase: build` 或已有 Design Doc 但计划/执行未完成 → 优先按 workflow 路由:`hotfix` → `/comet-hotfix`,`tweak` → `/comet-tweak`,`full` → `/comet-build`
|
||||
6. `phase: design` 或有 change 但无 Design Doc → `/comet-design`
|
||||
7. `phase: open` 或有活跃 change 但 `.comet.yaml` 缺失 → `/comet-open`
|
||||
8. 无活跃 change → `/comet-open`
|
||||
|
||||
如果元数据与文件状态冲突,以文件状态为准,修正 `.comet.yaml` 后继续。
|
||||
|
||||
### 预设升级条件
|
||||
|
||||
**hotfix → full**(满足任一即升级):
|
||||
- 改动涉及 **3+ 文件**
|
||||
- 涉及架构变更(新模块、新接口、新依赖)
|
||||
- 涉及数据库 schema 变更
|
||||
- 修复引入新的 public API
|
||||
- 修复范围超出单一函数/模块
|
||||
|
||||
**tweak → full**(满足任一即升级):
|
||||
- 改动涉及 **5+ 文件**
|
||||
- 涉及多个模块的协调修改
|
||||
- 需要新增测试用例 **5+**
|
||||
- 涉及配置项的新增或删除(非值修改)
|
||||
|
||||
### 错误处理速查
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| `openspec list --json` 失败 | 检查 openspec 是否已安装,提示 `openspec init` |
|
||||
| 子 skill 不可用 | 停止流程,提示安装或启用对应 skill |
|
||||
| `.comet.yaml` 格式异常或缺失 | 以文件状态为准,用 `bash $COMET_STATE set` 修正后继续 |
|
||||
| 构建/测试失败 | 返回 build 阶段修复,不进入 verify |
|
||||
| change 目录结构不完整 | 按 `comet-open` 产物要求补齐 |
|
||||
|
||||
### 阶段衔接
|
||||
|
||||
<IMPORTANT>
|
||||
单次 `/comet` 调用从检测到的阶段开始,退出条件满足后进入下一阶段。
|
||||
|
||||
流转链:open → design → build → verify → archive
|
||||
|
||||
**连续执行要求**:从检测到的阶段开始,agent 自动推进后续阶段。但**自动推进仅适用于没有用户决策的衔接点**。遇到用户决策点时,**必须使用 AskUserQuestion 工具暂停并等待用户明确回复**,不得用推荐规则、默认值或历史偏好代替用户确认,也不得仅输出文字提示后继续执行。
|
||||
|
||||
**决策点是阻塞点**:只要到达下列任一节点,当前 `/comet` 调用必须停住,**使用 AskUserQuestion 工具等待用户选择**。用户明确选择后才能写入对应状态字段、执行对应操作,随后再继续自动流转。
|
||||
|
||||
需要用户参与的节点(仅在这些节点暂停):
|
||||
1. open 阶段 proposal/design/tasks 审视确认
|
||||
2. brainstorming 确认设计方案
|
||||
3. build 阶段选择工作方式(隔离方式 + 执行方式,一次交互完成)
|
||||
4. verify 不通过时决定修复或接受偏差(含 Spec 漂移处理方式选择)
|
||||
5. finishing-branch 选择分支处理方式
|
||||
6. 遇到升级条件(hotfix/tweak → 完整流程)
|
||||
7. build 阶段范围扩张需重新设计或拆分新 change
|
||||
|
||||
agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须自动继续推进,不得中途退出。到达决策点时,**禁止以文字输出代替工具等待——必须通过 AskUserQuestion 明确获取用户选择后才能继续**。
|
||||
|
||||
**红旗清单** — 以下想法出现时立即停止并检查:
|
||||
|
||||
| Agent 心理 | 实际风险 |
|
||||
|-----------|---------|
|
||||
| "用户应该会同意这个方案" | 不能替用户决策,用 AskUserQuestion |
|
||||
| "这只是个小改动,不需要确认" | 决策点无大小之分,阻塞点必须等待 |
|
||||
| "用户之前选过 A,这次也选 A" | 历史偏好不能替代当前确认 |
|
||||
| "我已经解释了方案,用户没反对" | 没反对 ≠ 同意,必须用工具获取明确选择 |
|
||||
| "流程走到这里应该没问题了" | 验证不通过 ≠ 通过,检查 verify_result |
|
||||
</IMPORTANT>
|
||||
|
||||
---
|
||||
|
||||
## 子命令速查
|
||||
|
||||
| 命令 | 阶段 | 归属 | 产物 |
|
||||
|------|------|------|------|
|
||||
| `/comet-open` | 1. 开启 | OpenSpec | proposal.md、design.md、tasks.md |
|
||||
| `/comet-design` | 2. 深度设计 | Superpowers | Design Doc、delta spec |
|
||||
| `/comet-build` | 3. 计划与构建 | Superpowers | 实施计划、代码提交 |
|
||||
| `/comet-verify` | 4. 验证与收尾 | Both | 验证报告、分支处理 |
|
||||
| `/comet-archive` | 5. 归档 | OpenSpec | delta→main spec 同步、design doc 标注、归档 |
|
||||
| `/comet-hotfix` | 预设路径 | Both | 快速修复(跳过 brainstorming) |
|
||||
| `/comet-tweak` | 预设路径 | Both | 小改动(跳过 brainstorming 和完整 plan) |
|
||||
|
||||
```
|
||||
/comet
|
||||
↓ 自动检测
|
||||
/comet-open ──→ /comet-design ──→ /comet-build ──→ /comet-verify ──→ /comet-archive
|
||||
(OpenSpec) (Superpowers) (Superpowers) (Both) (OpenSpec)
|
||||
|
||||
/comet-hotfix(预设路径,跳过 brainstorming)
|
||||
open ──→ build ──→ verify ──→ archive
|
||||
↑ 如触发升级条件 → 阻塞确认升级 → 补充 Design Doc → 回到完整流程
|
||||
|
||||
/comet-tweak(预设路径,跳过 brainstorming 和完整 plan)
|
||||
open ──→ lightweight build ──→ light verify ──→ archive
|
||||
↑ 如触发升级条件 → 阻塞确认升级 → 补充 Design Doc → 回到完整流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考附录(Reference Appendix)
|
||||
|
||||
### .comet.yaml 字段说明
|
||||
|
||||
```yaml
|
||||
workflow: full
|
||||
phase: build
|
||||
design_doc: docs/superpowers/specs/YYYY-MM-DD-topic-design.md
|
||||
plan: docs/superpowers/plans/YYYY-MM-DD-feature.md
|
||||
base_ref: a1b2c3d4e5f6...
|
||||
build_mode: subagent-driven-development
|
||||
isolation: branch
|
||||
verify_mode: light
|
||||
verify_result: pending
|
||||
verification_report: null
|
||||
branch_status: pending
|
||||
created_at: 2026-05-26
|
||||
verified_at: null
|
||||
archived: false
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `workflow` | `full`、`hotfix` 或 `tweak` |
|
||||
| `phase` | 当前阶段:`open`、`design`、`build`、`verify`、`archive`(init 统一设为 `open`,guard 负责过渡) |
|
||||
| `design_doc` | 关联的 Superpowers Design Doc 路径,可为空 |
|
||||
| `plan` | 关联的 Superpowers Plan 路径,可为空 |
|
||||
| `base_ref` | init 时记录的 git commit SHA,用于 scale 评估。无 plan 时作为改动文件数统计基准 |
|
||||
| `build_mode` | 已选择的执行方式,可为空 |
|
||||
| `isolation` | `branch` 或 `worktree`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 默认 `branch` |
|
||||
| `verify_mode` | `light` 或 `full`,可为空 |
|
||||
| `verify_result` | `pending`、`pass` 或 `fail` |
|
||||
| `verification_report` | 验证报告文件路径,verify 通过前必须指向已存在文件 |
|
||||
| `branch_status` | `pending` 或 `handled`,分支处理完成后设为 `handled` |
|
||||
| `created_at` | change 创建日期(init 时自动写入),格式 `YYYY-MM-DD` |
|
||||
| `verified_at` | 验证通过时间,可为空 |
|
||||
| `archived` | change 是否已归档 |
|
||||
|
||||
可选字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `direct_override` | `true`/`false`。full workflow 如需使用 `build_mode: direct`,必须显式设为 `true` |
|
||||
| `build_command` | 项目构建命令。guard 优先运行该命令,失败时打印命令输出 |
|
||||
| `verify_command` | 项目验证命令。verify guard 优先运行该命令,未配置时回退到构建命令 |
|
||||
|
||||
状态机硬约束:
|
||||
- `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`
|
||||
- `build → verify` 前,`build_mode` 必须已选择
|
||||
- `build_mode: direct` 默认只允许 `hotfix` / `tweak`;full workflow 需要 `direct_override: true`
|
||||
- 这些约束同时存在于 `comet-guard.sh build --apply` 和 `comet-state.sh transition <name> build-complete`
|
||||
|
||||
### 脚本定位
|
||||
|
||||
Comet 脚本随 skill 包分发在 `comet/scripts/` 下。**不硬编码路径** — 定位一次,缓存到环境变量:
|
||||
|
||||
```bash
|
||||
COMET_ENV="${COMET_ENV:-$(find . "$HOME"/.*/skills "$HOME/.config" "$HOME/.gemini" -path '*/comet/scripts/comet-env.sh' -type f -print -quit 2>/dev/null)}"
|
||||
if [ -z "$COMET_ENV" ]; then
|
||||
echo "ERROR: comet-env.sh not found. Ensure the comet skill is installed." >&2
|
||||
return 1
|
||||
fi
|
||||
. "$COMET_ENV"
|
||||
|
||||
# 脚本定位失败时停止流程
|
||||
if [ -z "$COMET_GUARD" ] || [ -z "$COMET_STATE" ] || [ -z "$COMET_HANDOFF" ] || [ -z "$COMET_ARCHIVE" ]; then
|
||||
echo "ERROR: Comet scripts not found. Ensure the comet skill is installed." >&2
|
||||
echo "Expected path pattern: */comet/scripts/comet-*.sh under project or platform skill directories" >&2
|
||||
return 1
|
||||
fi
|
||||
```
|
||||
|
||||
**自动状态更新**:guard 支持 `--apply` 参数,验证通过后自动更新 `.comet.yaml` 状态字段:
|
||||
|
||||
```bash
|
||||
bash "$COMET_GUARD" <change-name> <phase> --apply
|
||||
```
|
||||
|
||||
`--apply` 内部委托给 `comet-state transition`。需要直接表达状态事件时使用:
|
||||
|
||||
```bash
|
||||
bash "$COMET_STATE" transition <change-name> open-complete
|
||||
bash "$COMET_STATE" transition <change-name> design-complete
|
||||
bash "$COMET_STATE" transition <change-name> build-complete
|
||||
bash "$COMET_STATE" transition <change-name> verify-pass
|
||||
bash "$COMET_STATE" transition <change-name> verify-fail
|
||||
bash "$COMET_STATE" transition <archive-name> archived
|
||||
```
|
||||
|
||||
**归档脚本**:一键完成归档全部步骤:
|
||||
|
||||
```bash
|
||||
bash "$COMET_ARCHIVE" <change-name>
|
||||
```
|
||||
|
||||
加载 comet 后,agent 应执行以上变量赋值一次,后续全程复用 `$COMET_GUARD`、`$COMET_STATE`、`$COMET_HANDOFF`、`$COMET_ARCHIVE`。
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
openspec/ # OpenSpec — WHAT
|
||||
├── config.yaml
|
||||
├── changes/
|
||||
│ ├── <name>/ # 活跃 change
|
||||
│ │ ├── .openspec.yaml
|
||||
│ │ ├── .comet.yaml
|
||||
│ │ ├── proposal.md # Why + What
|
||||
│ │ ├── design.md # 高层架构决策
|
||||
│ │ ├── specs/<capability>/spec.md # Delta 能力规格
|
||||
│ │ ├── .comet/handoff/ # 脚本生成的阶段交接包
|
||||
│ │ └── tasks.md # 任务清单
|
||||
│ └── archive/YYYY-MM-DD-<name>/ # 已归档
|
||||
└── specs/<capability>/spec.md # 主 specs(归档时从 delta 覆盖)
|
||||
|
||||
docs/superpowers/ # Superpowers — HOW
|
||||
├── specs/YYYY-MM-DD-<topic>-design.md # 设计文档(技术 RFC,归档时标注状态)
|
||||
└── plans/YYYY-MM-DD-<feature>.md # 实施计划(文件头含 change 关联元数据)
|
||||
```
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **brainstorming 不可跳过** — 每次变更必须经过深度设计(hotfix 和 tweak 除外)
|
||||
2. **delta spec 是活文档** — 阶段 3 期间可自由修改,归档时同步
|
||||
3. **交接包由脚本生成** — OpenSpec → Superpowers 的上下文必须通过 `comet-handoff.sh` 生成 compact 可追溯摘录(必要时 `--full`),并由 guard 校验 source/hash/mode
|
||||
4. **保持 tasks.md 同步** — 完成一个勾一个
|
||||
5. **频繁提交** — 每个任务一次提交,message 体现设计意图
|
||||
6. **先验证再归档** — `/comet-verify` 通过后才执行 `/comet-archive`
|
||||
7. **增量更新分级** — 小编辑、中重 brainstorming、大新 change
|
||||
8. **Plan 必须关联 change** — 文件头包含 `change:` 和 `design-doc:` 元数据
|
||||
9. **归档闭环** — design doc 和 plan 必须标注 `archived-with` 状态
|
||||
10. **修改已有功能** — 直接 open 新 change 即可
|
||||
11. **Preset 有上限** — hotfix/tweak 满足升级条件时及时切换到完整流程
|
||||
@@ -0,0 +1,58 @@
|
||||
# Dirty Worktree 协议
|
||||
|
||||
规范路径:`comet/reference/dirty-worktree.md`
|
||||
|
||||
本协议由所有涉及代码修改的 comet 子 skill 共享。当 agent 恢复上下文或继续执行时,必须按本协议处理未提交的工作区改动。
|
||||
|
||||
## 1. 检查步骤
|
||||
|
||||
继续或开始修改前,必须先运行以下命令:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat
|
||||
git diff --cached --stat
|
||||
git ls-files --others --exclude-standard
|
||||
```
|
||||
|
||||
必要时再查看 `git diff` / `git diff --cached` / 新建文件内容。
|
||||
|
||||
## 2. 核心规则
|
||||
|
||||
- 用户可能不会说明自己改了哪里。只要存在 dirty worktree(包括 Git 状态里显示为 `??` 的新建文件),就先假设改动可能来自用户或混合来源
|
||||
- dirty worktree 只代表代码事实,不会自动推进 `.comet.yaml` 的 `phase` 或勾选 `tasks.md`;只有完成归因、验证、同步必要文档,并通过对应阶段 guard 后,才允许推进 Comet 状态
|
||||
|
||||
## 3. 归因分类
|
||||
|
||||
将 dirty diff 分为三类:
|
||||
|
||||
1. **属于当前 change**:文件和内容能对应当前 change 的目标、tasks.md、plan 或 delta spec。将其纳入当前任务继续,不重复改同一处
|
||||
2. **不属于当前 change**:文件或内容与当前目标无关。暂停并询问用户:并入当前 change、拆成新 change、保留不处理,或明确授权丢弃
|
||||
3. **来源不确定**:无法从 diff 和文档判断归属。暂停并向用户汇报文件列表和判断依据,不继续推进阶段
|
||||
|
||||
## 4. 常见处理模式
|
||||
|
||||
### 已实现但 tasks.md 未勾选
|
||||
|
||||
先验证实现(运行构建和测试),通过后补勾任务。不要因为任务未勾选就重做一遍,也不要因为状态文件滞后而忽略代码事实。若当前子 skill 定义了阶段特例,以子 skill 为准。
|
||||
|
||||
### 暗示计划或范围已变化
|
||||
|
||||
按当前子 skill 的升级、增量更新或回退规则处理,本协议不重复阶段特例。
|
||||
|
||||
### 模糊恢复意图
|
||||
|
||||
用户说"继续""接着跑""我改了一点""刚才不满意""重新弄""代码动过""先按现在的来"等模糊恢复意图时,按本协议处理。不要要求用户先回忆具体改了哪里。
|
||||
|
||||
### open/design 阶段出现代码改动
|
||||
|
||||
若当前仍处于 `open` 或 `design`,但 dirty worktree 已经包含代码改动,先按本协议归因,不要直接推进阶段:
|
||||
|
||||
- 属于当前 change 的改动:作为需求或设计输入记录到 proposal/design/spec/design doc/tasks 中;进入 build 前仍需完成对应阶段 guard
|
||||
- 不属于当前 change 或来源不确定:暂停询问用户是并入当前 change、拆成新 change、保留不处理,还是明确授权丢弃
|
||||
- 禁止在 open/design 阶段直接把代码改动当作已完成实现并推进到 verify
|
||||
|
||||
## 5. 禁令
|
||||
|
||||
- 禁止在未理解 dirty diff 来源时覆盖、回滚、格式化重写或忽略用户改动
|
||||
- 禁止在 dirty diff 未解释清楚时判定验证通过
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/bin/bash
|
||||
# Comet Archive — automates the archive phase in one command
|
||||
# Usage: comet-archive.sh <change-name> [--dry-run]
|
||||
# Exit 0 = archive complete, exit 1 = fatal error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
yellow() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
DRY_RUN=0
|
||||
if [[ "${2:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=1
|
||||
fi
|
||||
|
||||
# Input validation
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
if [ -z "$name" ]; then
|
||||
red "FATAL: Change name cannot be empty"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "FATAL: Invalid change name: '$name'"
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "FATAL: Change name cannot contain '..'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
CHANGE="$1"
|
||||
validate_change_name "$CHANGE"
|
||||
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")" 2>/dev/null || dirname "$0")" && pwd)"
|
||||
STATE_SH="$SCRIPT_DIR/comet-state.sh"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
ARCHIVE_NAME="${TODAY}-${CHANGE}"
|
||||
ARCHIVE_DIR="openspec/changes/archive/${ARCHIVE_NAME}"
|
||||
|
||||
STEPS_OK=0
|
||||
STEPS_TOTAL=0
|
||||
|
||||
step_ok() {
|
||||
green " [OK] $1"
|
||||
STEPS_OK=$((STEPS_OK + 1))
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
step_fail() {
|
||||
red " [FAIL] $1"
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
step_dry_run() {
|
||||
yellow " [DRY-RUN] $1"
|
||||
STEPS_OK=$((STEPS_OK + 1))
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
echo "=== Comet Archive: $CHANGE ===" >&2
|
||||
|
||||
# --- Step 1: Read .comet.yaml, extract paths ---
|
||||
|
||||
yaml_field() {
|
||||
local field="$1"
|
||||
if [ -f "$STATE_SH" ]; then
|
||||
bash "$STATE_SH" get "$CHANGE" "$field" 2>/dev/null
|
||||
else
|
||||
if [ -f "$YAML" ]; then
|
||||
local value
|
||||
value=$(grep "^${field}:" "$YAML" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
\'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
*) printf '%s\n' "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ ! -f "$YAML" ]; then
|
||||
red "FATAL: .comet.yaml not found in $CHANGE_DIR/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESIGN_DOC=$(yaml_field "design_doc")
|
||||
PLAN_PATH=$(yaml_field "plan")
|
||||
|
||||
# --- Step 2: Validate entry state ---
|
||||
|
||||
PHASE_VAL=$(yaml_field "phase")
|
||||
VERIFY_VAL=$(yaml_field "verify_result")
|
||||
ARCHIVED_VAL=$(yaml_field "archived")
|
||||
|
||||
if [ "$PHASE_VAL" != "archive" ]; then
|
||||
red "FATAL: phase is '$PHASE_VAL', expected 'archive'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VERIFY_VAL" != "pass" ]; then
|
||||
red "FATAL: verify_result is '$VERIFY_VAL', expected 'pass'. Run comet-verify first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ARCHIVED_VAL" = "true" ]; then
|
||||
red "FATAL: change already archived"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step_ok "Entry state verified"
|
||||
|
||||
# --- Step 3: Check archive target ---
|
||||
|
||||
if [ -d "$ARCHIVE_DIR" ]; then
|
||||
red "FATAL: archive target already exists: $ARCHIVE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step_ok "Archive target available"
|
||||
|
||||
# --- Step 4: Sync delta specs → main specs ---
|
||||
|
||||
sync_delta_specs() {
|
||||
local delta_root="$CHANGE_DIR/specs"
|
||||
if [ ! -d "$delta_root" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for delta_spec_dir in "$delta_root"/*/; do
|
||||
[ -d "$delta_spec_dir" ] || continue
|
||||
local capability
|
||||
capability=$(basename "$delta_spec_dir")
|
||||
local delta_spec="$delta_spec_dir/spec.md"
|
||||
local main_spec="openspec/specs/$capability/spec.md"
|
||||
|
||||
if [ ! -f "$delta_spec" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would sync: $capability → $main_spec"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$main_spec" ]; then
|
||||
mkdir -p "openspec/specs/$capability"
|
||||
elif ! cmp -s "$main_spec" "$delta_spec"; then
|
||||
yellow " [DIFF] Delta spec differs from main spec before sync: $capability"
|
||||
diff -u "$main_spec" "$delta_spec" >&2 || true
|
||||
fi
|
||||
cp "$delta_spec" "$main_spec"
|
||||
|
||||
step_ok "Delta spec synced: $capability → openspec/specs/$capability/spec.md"
|
||||
done
|
||||
}
|
||||
|
||||
sync_delta_specs
|
||||
|
||||
# --- Step 5: Annotate design doc frontmatter ---
|
||||
|
||||
annotate_frontmatter() {
|
||||
local file="$1"
|
||||
local extra_fields="$2"
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would annotate: $file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if head -1 "$file" | grep -q '^---'; then
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
awk -v archive="$ARCHIVE_NAME" -v extra="$extra_fields" '
|
||||
/^archived-with:/ { next }
|
||||
NR==1 && /^---/ { print; next }
|
||||
/^---/ && NR>1 {
|
||||
print "archived-with: " archive
|
||||
if (extra != "") print extra
|
||||
print; next
|
||||
}
|
||||
{ print }
|
||||
' "$file" > "$tmp_file"
|
||||
mv "$tmp_file" "$file"
|
||||
else
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
{
|
||||
echo "---"
|
||||
echo "archived-with: $ARCHIVE_NAME"
|
||||
if [ -n "$extra_fields" ]; then
|
||||
echo "$extra_fields"
|
||||
fi
|
||||
echo "status: final"
|
||||
echo "---"
|
||||
cat "$file"
|
||||
} > "$tmp_file"
|
||||
mv "$tmp_file" "$file"
|
||||
fi
|
||||
|
||||
step_ok "Annotated: $file"
|
||||
}
|
||||
|
||||
if [ -n "$DESIGN_DOC" ] && [ "$DESIGN_DOC" != "null" ]; then
|
||||
annotate_frontmatter "$DESIGN_DOC" "status: final"
|
||||
fi
|
||||
|
||||
# --- Step 6: Annotate plan frontmatter ---
|
||||
|
||||
if [ -n "$PLAN_PATH" ] && [ "$PLAN_PATH" != "null" ]; then
|
||||
annotate_frontmatter "$PLAN_PATH" ""
|
||||
fi
|
||||
|
||||
# --- Step 7: Move change to archive ---
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would move: $CHANGE_DIR → $ARCHIVE_DIR"
|
||||
else
|
||||
mkdir -p "openspec/changes/archive"
|
||||
mv "$CHANGE_DIR" "$ARCHIVE_DIR"
|
||||
step_ok "Moved to: $ARCHIVE_DIR"
|
||||
fi
|
||||
|
||||
# --- Step 8: Mark archived via comet-state transition ---
|
||||
|
||||
ARCHIVE_YAML="$ARCHIVE_DIR/.comet.yaml"
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would set archived: true in $ARCHIVE_YAML"
|
||||
else
|
||||
if [ -f "$ARCHIVE_YAML" ]; then
|
||||
bash "$STATE_SH" transition "$ARCHIVE_NAME" archived >/dev/null
|
||||
step_ok "archived: true"
|
||||
else
|
||||
step_fail "archived: true (.comet.yaml not found after move)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Step 9: Print summary ---
|
||||
|
||||
echo "" >&2
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
yellow "Dry run complete. $STEPS_OK/$STEPS_TOTAL steps would succeed."
|
||||
else
|
||||
green "Archive complete. $STEPS_OK/$STEPS_TOTAL steps succeeded."
|
||||
fi
|
||||
|
||||
if [ "$STEPS_OK" -lt "$STEPS_TOTAL" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Comet script locator — source this file to export paths to bundled scripts.
|
||||
#
|
||||
# Usage:
|
||||
# . /path/to/comet/scripts/comet-env.sh
|
||||
#
|
||||
# This file is sourced by workflow snippets. Do not set global shell options here.
|
||||
|
||||
_comet_env_source="${BASH_SOURCE[0]:-$0}"
|
||||
_comet_script_dir="$(cd "$(dirname "$_comet_env_source")" && pwd -P)"
|
||||
_comet_env_sourced=0
|
||||
(return 0 2>/dev/null) && _comet_env_sourced=1
|
||||
|
||||
export COMET_GUARD="${COMET_GUARD:-${_comet_script_dir}/comet-guard.sh}"
|
||||
export COMET_STATE="${COMET_STATE:-${_comet_script_dir}/comet-state.sh}"
|
||||
export COMET_HANDOFF="${COMET_HANDOFF:-${_comet_script_dir}/comet-handoff.sh}"
|
||||
export COMET_ARCHIVE="${COMET_ARCHIVE:-${_comet_script_dir}/comet-archive.sh}"
|
||||
export COMET_YAML_VALIDATE="${COMET_YAML_VALIDATE:-${_comet_script_dir}/comet-yaml-validate.sh}"
|
||||
|
||||
_comet_env_fail() {
|
||||
echo "ERROR: Comet scripts not found. Ensure the comet skill is installed completely." >&2
|
||||
echo "Expected path pattern: */comet/scripts/comet-*.sh under project or platform skill directories" >&2
|
||||
}
|
||||
|
||||
_comet_env_abort() {
|
||||
local _comet_env_was_sourced="$_comet_env_sourced"
|
||||
unset _comet_env_source _comet_script_dir _comet_script _comet_env_missing _comet_env_sourced
|
||||
unset -f _comet_env_fail
|
||||
if [ "$_comet_env_was_sourced" -eq 1 ]; then
|
||||
unset -f _comet_env_abort
|
||||
return 1
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
_comet_env_missing=0
|
||||
for _comet_script in \
|
||||
"$COMET_GUARD" \
|
||||
"$COMET_STATE" \
|
||||
"$COMET_HANDOFF" \
|
||||
"$COMET_ARCHIVE" \
|
||||
"$COMET_YAML_VALIDATE"; do
|
||||
if [ ! -f "$_comet_script" ]; then
|
||||
_comet_env_fail
|
||||
_comet_env_missing=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$_comet_env_missing" -ne 0 ]; then
|
||||
_comet_env_abort
|
||||
else
|
||||
unset _comet_env_source _comet_script_dir _comet_script _comet_env_missing _comet_env_sourced
|
||||
unset -f _comet_env_fail _comet_env_abort
|
||||
fi
|
||||
@@ -0,0 +1,594 @@
|
||||
#!/bin/bash
|
||||
# Comet Phase Guard — validates exit conditions before phase transitions
|
||||
# Usage: comet-guard.sh <change-name> <current-phase> [--apply]
|
||||
# Phases: open, design, build, verify, archive
|
||||
# Exit 0 = all checks pass, exit 1 = blocked (reasons printed to stderr)
|
||||
# shellcheck disable=SC2329 # Functions called indirectly via check() dispatch
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
warn() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
# Input validation - prevent path traversal
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
# Reject empty names
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'" >&2
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Reject path traversal attempts
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_change_name "$1"
|
||||
|
||||
CHANGE="$1"
|
||||
PHASE="$2"
|
||||
APPLY=0
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")" 2>/dev/null || dirname "$0")"
|
||||
if [[ "${3:-}" == "--apply" ]]; then
|
||||
APPLY=1
|
||||
fi
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
if [ "$PHASE" = "archive" ] && [ ! -d "$CHANGE_DIR" ] && [ -d "openspec/changes/archive/$CHANGE" ]; then
|
||||
CHANGE_DIR="openspec/changes/archive/$CHANGE"
|
||||
fi
|
||||
|
||||
BLOCK=0
|
||||
check() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local output
|
||||
if output=$("$@" 2>&1); then
|
||||
green " [PASS] $desc"
|
||||
else
|
||||
red " [FAIL] $desc"
|
||||
if [ -n "$output" ]; then
|
||||
while IFS= read -r line; do
|
||||
red " $line"
|
||||
done <<< "$output"
|
||||
fi
|
||||
BLOCK=1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
tasks_all_done() {
|
||||
local tasks="$CHANGE_DIR/tasks.md"
|
||||
if [ ! -f "$tasks" ]; then
|
||||
echo "tasks.md is missing at $tasks" >&2
|
||||
echo "Next: restore or create tasks.md for this change before leaving build." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! grep -q '\- \[x\]' "$tasks"; then
|
||||
echo "tasks.md has no completed tasks." >&2
|
||||
echo "Next: complete implementation tasks and mark them with '- [x]'." >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -q '\- \[ \]' "$tasks"; then
|
||||
echo "Unfinished tasks:" >&2
|
||||
grep -n '\- \[ \]' "$tasks" >&2 || true
|
||||
echo "Next: complete or explicitly remove unfinished tasks, then mark tasks.md with '- [x]'." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
tasks_has_any() {
|
||||
local tasks="$CHANGE_DIR/tasks.md"
|
||||
[ -f "$tasks" ] && grep -q '\- \[' "$tasks"
|
||||
}
|
||||
|
||||
yaml_field_value() {
|
||||
local field="$1"
|
||||
local yaml="$CHANGE_DIR/.comet.yaml"
|
||||
if [ -f "$yaml" ]; then
|
||||
local value
|
||||
value=$(grep "^${field}:" "$yaml" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\")
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
\'*\')
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
project_config_value() {
|
||||
local field="$1"
|
||||
local value
|
||||
|
||||
value=$(yaml_field_value "$field" 2>/dev/null || true)
|
||||
if [ -n "$value" ] && [ "$value" != "null" ]; then
|
||||
echo "$value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for config in ".comet.yaml" "comet.yaml" ".comet.yml" "comet.yml"; do
|
||||
if [ -f "$config" ]; then
|
||||
value=$(grep "^${field}:" "$config" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
value=$(strip_wrapping_quotes "$value")
|
||||
if [ -n "$value" ] && [ "$value" != "null" ]; then
|
||||
echo "$value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
file_nonempty() {
|
||||
[ -f "$1" ] && [ -s "$1" ]
|
||||
}
|
||||
|
||||
is_windows_bash() {
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
MINGW*|MSYS*|CYGWIN*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_command_string() {
|
||||
local command="$1"
|
||||
echo "+ $command" >&2
|
||||
bash -lc "$command"
|
||||
}
|
||||
|
||||
hash_stream() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
else
|
||||
echo "sha256sum or shasum is required" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
hash_file() {
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
echo "sha256sum or shasum is required" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
handoff_source_files() {
|
||||
printf '%s\n' "$CHANGE_DIR/proposal.md"
|
||||
printf '%s\n' "$CHANGE_DIR/design.md"
|
||||
printf '%s\n' "$CHANGE_DIR/tasks.md"
|
||||
if [ -d "$CHANGE_DIR/specs" ]; then
|
||||
find "$CHANGE_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
compute_handoff_hash() {
|
||||
handoff_source_files | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
printf 'path:%s\n' "$file"
|
||||
printf 'sha256:%s\n' "$(hash_file "$file")"
|
||||
fi
|
||||
done | hash_stream
|
||||
}
|
||||
|
||||
preflight() {
|
||||
|
||||
if [ ! -d "$CHANGE_DIR" ]; then
|
||||
red "FATAL: change directory not found: $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CHANGE_DIR/.comet.yaml" ]; then
|
||||
red "FATAL: .comet.yaml not found in $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Schema validation
|
||||
local validate_script
|
||||
validate_script="$SCRIPT_DIR/comet-yaml-validate.sh"
|
||||
if [ -f "$validate_script" ]; then
|
||||
if ! bash "$validate_script" "$CHANGE" 2>/dev/null; then
|
||||
bash "$validate_script" "$CHANGE"
|
||||
red "FATAL: .comet.yaml schema validation failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
build_passes() {
|
||||
if [ "${COMET_SKIP_BUILD:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
local configured_build
|
||||
configured_build=$(project_config_value "build_command" 2>/dev/null || true)
|
||||
if [ -n "$configured_build" ]; then
|
||||
run_command_string "$configured_build"
|
||||
return $?
|
||||
fi
|
||||
if [ -f "package.json" ] && grep -q '"build"' "package.json"; then
|
||||
npm run build
|
||||
return $?
|
||||
fi
|
||||
if [ -f "pom.xml" ]; then
|
||||
if [ -x "./mvnw" ]; then
|
||||
./mvnw compile -q
|
||||
elif is_windows_bash && command -v mvn.cmd >/dev/null 2>&1; then
|
||||
mvn.cmd compile -q
|
||||
else
|
||||
mvn compile -q
|
||||
fi
|
||||
return $?
|
||||
fi
|
||||
if [ -f "Cargo.toml" ]; then
|
||||
cargo build
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
verification_command_passes() {
|
||||
if [ "${COMET_SKIP_BUILD:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
local configured_verify
|
||||
configured_verify=$(project_config_value "verify_command" 2>/dev/null || true)
|
||||
if [ -n "$configured_verify" ]; then
|
||||
run_command_string "$configured_verify"
|
||||
return $?
|
||||
fi
|
||||
build_passes
|
||||
}
|
||||
|
||||
isolation_selected() {
|
||||
local isolation
|
||||
isolation=$(yaml_field_value "isolation" 2>/dev/null || true)
|
||||
case "$isolation" in
|
||||
branch|worktree) return 0 ;;
|
||||
*)
|
||||
echo "isolation must be branch or worktree, got '${isolation:-null}'" >&2
|
||||
echo "Next: ask the user to choose branch or worktree, create the chosen isolation, then run:" >&2
|
||||
echo " bash \"\$COMET_STATE\" set $CHANGE isolation <branch|worktree>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_mode_selected() {
|
||||
local build_mode
|
||||
build_mode=$(yaml_field_value "build_mode" 2>/dev/null || true)
|
||||
case "$build_mode" in
|
||||
subagent-driven-development|executing-plans|direct) return 0 ;;
|
||||
*)
|
||||
echo "build_mode must be selected before leaving build, got '${build_mode:-null}'" >&2
|
||||
echo "Next: ask the user to choose an implementation mode, then run:" >&2
|
||||
echo " bash \"\$COMET_STATE\" set $CHANGE build_mode <subagent-driven-development|executing-plans>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_mode_allowed_for_workflow() {
|
||||
local workflow build_mode direct_override
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
build_mode=$(yaml_field_value "build_mode" 2>/dev/null || true)
|
||||
direct_override=$(yaml_field_value "direct_override" 2>/dev/null || true)
|
||||
|
||||
if [ "$build_mode" != "direct" ]; then
|
||||
return 0
|
||||
fi
|
||||
case "$workflow" in
|
||||
hotfix|tweak) return 0 ;;
|
||||
*)
|
||||
if [ "$direct_override" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "build_mode=direct is only allowed for hotfix/tweak unless direct_override: true is recorded" >&2
|
||||
echo "Next: switch build_mode to executing-plans or subagent-driven-development, or stop and ask the user for an explicit direct override." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_result_is_pass() {
|
||||
local result
|
||||
result=$(yaml_field_value "verify_result" 2>/dev/null || true)
|
||||
[ "$result" = "pass" ]
|
||||
}
|
||||
|
||||
verification_report_exists() {
|
||||
local report
|
||||
report=$(yaml_field_value "verification_report" 2>/dev/null || true)
|
||||
[ -n "$report" ] && [ "$report" != "null" ] && [ -f "$report" ]
|
||||
}
|
||||
|
||||
branch_status_handled() {
|
||||
local status
|
||||
status=$(yaml_field_value "branch_status" 2>/dev/null || true)
|
||||
[ "$status" = "handled" ]
|
||||
}
|
||||
|
||||
design_handoff_context_valid() {
|
||||
local context recorded_hash actual_hash markdown
|
||||
context=$(yaml_field_value "handoff_context" 2>/dev/null || true)
|
||||
recorded_hash=$(yaml_field_value "handoff_hash" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$context" ] || [ "$context" = "null" ]; then
|
||||
echo "handoff_context is missing from .comet.yaml" >&2
|
||||
echo "Next: run bash \"\$COMET_HANDOFF\" $CHANGE design --write before invoking Superpowers." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -s "$context" ]; then
|
||||
echo "handoff_context does not point to a non-empty file: $context" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$recorded_hash" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
echo "handoff_hash is missing or invalid: ${recorded_hash:-null}" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
actual_hash=$(compute_handoff_hash)
|
||||
if [ "$actual_hash" != "$recorded_hash" ]; then
|
||||
echo "OpenSpec artifacts changed after handoff was generated." >&2
|
||||
echo "Expected handoff_hash: $recorded_hash" >&2
|
||||
echo "Actual handoff_hash: $actual_hash" >&2
|
||||
echo "Next: rerun comet-handoff.sh so Superpowers receives the current OpenSpec context." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
markdown="${context%.json}.md"
|
||||
if [ ! -s "$markdown" ]; then
|
||||
echo "design handoff markdown is missing or empty: $markdown" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
design_handoff_markdown_traceable() {
|
||||
local context markdown missing=0
|
||||
context=$(yaml_field_value "handoff_context" 2>/dev/null || true)
|
||||
if [ -z "$context" ] || [ "$context" = "null" ]; then
|
||||
echo "handoff_context is missing from .comet.yaml" >&2
|
||||
return 1
|
||||
fi
|
||||
markdown="${context%.json}.md"
|
||||
if [ ! -s "$markdown" ]; then
|
||||
echo "design handoff markdown is missing or empty: $markdown" >&2
|
||||
return 1
|
||||
fi
|
||||
grep -q '^Generated-by: comet-handoff\.sh$' "$markdown" || {
|
||||
echo "handoff markdown is missing Generated-by marker" >&2
|
||||
missing=1
|
||||
}
|
||||
grep -Eq '^- Mode: (compact|full)$' "$markdown" || {
|
||||
echo "handoff markdown is missing Mode marker" >&2
|
||||
missing=1
|
||||
}
|
||||
handoff_source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if ! grep -q "^- Source: $file$" "$markdown"; then
|
||||
echo "handoff markdown is missing source reference: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! grep -q "^- SHA256: $(hash_file "$file")$" "$markdown"; then
|
||||
echo "handoff markdown is missing current sha256 for: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
done || missing=1
|
||||
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
design_doc_frontmatter_has() {
|
||||
local design_doc="$1"
|
||||
local field="$2"
|
||||
local expected="$3"
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
sub(/^\357\273\277/, "", line)
|
||||
}
|
||||
!in_fm && line == "---" { in_fm = 1; next }
|
||||
in_fm && line == "---" { exit }
|
||||
in_fm { print line }
|
||||
' "$design_doc" | grep -Eq "^${field}: ['\"]?${expected}['\"]?[[:space:]]*$"
|
||||
}
|
||||
|
||||
design_doc_links_current_change() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
if [ -z "$design_doc" ] || [ "$design_doc" = "null" ] || [ ! -s "$design_doc" ]; then
|
||||
echo "design_doc must point to an existing Superpowers Design Doc before leaving design." >&2
|
||||
return 1
|
||||
fi
|
||||
design_doc_frontmatter_has "$design_doc" "comet_change" "$CHANGE"
|
||||
}
|
||||
|
||||
design_doc_declares_technical_role() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
[ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -s "$design_doc" ] &&
|
||||
design_doc_frontmatter_has "$design_doc" "role" "technical-design"
|
||||
}
|
||||
|
||||
design_doc_declares_canonical_spec() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
[ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -s "$design_doc" ] &&
|
||||
design_doc_frontmatter_has "$design_doc" "canonical_spec" "openspec"
|
||||
}
|
||||
|
||||
archived_is_true() {
|
||||
local val
|
||||
val=$(yaml_field_value "archived" 2>/dev/null || true)
|
||||
[ "$val" = "true" ]
|
||||
}
|
||||
|
||||
# --- Phase-specific checks ---
|
||||
|
||||
guard_open() {
|
||||
echo "=== Guard: open → next ===" >&2
|
||||
|
||||
check "proposal.md exists and non-empty" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "design.md exists and non-empty" file_nonempty "$CHANGE_DIR/design.md"
|
||||
check "tasks.md exists and non-empty" file_nonempty "$CHANGE_DIR/tasks.md"
|
||||
check "tasks.md has at least one task" tasks_has_any
|
||||
}
|
||||
|
||||
guard_design() {
|
||||
echo "=== Guard: design → build ===" >&2
|
||||
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "design.md exists" file_nonempty "$CHANGE_DIR/design.md"
|
||||
check "tasks.md exists" file_nonempty "$CHANGE_DIR/tasks.md"
|
||||
check "design handoff context exists" design_handoff_context_valid
|
||||
check "design handoff markdown is traceable" design_handoff_markdown_traceable
|
||||
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ]; then
|
||||
check "Design Doc ($design_doc) exists" file_nonempty "$design_doc"
|
||||
check "Design Doc frontmatter links current change" design_doc_links_current_change
|
||||
check "Design Doc declares technical design role" design_doc_declares_technical_role
|
||||
check "Design Doc declares OpenSpec as canonical spec" design_doc_declares_canonical_spec
|
||||
else
|
||||
warn " [WARN] No design_doc recorded in .comet.yaml"
|
||||
fi
|
||||
}
|
||||
|
||||
guard_build() {
|
||||
echo "=== Guard: build → verify ===" >&2
|
||||
|
||||
check "isolation selected" isolation_selected
|
||||
check "build_mode selected" build_mode_selected
|
||||
check "build_mode allowed for workflow" build_mode_allowed_for_workflow
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "Build passes" build_passes
|
||||
}
|
||||
|
||||
guard_verify() {
|
||||
echo "=== Guard: verify → archive ===" >&2
|
||||
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
check "Build passes" verification_command_passes
|
||||
check "verification_report exists" verification_report_exists
|
||||
check "branch_status=handled" branch_status_handled
|
||||
}
|
||||
|
||||
guard_archive() {
|
||||
echo "=== Guard: archive completeness ===" >&2
|
||||
|
||||
check "archived is true" archived_is_true
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
}
|
||||
|
||||
apply_state_update() {
|
||||
local state_sh="$SCRIPT_DIR/comet-state.sh"
|
||||
local p="$1"
|
||||
|
||||
if [ -f "$state_sh" ]; then
|
||||
case "$p" in
|
||||
open) bash "$state_sh" transition "$CHANGE" open-complete ;;
|
||||
design) bash "$state_sh" transition "$CHANGE" design-complete ;;
|
||||
build) bash "$state_sh" transition "$CHANGE" build-complete ;;
|
||||
verify) bash "$state_sh" transition "$CHANGE" verify-pass ;;
|
||||
esac
|
||||
else
|
||||
red "FATAL: comet-state.sh not found; cannot apply state transition"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
case "$PHASE" in
|
||||
open) preflight ; guard_open ;;
|
||||
design) preflight ; guard_design ;;
|
||||
build) preflight ; guard_build ;;
|
||||
verify) preflight ; guard_verify ;;
|
||||
archive) preflight ; guard_archive ;;
|
||||
*)
|
||||
red "Unknown phase: $PHASE"
|
||||
echo "Valid phases: open, design, build, verify, archive" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$BLOCK" -eq 1 ]; then
|
||||
echo "" >&2
|
||||
red "BLOCKED — fix failing checks before proceeding to next phase"
|
||||
exit 1
|
||||
else
|
||||
echo "" >&2
|
||||
green "ALL CHECKS PASSED — ready for next phase"
|
||||
if [ "$APPLY" -eq 1 ]; then
|
||||
apply_state_update "$PHASE"
|
||||
case "$PHASE" in
|
||||
open)
|
||||
new_phase=$(yaml_field_value "phase")
|
||||
green " [APPLY] .comet.yaml updated: phase=$new_phase"
|
||||
;;
|
||||
design) green " [APPLY] .comet.yaml updated: phase=build" ;;
|
||||
build) green " [APPLY] .comet.yaml updated: phase=verify, verify_result=pending" ;;
|
||||
verify) green " [APPLY] .comet.yaml updated: phase=archive, verify_result=pass" ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/bin/bash
|
||||
# Comet Handoff — creates machine-owned context packages between phases
|
||||
# Usage: comet-handoff.sh <change-name> design --write [--full]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'"
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
\'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
*) printf '%s\n' "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
yaml_field_value() {
|
||||
local field="$1"
|
||||
local yaml="$CHANGE_DIR/.comet.yaml"
|
||||
local value
|
||||
value=$(grep "^${field}:" "$yaml" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
}
|
||||
|
||||
hash_stream() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
else
|
||||
red "ERROR: sha256sum or shasum is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
hash_file() {
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
red "ERROR: sha256sum or shasum is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
source_files() {
|
||||
printf '%s\n' "$CHANGE_DIR/proposal.md"
|
||||
printf '%s\n' "$CHANGE_DIR/design.md"
|
||||
printf '%s\n' "$CHANGE_DIR/tasks.md"
|
||||
if [ -d "$CHANGE_DIR/specs" ]; then
|
||||
find "$CHANGE_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
compute_context_hash() {
|
||||
source_files | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
printf 'path:%s\n' "$file"
|
||||
printf 'sha256:%s\n' "$(hash_file "$file")"
|
||||
fi
|
||||
done | hash_stream
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
file_line_count() {
|
||||
local file="$1"
|
||||
wc -l < "$file" | tr -d ' '
|
||||
}
|
||||
|
||||
write_file_excerpt() {
|
||||
local file="$1"
|
||||
local max_lines="$2"
|
||||
local total_lines
|
||||
total_lines=$(file_line_count "$file")
|
||||
|
||||
echo "## $file"
|
||||
echo ""
|
||||
echo "- Source: $file"
|
||||
echo "- Lines: 1-$total_lines"
|
||||
echo "- SHA256: $(hash_file "$file")"
|
||||
echo ""
|
||||
|
||||
if [ "$HANDOFF_MODE" = "full" ] || [ "$total_lines" -le "$max_lines" ]; then
|
||||
echo '```md'
|
||||
cat "$file"
|
||||
echo '```'
|
||||
else
|
||||
echo "[TRUNCATED]"
|
||||
echo ""
|
||||
echo '```md'
|
||||
sed -n "1,${max_lines}p" "$file"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Full source: $file"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
write_markdown_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "# Comet Design Handoff"
|
||||
echo ""
|
||||
echo "- Change: $CHANGE"
|
||||
echo "- Phase: design"
|
||||
echo "- Mode: $HANDOFF_MODE"
|
||||
echo "- Context hash: $CONTEXT_HASH"
|
||||
echo ""
|
||||
echo "Generated-by: comet-handoff.sh"
|
||||
echo ""
|
||||
echo "OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary."
|
||||
echo ""
|
||||
source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
write_file_excerpt "$file" 80
|
||||
done
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
write_json_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "{"
|
||||
echo " \"change\": \"$(json_escape "$CHANGE")\","
|
||||
echo " \"phase\": \"design\","
|
||||
echo " \"mode\": \"$HANDOFF_MODE\","
|
||||
echo " \"canonical_spec\": \"openspec\","
|
||||
echo " \"generated_by\": \"comet-handoff.sh\","
|
||||
echo " \"context_hash\": \"$CONTEXT_HASH\","
|
||||
echo " \"files\": ["
|
||||
local first=1
|
||||
while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if [ "$first" -eq 0 ]; then
|
||||
echo ","
|
||||
fi
|
||||
first=0
|
||||
printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$file")" "$(hash_file "$file")"
|
||||
done < <(source_files)
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
CHANGE="${1:-}"
|
||||
PHASE="${2:-}"
|
||||
MODE="${3:-}"
|
||||
FULL_FLAG="${4:-}"
|
||||
|
||||
validate_change_name "$CHANGE"
|
||||
|
||||
if [ "$PHASE" != "design" ] || [ "$MODE" != "--write" ]; then
|
||||
red "Usage: comet-handoff.sh <change-name> design --write [--full]"
|
||||
exit 1
|
||||
fi
|
||||
case "$FULL_FLAG" in
|
||||
"") HANDOFF_MODE="compact" ;;
|
||||
--full) HANDOFF_MODE="full" ;;
|
||||
*)
|
||||
red "Usage: comet-handoff.sh <change-name> design --write [--full]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")" 2>/dev/null || dirname "$0")"
|
||||
STATE_SH="$SCRIPT_DIR/comet-state.sh"
|
||||
|
||||
if [ ! -d "$CHANGE_DIR" ]; then
|
||||
red "ERROR: change directory not found: $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$YAML" ]; then
|
||||
red "ERROR: .comet.yaml not found at $YAML"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(yaml_field_value phase)" != "design" ]; then
|
||||
red "ERROR: design handoff requires phase: design"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required in proposal.md design.md tasks.md; do
|
||||
if [ ! -s "$CHANGE_DIR/$required" ]; then
|
||||
red "ERROR: required OpenSpec artifact missing or empty: $CHANGE_DIR/$required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
HANDOFF_DIR="$CHANGE_DIR/.comet/handoff"
|
||||
CONTEXT_JSON="$HANDOFF_DIR/design-context.json"
|
||||
CONTEXT_MD="$HANDOFF_DIR/design-context.md"
|
||||
mkdir -p "$HANDOFF_DIR"
|
||||
|
||||
CONTEXT_HASH="$(compute_context_hash)"
|
||||
write_markdown_context "$CONTEXT_MD"
|
||||
write_json_context "$CONTEXT_JSON"
|
||||
|
||||
if [ -x "$STATE_SH" ] || [ -f "$STATE_SH" ]; then
|
||||
bash "$STATE_SH" set "$CHANGE" handoff_context "$CONTEXT_JSON" >/dev/null
|
||||
bash "$STATE_SH" set "$CHANGE" handoff_hash "$CONTEXT_HASH" >/dev/null
|
||||
else
|
||||
red "ERROR: comet-state.sh not found; cannot record handoff fields"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
green "[HANDOFF] wrote $CONTEXT_JSON"
|
||||
green "[HANDOFF] wrote $CONTEXT_MD"
|
||||
green "[HANDOFF] handoff_hash=$CONTEXT_HASH"
|
||||
@@ -0,0 +1,896 @@
|
||||
#!/bin/bash
|
||||
# Comet State — unified interface for .comet.yaml state management
|
||||
# Usage: comet-state.sh <subcommand> <change-name> [args...]
|
||||
#
|
||||
# Subcommands:
|
||||
# init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults
|
||||
# get <change-name> <field> — Read a field value from .comet.yaml
|
||||
# set <change-name> <field> <val> — Update a field value
|
||||
# transition <change-name> <event> — Apply a validated state transition
|
||||
# check <change-name> <phase> — Verify entry requirements for a phase
|
||||
# check <change-name> <phase> --recover — Output structured recovery context for compaction resume
|
||||
# scale <change-name> — Assess and set verification mode based on metrics
|
||||
#
|
||||
# Workflows: full, hotfix, tweak
|
||||
# Phases for check: open, design, build, verify, archive
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Color output helpers ---
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
yellow() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
# --- Script location ---
|
||||
|
||||
# shellcheck disable=SC2034
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# --- Input validation ---
|
||||
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
# Reject empty names
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'" >&2
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Reject path traversal attempts
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_enum() {
|
||||
local value="$1"
|
||||
shift
|
||||
local valid_values=("$@")
|
||||
|
||||
for valid in "${valid_values[@]}"; do
|
||||
if [ "$value" = "$valid" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
red "ERROR: Invalid value: '$value'" >&2
|
||||
red "Valid values: ${valid_values[*]}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
yaml_field() {
|
||||
local field="$1"
|
||||
local yaml_file="$2"
|
||||
if [ -f "$yaml_file" ]; then
|
||||
local value
|
||||
value=$(grep "^${field}:" "$yaml_file" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\")
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
\'*\')
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
replace_yaml_field() {
|
||||
local yaml_file="$1"
|
||||
local field="$2"
|
||||
local value="$3"
|
||||
local tmp_file
|
||||
|
||||
tmp_file=$(mktemp)
|
||||
awk -v field="$field" -v value="$value" '
|
||||
index($0, field ":") == 1 { print field ": " value; next }
|
||||
{ print }
|
||||
' "$yaml_file" > "$tmp_file"
|
||||
mv "$tmp_file" "$yaml_file"
|
||||
}
|
||||
|
||||
file_nonempty() {
|
||||
[ -f "$1" ] && [ -s "$1" ]
|
||||
}
|
||||
|
||||
change_dir_for() {
|
||||
local change_name="$1"
|
||||
if [ -d "openspec/changes/$change_name" ]; then
|
||||
echo "openspec/changes/$change_name"
|
||||
elif [ -d "openspec/changes/archive/$change_name" ]; then
|
||||
echo "openspec/changes/archive/$change_name"
|
||||
else
|
||||
echo "openspec/changes/$change_name"
|
||||
fi
|
||||
}
|
||||
|
||||
yaml_file_for() {
|
||||
local change_name="$1"
|
||||
local change_dir
|
||||
change_dir=$(change_dir_for "$change_name")
|
||||
echo "$change_dir/.comet.yaml"
|
||||
}
|
||||
|
||||
# --- Subcommands ---
|
||||
|
||||
cmd_init() {
|
||||
local change_name="$1"
|
||||
local workflow="$2"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
validate_enum "$workflow" "full" "hotfix" "tweak"
|
||||
|
||||
local change_dir yaml_file
|
||||
change_dir=$(change_dir_for "$change_name")
|
||||
yaml_file=$(yaml_file_for "$change_name")
|
||||
|
||||
# Check if .comet.yaml already exists
|
||||
if [ -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml already exists at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create change directory if it doesn't exist
|
||||
mkdir -p "$change_dir"
|
||||
|
||||
# Set workflow-appropriate defaults
|
||||
local phase build_mode isolation verify_mode
|
||||
phase="open"
|
||||
|
||||
case "$workflow" in
|
||||
full)
|
||||
build_mode="null"
|
||||
isolation="null"
|
||||
verify_mode="null"
|
||||
;;
|
||||
hotfix|tweak)
|
||||
build_mode="direct"
|
||||
isolation="branch"
|
||||
verify_mode="light"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Write .comet.yaml
|
||||
# Record current HEAD as base_ref for scale assessment fallback
|
||||
local base_ref="null"
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
base_ref=$(git rev-parse HEAD 2>/dev/null || echo "null")
|
||||
fi
|
||||
|
||||
cat > "$yaml_file" <<EOF
|
||||
workflow: $workflow
|
||||
phase: $phase
|
||||
build_mode: $build_mode
|
||||
isolation: $isolation
|
||||
verify_mode: $verify_mode
|
||||
base_ref: $base_ref
|
||||
design_doc: null
|
||||
plan: null
|
||||
verify_result: pending
|
||||
verification_report: null
|
||||
branch_status: pending
|
||||
created_at: $(date +%Y-%m-%d)
|
||||
verified_at: null
|
||||
archived: false
|
||||
EOF
|
||||
|
||||
green "Initialized: $yaml_file (workflow=$workflow)"
|
||||
}
|
||||
|
||||
cmd_get() {
|
||||
local change_name="$1"
|
||||
local field="$2"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
|
||||
local yaml_file
|
||||
yaml_file=$(yaml_file_for "$change_name")
|
||||
|
||||
# Check if .comet.yaml exists
|
||||
if [ ! -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml not found at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read and output the field value
|
||||
local value
|
||||
value=$(yaml_field "$field" "$yaml_file")
|
||||
echo "${value:-}"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local change_name="$1"
|
||||
local field="$2"
|
||||
local value="$3"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
|
||||
local yaml_file
|
||||
yaml_file=$(yaml_file_for "$change_name")
|
||||
|
||||
# Check if .comet.yaml exists
|
||||
if [ ! -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml not found at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate field name
|
||||
case "$field" in
|
||||
phase)
|
||||
yellow "WARNING: Setting 'phase' directly bypasses state machine constraints." >&2
|
||||
yellow " Consider using: comet-state.sh transition <change-name> <event>" >&2
|
||||
;;
|
||||
workflow|build_mode|isolation|verify_mode|verify_result|verification_report|branch_status|archived|design_doc|plan|verified_at|created_at|direct_override|build_command|verify_command|handoff_context|handoff_hash|base_ref)
|
||||
# Valid field
|
||||
;;
|
||||
*)
|
||||
red "ERROR: Unknown field: '$field'" >&2
|
||||
red "Valid fields:" >&2
|
||||
red " workflow, phase, design_doc, plan, build_mode, isolation," >&2
|
||||
red " verify_mode, verify_result, verification_report, branch_status," >&2
|
||||
red " verified_at, created_at, archived, base_ref, direct_override," >&2
|
||||
red " build_command, verify_command, handoff_context, handoff_hash" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Validate enum values
|
||||
case "$field" in
|
||||
workflow)
|
||||
validate_enum "$value" "full" "hotfix" "tweak"
|
||||
;;
|
||||
phase)
|
||||
validate_enum "$value" "open" "design" "build" "verify" "archive"
|
||||
;;
|
||||
build_mode)
|
||||
validate_enum "$value" "subagent-driven-development" "executing-plans" "direct"
|
||||
;;
|
||||
isolation)
|
||||
validate_enum "$value" "branch" "worktree"
|
||||
;;
|
||||
verify_mode)
|
||||
validate_enum "$value" "light" "full"
|
||||
;;
|
||||
verify_result)
|
||||
validate_enum "$value" "pending" "pass" "fail"
|
||||
;;
|
||||
branch_status)
|
||||
validate_enum "$value" "pending" "handled"
|
||||
;;
|
||||
archived)
|
||||
validate_enum "$value" "true" "false"
|
||||
;;
|
||||
direct_override)
|
||||
validate_enum "$value" "true" "false"
|
||||
;;
|
||||
design_doc|plan|verification_report|verified_at|created_at|build_command|verify_command|handoff_context|handoff_hash)
|
||||
# No validation for path fields, date fields, or project command strings
|
||||
;;
|
||||
esac
|
||||
|
||||
# Write or update the field
|
||||
if grep -q "^${field}:" "$yaml_file"; then
|
||||
replace_yaml_field "$yaml_file" "$field" "$value"
|
||||
else
|
||||
# Field doesn't exist, append it
|
||||
echo "${field}: ${value}" >> "$yaml_file"
|
||||
fi
|
||||
|
||||
green "[SET] ${field}=${value}"
|
||||
}
|
||||
|
||||
require_phase() {
|
||||
local change_name="$1"
|
||||
local expected="$2"
|
||||
local actual
|
||||
actual=$(cmd_get "$change_name" "phase")
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
red "ERROR: Cannot transition '$change_name': expected phase ${expected}, got ${actual}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_verification_evidence() {
|
||||
local change_name="$1"
|
||||
local report branch_status
|
||||
report=$(cmd_get "$change_name" "verification_report")
|
||||
branch_status=$(cmd_get "$change_name" "branch_status")
|
||||
|
||||
if [ -z "$report" ] || [ "$report" = "null" ] || [ ! -f "$report" ]; then
|
||||
red "ERROR: Cannot transition '$change_name': verification_report must point to an existing report file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$branch_status" != "handled" ]; then
|
||||
red "ERROR: Cannot transition '$change_name': branch_status must be handled" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_build_decisions() {
|
||||
local change_name="$1"
|
||||
local workflow build_mode isolation direct_override
|
||||
workflow=$(cmd_get "$change_name" "workflow")
|
||||
build_mode=$(cmd_get "$change_name" "build_mode")
|
||||
isolation=$(cmd_get "$change_name" "isolation")
|
||||
direct_override=$(cmd_get "$change_name" "direct_override" 2>/dev/null || true)
|
||||
|
||||
case "$isolation" in
|
||||
branch|worktree) ;;
|
||||
*)
|
||||
red "ERROR: Cannot transition '$change_name': isolation must be branch or worktree, got '${isolation:-null}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$build_mode" in
|
||||
subagent-driven-development|executing-plans|direct) ;;
|
||||
*)
|
||||
red "ERROR: Cannot transition '$change_name': build_mode must be selected before leaving build, got '${build_mode:-null}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$build_mode" = "direct" ] && [ "$workflow" != "hotfix" ] && [ "$workflow" != "tweak" ] && [ "$direct_override" != "true" ]; then
|
||||
red "ERROR: Cannot transition '$change_name': build_mode=direct is only allowed for hotfix/tweak unless direct_override=true" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_transition() {
|
||||
local change_name="$1"
|
||||
local event="$2"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
validate_enum "$event" "open-complete" "design-complete" "build-complete" "verify-pass" "verify-fail" "archived"
|
||||
|
||||
case "$event" in
|
||||
open-complete)
|
||||
require_phase "$change_name" "open"
|
||||
local workflow
|
||||
workflow=$(cmd_get "$change_name" "workflow")
|
||||
if [ "$workflow" = "full" ]; then
|
||||
cmd_set "$change_name" phase design
|
||||
else
|
||||
cmd_set "$change_name" phase build
|
||||
fi
|
||||
;;
|
||||
design-complete)
|
||||
require_phase "$change_name" "design"
|
||||
cmd_set "$change_name" phase build
|
||||
;;
|
||||
build-complete)
|
||||
require_phase "$change_name" "build"
|
||||
require_build_decisions "$change_name"
|
||||
cmd_set "$change_name" phase verify
|
||||
cmd_set "$change_name" verify_result pending
|
||||
cmd_set "$change_name" verification_report null
|
||||
cmd_set "$change_name" branch_status pending
|
||||
;;
|
||||
verify-pass)
|
||||
require_phase "$change_name" "verify"
|
||||
require_verification_evidence "$change_name"
|
||||
cmd_set "$change_name" verify_result pass
|
||||
cmd_set "$change_name" phase archive
|
||||
cmd_set "$change_name" verified_at "$(date +%Y-%m-%d)"
|
||||
;;
|
||||
verify-fail)
|
||||
require_phase "$change_name" "verify"
|
||||
cmd_set "$change_name" verify_result fail
|
||||
cmd_set "$change_name" phase build
|
||||
cmd_set "$change_name" branch_status pending
|
||||
;;
|
||||
archived)
|
||||
require_phase "$change_name" "archive"
|
||||
cmd_set "$change_name" archived true
|
||||
;;
|
||||
esac
|
||||
|
||||
green "[TRANSITION] ${event}"
|
||||
}
|
||||
|
||||
# --- Check helpers for entry verification ---
|
||||
|
||||
CHECK_BLOCK=0
|
||||
|
||||
check_pass() {
|
||||
local msg="$1"
|
||||
echo " $(green "[PASS]") $msg"
|
||||
}
|
||||
|
||||
check_fail() {
|
||||
local msg="$1"
|
||||
echo " $(red "[FAIL]") $msg"
|
||||
CHECK_BLOCK=1
|
||||
}
|
||||
|
||||
check_nonempty() {
|
||||
local desc="$1"
|
||||
local path="$2"
|
||||
if file_nonempty "$path"; then
|
||||
check_pass "$desc non-empty"
|
||||
else
|
||||
check_fail "$desc missing or empty"
|
||||
fi
|
||||
}
|
||||
|
||||
check_yaml_is() {
|
||||
local field="$1"
|
||||
local expected="$2"
|
||||
local change_name="$3"
|
||||
local actual
|
||||
actual=$(cmd_get "$change_name" "$field")
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
check_pass "${field}=${actual} (expected: ${expected})"
|
||||
else
|
||||
check_fail "${field}=${actual} (expected: ${expected})"
|
||||
fi
|
||||
}
|
||||
|
||||
check_yaml_empty() {
|
||||
local field="$1"
|
||||
local change_name="$2"
|
||||
local value
|
||||
value=$(cmd_get "$change_name" "$field")
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
check_pass "${field} is empty/null"
|
||||
else
|
||||
check_fail "${field}=${value} (expected: empty/null)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_file_not_exists() {
|
||||
local desc="$1"
|
||||
local path="$2"
|
||||
if [ ! -f "$path" ]; then
|
||||
check_pass "$desc does not exist"
|
||||
else
|
||||
check_fail "$desc exists (should not exist)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
local change_name="$1"
|
||||
local phase="$2"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
validate_enum "$phase" "open" "design" "build" "verify" "archive"
|
||||
|
||||
local change_dir="openspec/changes/$change_name"
|
||||
local yaml_file="$change_dir/.comet.yaml"
|
||||
local proposal_file="$change_dir/proposal.md"
|
||||
local design_file="$change_dir/design.md"
|
||||
local tasks_file="$change_dir/tasks.md"
|
||||
|
||||
echo "=== Entry Check: comet-${phase} ==="
|
||||
|
||||
# .comet.yaml must exist for all phases (state machine core)
|
||||
if [ ! -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml not found at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Phase-specific checks
|
||||
case "$phase" in
|
||||
open)
|
||||
check_pass ".comet.yaml exists"
|
||||
check_yaml_is "phase" "open" "$change_name"
|
||||
;;
|
||||
design)
|
||||
check_pass ".comet.yaml exists"
|
||||
check_yaml_is "phase" "design" "$change_name"
|
||||
check_yaml_is "workflow" "full" "$change_name"
|
||||
check_yaml_empty "design_doc" "$change_name"
|
||||
check_nonempty "proposal.md" "$proposal_file"
|
||||
check_nonempty "design.md" "$design_file"
|
||||
check_nonempty "tasks.md" "$tasks_file"
|
||||
;;
|
||||
build)
|
||||
check_pass ".comet.yaml exists"
|
||||
check_yaml_is "phase" "build" "$change_name"
|
||||
# design_doc required for full workflow only
|
||||
local workflow
|
||||
workflow=$(cmd_get "$change_name" "workflow")
|
||||
if [ "$workflow" = "full" ]; then
|
||||
local design_doc
|
||||
design_doc=$(cmd_get "$change_name" "design_doc")
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
|
||||
check_pass "design_doc=${design_doc} (file exists)"
|
||||
else
|
||||
check_fail "design_doc=${design_doc} (expected: non-null and file exists)"
|
||||
fi
|
||||
else
|
||||
check_pass "workflow=${workflow} (design_doc not required)"
|
||||
fi
|
||||
check_nonempty "proposal.md" "$proposal_file"
|
||||
check_nonempty "tasks.md" "$tasks_file"
|
||||
;;
|
||||
verify)
|
||||
check_pass ".comet.yaml exists"
|
||||
check_yaml_is "phase" "verify" "$change_name"
|
||||
# Check verify_result is pending or null
|
||||
local verify_result
|
||||
verify_result=$(cmd_get "$change_name" "verify_result")
|
||||
if [ "$verify_result" = "pending" ] || [ -z "$verify_result" ] || [ "$verify_result" = "null" ]; then
|
||||
check_pass "verify_result=${verify_result} (expected: pending or null)"
|
||||
else
|
||||
check_fail "verify_result=${verify_result} (expected: pending or null)"
|
||||
fi
|
||||
;;
|
||||
archive)
|
||||
check_pass ".comet.yaml exists"
|
||||
check_yaml_is "phase" "archive" "$change_name"
|
||||
check_yaml_is "verify_result" "pass" "$change_name"
|
||||
# Check archived is NOT true
|
||||
local archived
|
||||
archived=$(cmd_get "$change_name" "archived")
|
||||
if [ "$archived" != "true" ]; then
|
||||
check_pass "archived=${archived} (expected: not true)"
|
||||
else
|
||||
check_fail "archived=${archived} (expected: not true)"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
red "ERROR: Unknown phase for check: $phase"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
if [ "$CHECK_BLOCK" -eq 1 ]; then
|
||||
red "BLOCKED — fix failing checks before proceeding"
|
||||
exit 1
|
||||
else
|
||||
green "ALL CHECKS PASSED — ready to proceed"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Recovery context for compaction resume ---
|
||||
|
||||
field_status() {
|
||||
# Args: field_name value [file_path]
|
||||
# Prints: "field_name: DONE (value)" or "field_name: PENDING"
|
||||
local field="$1"
|
||||
local value="$2"
|
||||
local file_path="${3:-}"
|
||||
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
echo " - ${field}: PENDING"
|
||||
elif [ -n "$file_path" ] && [ ! -f "$file_path" ]; then
|
||||
echo " - ${field}: BROKEN (path ${value} does not exist)"
|
||||
else
|
||||
echo " - ${field}: DONE (${value})"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_recover() {
|
||||
local change_name="$1"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
|
||||
local change_dir="openspec/changes/$change_name"
|
||||
local yaml_file="$change_dir/.comet.yaml"
|
||||
|
||||
if [ ! -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml not found at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local phase workflow
|
||||
phase=$(cmd_get "$change_name" "phase")
|
||||
workflow=$(cmd_get "$change_name" "workflow")
|
||||
|
||||
echo "=== Recovery Context: ${change_name} ==="
|
||||
echo "Phase: ${phase}"
|
||||
echo "Workflow: ${workflow}"
|
||||
echo ""
|
||||
|
||||
# Read all relevant fields
|
||||
local design_doc plan verify_result verify_mode verification_report
|
||||
local branch_status handoff_context handoff_hash isolation build_mode direct_override
|
||||
design_doc=$(cmd_get "$change_name" "design_doc")
|
||||
plan=$(cmd_get "$change_name" "plan")
|
||||
verify_result=$(cmd_get "$change_name" "verify_result")
|
||||
verify_mode=$(cmd_get "$change_name" "verify_mode")
|
||||
verification_report=$(cmd_get "$change_name" "verification_report")
|
||||
branch_status=$(cmd_get "$change_name" "branch_status")
|
||||
handoff_context=$(cmd_get "$change_name" "handoff_context")
|
||||
handoff_hash=$(cmd_get "$change_name" "handoff_hash")
|
||||
isolation=$(cmd_get "$change_name" "isolation")
|
||||
build_mode=$(cmd_get "$change_name" "build_mode")
|
||||
direct_override=$(cmd_get "$change_name" "direct_override" 2>/dev/null || true)
|
||||
|
||||
echo "State fields:"
|
||||
|
||||
# Phase-specific field reporting
|
||||
case "$phase" in
|
||||
open)
|
||||
echo " Artifacts:"
|
||||
for f in proposal.md design.md tasks.md; do
|
||||
if file_nonempty "$change_dir/$f"; then
|
||||
echo " - ${f}: DONE"
|
||||
else
|
||||
echo " - ${f}: PENDING"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "Recovery action: Create or complete missing artifacts, then use AskUserQuestion for user confirmation."
|
||||
;;
|
||||
design)
|
||||
echo " Artifacts:"
|
||||
for f in proposal.md design.md tasks.md; do
|
||||
if file_nonempty "$change_dir/$f"; then
|
||||
echo " - ${f}: DONE"
|
||||
else
|
||||
echo " - ${f}: MISSING (unexpected in design phase)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Design progress:"
|
||||
field_status "handoff_context" "$handoff_context" "$handoff_context"
|
||||
field_status "handoff_hash" "$handoff_hash"
|
||||
field_status "design_doc" "$design_doc" "$design_doc"
|
||||
echo ""
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
|
||||
echo "Recovery action: Design Doc already created and linked. Run guard to transition to build."
|
||||
elif [ -n "$handoff_context" ] && [ "$handoff_context" != "null" ] && [ -f "$handoff_context" ]; then
|
||||
echo "Recovery action: Handoff generated but Design Doc not yet created. Resume from brainstorming confirmation (Step 1c)."
|
||||
else
|
||||
echo "Recovery action: No handoff generated yet. Start from Step 1a (generate handoff package)."
|
||||
fi
|
||||
;;
|
||||
build)
|
||||
echo " Build decisions:"
|
||||
field_status "isolation" "$isolation"
|
||||
field_status "build_mode" "$build_mode"
|
||||
if [ "$build_mode" = "direct" ] && [ "$workflow" != "hotfix" ] && [ "$workflow" != "tweak" ]; then
|
||||
field_status "direct_override" "$direct_override"
|
||||
fi
|
||||
echo ""
|
||||
echo " Plan:"
|
||||
field_status "plan" "$plan" "$plan"
|
||||
echo ""
|
||||
# Count completed vs pending tasks
|
||||
local tasks_file="$change_dir/tasks.md"
|
||||
local total=0 done=0 pending=0
|
||||
if [ -f "$tasks_file" ]; then
|
||||
total=$(grep -c '^\- \[' "$tasks_file" 2>/dev/null || echo "0")
|
||||
done=$(grep -c '^\- \[x\]' "$tasks_file" 2>/dev/null || echo "0")
|
||||
pending=$((total - done))
|
||||
echo " Tasks: ${done}/${total} done, ${pending} pending"
|
||||
else
|
||||
echo " Tasks: tasks.md MISSING"
|
||||
fi
|
||||
echo ""
|
||||
if [ "$isolation" = "null" ] || [ -z "$isolation" ]; then
|
||||
echo "Recovery action: Isolation not selected. Use AskUserQuestion to ask user for branch/worktree choice."
|
||||
elif [ "$build_mode" = "null" ] || [ -z "$build_mode" ]; then
|
||||
echo "Recovery action: Build mode not selected. Use AskUserQuestion to ask user for execution method."
|
||||
elif [ ! -f "$tasks_file" ]; then
|
||||
echo "Recovery action: tasks.md missing. Verify change directory integrity."
|
||||
elif [ "$pending" -gt 0 ]; then
|
||||
echo "Recovery action: Read tasks.md and continue from first unchecked task."
|
||||
else
|
||||
echo "Recovery action: All tasks done. Run guard to transition to verify."
|
||||
fi
|
||||
;;
|
||||
verify)
|
||||
echo " Verification:"
|
||||
field_status "verify_result" "$verify_result"
|
||||
field_status "verify_mode" "$verify_mode"
|
||||
field_status "verification_report" "$verification_report" "$verification_report"
|
||||
field_status "branch_status" "$branch_status"
|
||||
echo ""
|
||||
if [ "$verify_result" = "pass" ] && [ "$branch_status" = "handled" ]; then
|
||||
echo "Recovery action: Verification complete. Run guard to transition to archive."
|
||||
elif [ "$verify_result" = "pass" ]; then
|
||||
echo "Recovery action: Verification passed but branch not yet handled. Complete branch handling and set branch_status to handled."
|
||||
elif [ "$verify_result" = "fail" ]; then
|
||||
echo "Recovery action: Verification failed and rolled back to build. Resume from /comet-build."
|
||||
else
|
||||
echo "Recovery action: Verification not yet started or in progress. Run scale assessment then verify."
|
||||
fi
|
||||
;;
|
||||
archive)
|
||||
echo " Archive:"
|
||||
field_status "verify_result" "$verify_result"
|
||||
field_status "archived" "$(cmd_get "$change_name" "archived")"
|
||||
echo ""
|
||||
echo "Recovery action: Run /comet-archive to complete archiving."
|
||||
;;
|
||||
*)
|
||||
red "ERROR: Unknown phase: $phase"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=== End Recovery Context ==="
|
||||
}
|
||||
|
||||
cmd_scale() {
|
||||
local change_name="$1"
|
||||
|
||||
validate_change_name "$change_name"
|
||||
|
||||
local change_dir="openspec/changes/$change_name"
|
||||
local yaml_file="$change_dir/.comet.yaml"
|
||||
|
||||
# Verify .comet.yaml exists
|
||||
if [ ! -f "$yaml_file" ]; then
|
||||
red "ERROR: .comet.yaml not found at $yaml_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read metrics
|
||||
# 1. Task count: count lines matching `- [` in tasks.md
|
||||
local tasks_file="$change_dir/tasks.md"
|
||||
local task_count=0
|
||||
if [ -f "$tasks_file" ]; then
|
||||
task_count=$(grep -c '^\- \[' "$tasks_file" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# 2. Delta spec count: count files named spec.md under specs/*/spec.md
|
||||
local delta_spec_count=0
|
||||
if [ -d "$change_dir/specs" ]; then
|
||||
delta_spec_count=$(find "$change_dir/specs" -name "spec.md" -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
# 3. Changed files: prefer plan base-ref, then .comet.yaml base_ref, fall back to worktree diff
|
||||
local changed_files=0
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
local plan_file base_ref=""
|
||||
plan_file=$(cmd_get "$change_name" "plan" 2>/dev/null || true)
|
||||
if [ -n "$plan_file" ] && [ "$plan_file" != "null" ] && [ -f "$plan_file" ]; then
|
||||
base_ref=$(grep '^base-ref:' "$plan_file" 2>/dev/null | head -1 | sed 's/^base-ref: *//')
|
||||
fi
|
||||
# Fallback to base_ref stored in .comet.yaml (set during init)
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "null" ]; then
|
||||
base_ref=$(cmd_get "$change_name" "base_ref" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "${base_ref:-}" ] && [ "$base_ref" != "null" ] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
|
||||
changed_files=$(git diff --name-only "$base_ref"...HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
else
|
||||
changed_files=$(git diff --name-only HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
fi
|
||||
|
||||
# Decision rules
|
||||
local result="light"
|
||||
if [ "$task_count" -gt 3 ] || [ "$delta_spec_count" -gt 1 ] || [ "$changed_files" -gt 4 ]; then
|
||||
result="full"
|
||||
fi
|
||||
|
||||
# Output assessment to stderr
|
||||
echo "=== Scale Assessment: $change_name ===" >&2
|
||||
echo " Tasks: $task_count (threshold: 3)" >&2
|
||||
echo " Delta specs: $delta_spec_count capabilities (threshold: 1)" >&2
|
||||
echo " Changed files: $changed_files (threshold: 4)" >&2
|
||||
echo " → Result: $result" >&2
|
||||
|
||||
# Update verify_mode in .comet.yaml
|
||||
replace_yaml_field "$yaml_file" "verify_mode" "$result"
|
||||
|
||||
green "[SCALE] verify_mode=$result"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
SUBCOMMAND="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "$SUBCOMMAND" in
|
||||
init)
|
||||
if [ $# -lt 2 ]; then
|
||||
red "Usage: comet-state.sh init <change-name> <workflow>" >&2
|
||||
red "Workflows: full, hotfix, tweak" >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd_init "$@"
|
||||
;;
|
||||
get)
|
||||
if [ $# -lt 2 ]; then
|
||||
red "Usage: comet-state.sh get <change-name> <field>" >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd_get "$@"
|
||||
;;
|
||||
set)
|
||||
if [ $# -lt 3 ]; then
|
||||
red "Usage: comet-state.sh set <change-name> <field> <value>" >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd_set "$@"
|
||||
;;
|
||||
transition)
|
||||
if [ $# -lt 2 ]; then
|
||||
red "Usage: comet-state.sh transition <change-name> <event>" >&2
|
||||
red "Events: open-complete, design-complete, build-complete, verify-pass, verify-fail, archived" >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd_transition "$@"
|
||||
;;
|
||||
check)
|
||||
if [ $# -lt 2 ]; then
|
||||
red "Usage: comet-state.sh check <change-name> <phase> [--recover]" >&2
|
||||
red "Phases: open, design, build, verify, archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Detect --recover flag (3rd argument)
|
||||
if [ "${3:-}" = "--recover" ]; then
|
||||
cmd_recover "$1"
|
||||
else
|
||||
cmd_check "$@"
|
||||
fi
|
||||
;;
|
||||
scale)
|
||||
if [ $# -lt 1 ]; then
|
||||
red "Usage: comet-state.sh scale <change-name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd_scale "$@"
|
||||
;;
|
||||
*)
|
||||
red "Unknown subcommand: $SUBCOMMAND" >&2
|
||||
echo "" >&2
|
||||
echo "Usage: comet-state.sh <subcommand> <change-name> [args...]" >&2
|
||||
echo "" >&2
|
||||
echo "Subcommands:" >&2
|
||||
echo " init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults" >&2
|
||||
echo " get <change-name> <field> — Read a field value from .comet.yaml" >&2
|
||||
echo " set <change-name> <field> <val> — Update a field value in .comet.yaml" >&2
|
||||
echo " transition <change-name> <event> — Apply a validated state transition" >&2
|
||||
echo " check <change-name> <phase> — Verify entry requirements for a phase" >&2
|
||||
echo " scale <change-name> — Assess and set verification mode based on metrics" >&2
|
||||
echo "" >&2
|
||||
echo "Workflows: full, hotfix, tweak" >&2
|
||||
echo "Phases for check: open, design, build, verify, archive" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
# Comet YAML Schema Validator — validates .comet.yaml structure
|
||||
# Usage: comet-yaml-validate.sh <change-name>
|
||||
# Exit 0 = valid, exit 1 = errors found (printed to stderr)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
warn() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
# Input validation - prevent path traversal
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
# Reject empty names
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'" >&2
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Reject path traversal attempts
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_change_name "$1"
|
||||
|
||||
CHANGE="$1"
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
if [ ! -d "$CHANGE_DIR" ] && [ -d "openspec/changes/archive/$CHANGE" ]; then
|
||||
CHANGE_DIR="openspec/changes/archive/$CHANGE"
|
||||
fi
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Helper: get value of a top-level field (handles null, empty, quoted)
|
||||
field_value() {
|
||||
local value
|
||||
value=$(grep "^${1}:" "$YAML" 2>/dev/null | sed "s/^${1}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\")
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
\'*\')
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
fail() { red " FAIL: $1"; ERRORS=$((ERRORS + 1)); }
|
||||
warn_msg() { warn " WARN: $1"; WARNINGS=$((WARNINGS + 1)); }
|
||||
|
||||
echo "[VALIDATE] $YAML" >&2
|
||||
|
||||
# --- Required fields ---
|
||||
REQUIRED_FIELDS="workflow phase design_doc plan build_mode isolation verify_mode verify_result verified_at archived"
|
||||
for field in $REQUIRED_FIELDS; do
|
||||
if ! grep -q "^${field}:" "$YAML" 2>/dev/null; then
|
||||
fail "missing required field '$field'"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Enum validation ---
|
||||
validate_enum() {
|
||||
local field="$1" value="$2"
|
||||
shift 2
|
||||
local valid_values="$*"
|
||||
|
||||
# null or empty is always acceptable
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for v in $valid_values; do
|
||||
if [ "$value" = "$v" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fail "$field='$value' is not valid. Expected: $valid_values"
|
||||
}
|
||||
|
||||
workflow=$(field_value "workflow")
|
||||
phase=$(field_value "phase")
|
||||
build_mode=$(field_value "build_mode")
|
||||
isolation=$(field_value "isolation")
|
||||
verify_mode=$(field_value "verify_mode")
|
||||
verify_result=$(field_value "verify_result")
|
||||
branch_status=$(field_value "branch_status")
|
||||
archived=$(field_value "archived")
|
||||
direct_override=$(field_value "direct_override")
|
||||
design_doc=$(field_value "design_doc")
|
||||
plan=$(field_value "plan")
|
||||
handoff_context=$(field_value "handoff_context")
|
||||
handoff_hash=$(field_value "handoff_hash")
|
||||
|
||||
validate_enum "workflow" "$workflow" "full hotfix tweak"
|
||||
validate_enum "phase" "$phase" "open design build verify archive"
|
||||
validate_enum "build_mode" "$build_mode" "subagent-driven-development executing-plans direct"
|
||||
validate_enum "isolation" "$isolation" "branch worktree"
|
||||
validate_enum "verify_mode" "$verify_mode" "light full"
|
||||
validate_enum "verify_result" "$verify_result" "pending pass fail"
|
||||
validate_enum "branch_status" "$branch_status" "pending handled"
|
||||
validate_enum "archived" "$archived" "true false"
|
||||
validate_enum "direct_override" "$direct_override" "true false"
|
||||
|
||||
# --- Path validation ---
|
||||
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ]; then
|
||||
if [ ! -f "$design_doc" ]; then
|
||||
fail "design_doc='$design_doc' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$plan" ] && [ "$plan" != "null" ]; then
|
||||
if [ ! -f "$plan" ]; then
|
||||
fail "plan='$plan' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$handoff_context" ] && [ "$handoff_context" != "null" ]; then
|
||||
if [ ! -f "$handoff_context" ]; then
|
||||
fail "handoff_context='$handoff_context' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$handoff_hash" ] && [ "$handoff_hash" != "null" ]; then
|
||||
if [[ ! "$handoff_hash" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
fail "handoff_hash='$handoff_hash' is not a sha256 hex digest"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Unknown keys check ---
|
||||
KNOWN_KEYS="workflow phase design_doc plan build_mode isolation verify_mode verify_result verification_report branch_status verified_at created_at archived direct_override build_command verify_command handoff_context handoff_hash base_ref"
|
||||
while IFS=: read -r key _; do
|
||||
key="${key// /}"
|
||||
[ -z "$key" ] && continue
|
||||
found=0
|
||||
for known in $KNOWN_KEYS; do
|
||||
[ "$key" = "$known" ] && found=1 && break
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
warn_msg "unknown field '$key' found"
|
||||
fi
|
||||
done < "$YAML"
|
||||
|
||||
# --- Summary ---
|
||||
echo "" >&2
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
red "$ERRORS error(s), $WARNINGS warning(s) — validation FAILED"
|
||||
exit 1
|
||||
else
|
||||
green "0 errors, $WARNINGS warning(s) — validation PASSED"
|
||||
exit 0
|
||||
fi
|
||||
Reference in New Issue
Block a user