diff --git a/.gitignore b/.gitignore index 536a5b3..5ef5f56 100644 --- a/.gitignore +++ b/.gitignore @@ -2,27 +2,28 @@ # mancode .gitignore # =================================== -# 内部开发文档(包含借鉴对象名,不公开) -DEVELOPMENT.md +# 内部法律文档(包含借鉴对象名,不公开) LEGAL.md *.backup -# 设计文档(内部参考,不公开) +# 历史设计文档默认不公开;当前文档白名单见下方 docs/ # Claude Code 开发配置(本地测试用) .claude/ -# 技术实现文档(包含借鉴对象名和完整实现细节,不公开) -docs/12-lifecycle.md -docs/13-scanning.md -docs/14-orchestration.md -docs/15-adapters.md - -# 公开优化决策文档:PostToolUse P0 设计决策需要随代码提交 +# 当前维护文档 !docs/ docs/* +!docs/README.md +!docs/architecture.md +!docs/workflows.md +!docs/project-intelligence.md +!docs/platform-adapters.md +!docs/engineering.md !docs/12-lifecycle.md +!docs/release-acceptance.md +!docs/planning-governance-evolution.md # Node.js node_modules/ @@ -94,6 +95,5 @@ output/ # Demo projects (local only, not published) examples/ -# Local-only publishing and planning documents +# Local-only publishing configuration .npmrc -CONTEXT_TEAM_REFACTOR_PLAN.md diff --git a/README.en.md b/README.en.md index c86082b..1f5390d 100644 --- a/README.en.md +++ b/README.en.md @@ -17,7 +17,7 @@

License: AGPL-3.0 npm version - Status: mancode Continuity v0.3.18 + Status: mancode Continuity v0.4.0 Platforms: Claude Code, Cursor, Codex in ChatGPT desktop and CLI, GitHub Copilot, ZCode

@@ -34,8 +34,9 @@ different gears for different stakes: light solo mode for daily practice, `/man` for playoff-level engineering discipline, and coaching-staff subagents for research, planning, implementation, and review. -**mancode Continuity** is the continuous-context runtime that carries tasks, -decisions, and verification evidence safely into later conversations. +**mancode Continuity** is the cross-session and team-collaboration runtime that +carries tasks, decisions, and verification evidence safely into later +conversations. [Installation](#installation) · [Usage](#usage) @@ -107,10 +108,15 @@ one window's temporary state as another window's identity. ```bash mancode status --brief --json mancode context session new --client claude-code +mancode context session show --session --client claude-code --json mancode context resume --session --client claude-code mancode context show --purpose orient --session --client claude-code ``` +When the project has a local mancode install, pin one CLI binary for the whole +task: prefer `./node_modules/.bin/mancode`, otherwise use `mancode`. Check the +selected binary with `--version` once and do not mix versions afterward. + The original `/man`, `/manba`, and `/manteam` entries handle these steps. The CLI form above is useful for diagnostics, automation, or manual recovery. @@ -234,7 +240,9 @@ design tokens: ``` -The default workflow asks six questions before writing code: +The default workflow evaluates a conditional clarity gate before writing code: it asks the user +only when an unresolved decision could change the goal, scope, behavior, acceptance, or key +constraints, and it proceeds without ceremonial questions when the request is already clear. 1. What problem does this change solve? 2. Can an existing implementation be reused? @@ -243,6 +251,10 @@ The default workflow asks six questions before writing code: 5. What is the smallest meaningful runtime check? 6. What remains uncertain after checking the code and docs? +These are internal readiness checks, not a mandatory questionnaire. Any unresolved +decision-changing uncertainty becomes a focused question and blocks requirements finalization +until the user answers; clear requests continue directly. + ## Usage @@ -353,7 +365,7 @@ it should behave, and why previous decisions were made. ## Installation -**Status**: mancode Continuity v0.3.18. Claude Code, Cursor, Codex in the ChatGPT +**Status**: mancode Continuity v0.4.0. Claude Code, Cursor, Codex in the ChatGPT desktop app and CLI, GitHub Copilot, and ZCode adapters are included. Requires Node.js 20 or newer. macOS, Linux, Windows CMD, PowerShell, and Git Bash @@ -394,8 +406,9 @@ mancode init --platform PLATFORMS # One or more: claude-code,cursor,codex,copilo mancode init --empty # Allow a safe empty directory in non-interactive scripts mancode init --lang zh-CN # Explicit initialization language (zh-CN or en) mancode refresh-project # Refresh facts after Git or project files are added -mancode install --force # Repair or reinstall the selected adapter -mancode install --minimal # The bootstrap is already minimal; retained for compatibility +mancode adapter status --json # Inspect actual managed-content digests +mancode adapter upgrade --platform codex --dry-run # Stage a preview only +mancode adapter upgrade --platform codex --confirm --operation-id --session --client ``` ## Agent Modes @@ -424,10 +437,16 @@ mancode init --legacy mancode status mancode status --json mancode status --brief --json -mancode install +mancode install --confirm --operation-id --session --client +mancode adapter status [--platform ] --json +mancode adapter upgrade <--all|--platform > --dry-run +mancode adapter upgrade <--all|--platform > --confirm --operation-id --session --client +mancode project upgrade --policy 2 --dry-run +mancode project upgrade --policy 2 --operation-id --session --client mancode list-platforms mancode team identity create --name "" mancode context session new --client +mancode context session show --session --client --json mancode workflow create "" --session mancode workflow list --json mancode workflow show --json @@ -438,6 +457,7 @@ mancode workflow plan confirm --plan-decision --status --expected-revision --session mancode workflow review apply --file --expected-revision --session mancode workflow verify apply --file --expected-revision --session +mancode workflow reframe --expected-revision --checkpoint-id --session mancode workflow complete --expected-revision --session mancode manps [area] mancode refresh-project @@ -452,7 +472,7 @@ mancode version Simplified output: ```text -mancode v0.3.18 +mancode v0.4.0 Project: my-app Runtime: ready @@ -594,14 +614,19 @@ mancode/ - Irreversible operations such as force pushes, schema migrations, and bulk deletes require explicit human confirmation. -## Roadmap +## Remaining Work + +- Complete real-host session acceptance for Claude Code, Codex, Cursor, + GitHub Copilot, and ZCode on one release candidate; verified host sessions + and isolated explicit sessions are both valid evidence paths. +- Run `npm run release:check -- --candidate ` for the clean + checkout, automated two-clone/legacy checks, tarball SHA-256, and install + smoke; complete cross-host recovery separately. +- Confirm ZCode project-skill discovery and workspace-command paths; keep the + adapter provisional until then. +- Evaluate Windsurf, Cline, and Roo Code adapters based on real demand. -| Phase | Focus | -|---|---| -| MVP-1 | solo mode, aesthetics, and Claude Code hooks | -| MVP-2 | `/manba`, `/man`, `/manteam`, `/manps`, and coaching-staff subagents | -| MVP-3 | Cursor, Codex (ChatGPT desktop/CLI), and GitHub Copilot adapters | -| Public Release | stable npm release, marketplace distribution, docs, and demos | +See [0.4.0 Continuity Release Acceptance](./docs/release-acceptance.md) for the complete gate. ## Troubleshooting @@ -616,21 +641,26 @@ a deliberately empty directory. ### Claude Code hooks not triggering After `mancode init`, restart Claude Code so it reloads `.claude/settings.json`. -Run `mancode status` to verify hooks are registered. If hooks are still missing, -run `mancode install claude-code --force` to rewrite the settings. +Run `mancode status` to verify hooks are registered. For Continuity adapter drift, preview +`mancode adapter upgrade --platform claude-code --dry-run`, then repair that +preview with its `--operation-id`, an active session, and `--confirm`. The legacy +hook architecture still uses `mancode init --legacy --force`. ### `mancode status` shows a platform as "not ready" -This means the platform's target files are missing. Run -`mancode install --force` to regenerate them. For managed-block +This means the platform's target files are missing or their digest is stale. +Preview `mancode adapter upgrade --platform --dry-run`, inspect the +staged result, then run it with the returned `--operation-id`, an active session, +and `--confirm`. For managed-block platforms (Codex, ZCode, Copilot), the managed block in `AGENTS.md` or `.github/copilot-instructions.md` may have been manually edited or deleted. ### AGENTS.md or copilot-instructions.md managed block was accidentally deleted -Run `mancode install codex --force` (or `zcode`, or `copilot`) to reinsert the -managed block. User-authored content outside the relevant mancode managed -markers is preserved. +Preview `mancode adapter upgrade --platform codex --dry-run` (or `zcode`, or +`copilot`), then run it with the returned `--operation-id`, an active session, +and `--confirm` to reinsert the managed block. User-authored content outside the +relevant markers is preserved. ### ZCode skills not appearing @@ -648,16 +678,8 @@ description field — invoke them by asking for `/manba` or similar. ### How to reinstall Continuity adapters ```bash -mancode uninstall claude-code --force -mancode uninstall cursor --force -mancode uninstall codex --force -mancode uninstall copilot --force -mancode uninstall zcode --force -mancode install claude-code -mancode install cursor -mancode install codex -mancode install copilot -mancode install zcode +mancode adapter upgrade --all --dry-run +mancode adapter upgrade --all --confirm --operation-id --session --client ``` Continuity authority is protected, so `mancode uninstall --all` does not delete workflow @@ -684,8 +706,11 @@ use. ### How is mancode different from a CLAUDE.md file? -A `CLAUDE.md` file is static guidance. mancode adds hooks, persisted workflow -state, slash-command skills, and separate review subagents with clean context. +A `CLAUDE.md` file is static guidance. Continuity maintains one always-loaded +Claude Code bootstrap block there, while sessions, TaskRefs, requirements, +plans, and review evidence remain structured authority under `.mancode/`. +mancode also adds mode skills, persisted workflows, and separate review +subagents with clean context. ### How is mancode different from Cursor rules, custom prompts, or agent instructions? diff --git a/README.md b/README.md index d818221..dc61761 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

许可证:AGPL-3.0 npm 版本 - 状态:mancode Continuity v0.3.18 + 状态:mancode Continuity v0.4.0 平台:Claude Code、Cursor、ChatGPT 桌面端 Codex、Codex CLI、GitHub Copilot、ZCode

@@ -36,7 +36,7 @@ 日常任务用轻量 `solo`,关键任务用季后赛级别的 `/man`,复杂任务让教练组 subagents 负责调研、计划、实现和审查。 -**mancode Continuity(连续上下文运行时)**负责把任务、决策和验证证据安全地带到后续对话。 +**mancode Continuity(跨会话与团队协作运行时)**负责把任务、决策和验证证据安全地带到后续对话,并协调多人或多 Agent 的任务权威。 mancode 当前支持 Claude Code、Cursor、ChatGPT 桌面端中的 Codex、Codex CLI、 GitHub Copilot 和 ZCode。每个平台继续使用原来的 `man*` 入口,并通过静态 @@ -118,6 +118,16 @@ mancode context session new --client claude-code mancode list-platforms ``` +要只读检查一个 session(例如确认 Codex 续接时仍使用同一身份),使用: + +```bash +mancode context session show --session --client --json +``` + +在项目同时安装 mancode 时,整项任务应固定使用项目本地 CLI:优先 +`./node_modules/.bin/mancode`,否则再使用 PATH 中的 `mancode`;先运行一次 +`--version`,后续不要混用不同版本。 + 普通 `mancode init` 会生成原来的 `man`、`manba`、`manteam`、`manps`、 `mansolo` 平台命令,不需要改用另一套命令名。确实需要旧 `state.json` 架构时才使用 `mancode init --legacy`。 @@ -228,7 +238,8 @@ mancode 不把“当前模式”写进持久状态。需要某种工作方式时 `/man` 既是正式计划入口,也是面向关键任务的季后赛模式。即使当前处于默认 `solo`,当用户要求先调研、给方案或出计划时,也会进入 `/man`。它会先了解项目, -只追问会改变范围、架构、成本或验收的问题;适合由系统推荐的决策会给出 2–3 个 +只追问会改变范围、架构、成本或验收的问题;需求足够清晰时不制造形式问题,需求 +不清晰时会停下等待用户回答;适合由系统推荐的决策会给出 2–3 个 方案、优缺点和明确建议。需求足够清楚后,计划才会写入 `.mancode/local/workflows//plan.md`。 @@ -248,6 +259,10 @@ mancode 不把“当前模式”写进持久状态。需要某种工作方式时 跳过的步骤会被记录。所有产物保留在本地,之后可以回看当时为什么做某个决策。 +默认 `solo` 也执行同一个轻量清晰度判断:清晰、窄范围的需求直接做最小改动;会改变 +行为、范围、验收或关键约束的歧义必须先提问。涉及架构、owner/source of truth、迁移、 +跨模块或团队决策时,`solo` 推荐 `/man`,但不会自行切换模式。 + ## 工作原理 ### Bootstrap 和 Adapters @@ -307,7 +322,7 @@ src/components/ ## 安装 -**状态**:mancode Continuity v0.3.18。Claude Code、Cursor、ChatGPT 桌面端中的 +**状态**:mancode Continuity v0.4.0。Claude Code、Cursor、ChatGPT 桌面端中的 Codex、Codex CLI、GitHub Copilot 和 ZCode adapter 均已接入。 需要 Node.js 20 或更高版本。原生支持 macOS、Linux、Windows CMD、 @@ -346,8 +361,9 @@ mancode init --platform PLATFORMS # 一个或多个:claude-code,cursor,codex,c mancode init --empty # 非交互脚本中允许安全的空目录 mancode init --lang zh-CN # 显式指定初始化语言(zh-CN 或 en) mancode refresh-project # 后续加入 Git 或项目文件后刷新项目事实 -mancode install --force # 修复或重装所选 adapter -mancode install --minimal # bootstrap 已是最小形态;保留兼容参数 +mancode adapter status --json # 检查实际 managed content digest +mancode adapter upgrade --platform codex --dry-run # 只生成 staging 预览 +mancode adapter upgrade --platform codex --confirm --operation-id --session --client ``` ## Agent Modes @@ -376,10 +392,16 @@ mancode init --legacy mancode status mancode status --json mancode status --brief --json -mancode install +mancode install --confirm --operation-id --session --client +mancode adapter status [--platform ] --json +mancode adapter upgrade <--all|--platform > --dry-run +mancode adapter upgrade <--all|--platform > --confirm --operation-id --session --client +mancode project upgrade --policy 2 --dry-run +mancode project upgrade --policy 2 --operation-id --session --client mancode list-platforms mancode team identity create --name "" mancode context session new --client +mancode context session show --session --client --json mancode workflow create "" --session mancode workflow list --json mancode workflow show --json @@ -390,6 +412,7 @@ mancode workflow plan confirm --plan-decision --status --expected-revision --session mancode workflow review apply --file --expected-revision --session mancode workflow verify apply --file --expected-revision --session +mancode workflow reframe --expected-revision --checkpoint-id --session mancode workflow complete --expected-revision --session mancode manps [area] mancode refresh-project @@ -404,7 +427,7 @@ mancode version 以下是简化输出示例: ```text -mancode v0.3.18 +mancode v0.4.0 Project: my-app Runtime: ready @@ -537,14 +560,14 @@ mancode/ - `/manps` 默认只扫描;进入整改前应明确确认代码改动。 - force push、schema migration、批量删除等不可逆操作需要明确人工确认。 -## 路线图 +## 仍在推进 + +- 在同一发布候选上完成 Claude Code、Codex、Cursor、GitHub Copilot 和 ZCode 的真实宿主 session 验收;宿主自动 session 和显式双 session 都是合法证据路径。 +- 使用 `npm run release:check -- --candidate <完整提交 SHA>` 完成干净 checkout、自动双 clone/legacy、tarball SHA-256 和安装 smoke;另完成跨真实宿主恢复。 +- 确认 ZCode 项目级 skill 发现和 workspace command 路径;完成前继续标记为 provisional。 +- 根据真实需求评估 Windsurf、Cline 和 Roo Code adapter。 -| 阶段 | 重点 | -|---|---| -| MVP-1 | solo 模式、审美扫描、Claude Code hooks | -| MVP-2 | `/manba`、`/man`、`/manteam`、`/manps` 和教练组 subagents | -| MVP-3 | Cursor、Codex(ChatGPT 桌面端/CLI)、GitHub Copilot 适配 | -| 公开发布 | npm 稳定版、marketplace 分发、文档和演示 | +完整发布条件见 [0.4.0 Continuity 发布验收](./docs/release-acceptance.md)。 ## 故障排查 @@ -557,18 +580,24 @@ mancode/ ### Claude Code hooks 不生效 `mancode init` 后需要重启 Claude Code 以重新加载 `.claude/settings.json`。 -运行 `mancode status` 确认 hooks 已注册。如果仍不生效,运行 -`mancode install claude-code --force` 重写配置。 +运行 `mancode status` 确认 hooks 已注册。Continuity adapter 内容异常时,先运行 +`mancode adapter upgrade --platform claude-code --dry-run`,再用该预览返回的 +`--operation-id`、active session 和 `--confirm` 完成修复。legacy hook 架构仍使用 +`mancode init --legacy --force`。 ### `mancode status` 显示某平台 "not ready" -该平台的目标文件缺失。运行 `mancode install --force` 重新生成。 +该平台的目标文件缺失或 digest 不匹配。先运行 +`mancode adapter upgrade --platform --dry-run`,确认 staging 结果后再用 +该预览返回的 `--operation-id` 和 active session 执行 `--confirm`。 对于 Codex、ZCode 和 Copilot,`AGENTS.md` 或 `.github/copilot-instructions.md` 中的受控区可能被手动编辑或删除了。 ### AGENTS.md 或 copilot-instructions.md 受控区被误删 -运行 `mancode install codex --force`(或 `zcode`、`copilot`)重新插入受控区。 +运行 `mancode adapter upgrade --platform codex --dry-run`(或 `zcode`、`copilot`) +检查差异,再用该预览返回的 `--operation-id` 和 active session 执行 `--confirm` +重新插入受控区。 对应 mancode 受控标记外的用户内容会被保留。 ### ZCode skills 未出现 @@ -586,16 +615,8 @@ manps)按 description 触发——输入 `/manba` 等关键词即可激活。 ### 如何重装 Continuity 适配器 ```bash -mancode uninstall claude-code --force -mancode uninstall cursor --force -mancode uninstall codex --force -mancode uninstall copilot --force -mancode uninstall zcode --force -mancode install claude-code -mancode install cursor -mancode install codex -mancode install copilot -mancode install zcode +mancode adapter upgrade --all --dry-run +mancode adapter upgrade --all --confirm --operation-id --session --client ``` Continuity authority 受保护,`mancode uninstall --all` 不会删除工作流权威数据。需要 @@ -620,8 +641,9 @@ npm uninstall -g mancode ### mancode 和 CLAUDE.md 有什么区别? -`CLAUDE.md` 是静态指导。mancode 增加 hooks、持久化工作流状态、slash-command skills -和独立审查 subagents。 +`CLAUDE.md` 是静态指导。Continuity 只在其中维护一段始终加载的 Claude Code bootstrap, +真正的 session、TaskRef、requirements、计划和审查证据仍由 `.mancode/` 的结构化权威管理; +mancode 还提供 mode skills、持久化工作流和独立审查 subagents。 ### mancode 和 Cursor rules、自定义 prompt、agent instructions 有什么区别? diff --git a/docs/12-lifecycle.md b/docs/12-lifecycle.md index 9dfde5c..0f3f5c1 100644 --- a/docs/12-lifecycle.md +++ b/docs/12-lifecycle.md @@ -1,237 +1,61 @@ -# 12 - Harness 生命周期 +# Continuity 生命周期 -mancode 的运行时生命周期:跨平台 hook、上下文注入、状态管理与团队检测。 +本文描述默认 Continuity 路径。旧 `state.json` 生命周期仅用于显式 `--legacy` 兼容。 ---- - -## 1. 生命周期概览 +## 初始化 ```text -用户执行 mancode init - ↓ -Node CLI 检测项目 profile - ↓ -Git 可用 → 读取团队活动 -Git 不可用 → 使用 solo 默认值,不阻止初始化 - ↓ -创建 .mancode/state.json 与平台适配文件 - ↓ -Claude Code 启动 - ↓ -node .mancode/hooks/session-start.mjs - ↓ 注入模式与项目上下文 -用户提交 prompt - ↓ -node .mancode/hooks/user-prompt-submit.mjs - ↓ 注入 solo 六问、自动路由与条件审美 token -Claude 处理请求 +mancode init + → 检查项目与 legacy/Continuity 物理布局 + → 检测 Project Profile + → 创建 schema.json,状态 initializing + → 写 shared/local/runtime 基础权威 + → 安装所选平台 bootstrap + → 校验 adapter 与 runtime binding + → 最后切换为 v3_active ``` -当前两个 runtime hook 都是 Node `.mjs` 脚本。它们不依赖 Bash、jq、grep、 -sed 或 Git,因此可以从 macOS、Linux、Windows CMD、PowerShell 与 Git Bash -运行。 - ---- - -## 2. Hook 详细说明 - -### 2.1 SessionStart Hook - -**位置**:`.mancode/hooks/session-start.mjs` - -**触发时机**:Claude Code 启动会话并加载项目。 - -**职责**: - -1. 从 hook 文件位置计算项目根目录,不依赖当前 shell 或 Git。 -2. 读取 `.mancode/state.json` 与 `.mancode/project-profile.json`。 -3. 注入当前模式、技术栈、UI 能力和最小改动原则。 -4. 已检测到团队且仍处于 solo 时,提示按需使用 `/manteam`。 -5. 状态不存在或 JSON 无法读取时安全退出,不抛出未处理异常。 - -动态值会移除换行并限制为 200 个字符,避免项目状态污染 prompt 结构。 - -### 2.2 UserPromptSubmit Hook - -**位置**:`.mancode/hooks/user-prompt-submit.mjs` - -**触发时机**:用户提交 prompt 后、Claude 开始处理前。 - -**职责**: - -1. 在 solo 模式下注入六个动手前问题。 -2. 从 stdin 读取 Claude Code 提供的 JSON,并提取原始 prompt。 -3. 识别规划或调研请求,提示路由到 `man` skill。 -4. 只有项目 profile 确认存在 UI 资产且 prompt 涉及 UI 时,才注入审美摘要。 -5. 对颜色、字体、组件和 CSS variable 设置数量与字符上限。 - -Node 直接解析 JSON。不存在 jq fallback,也不会调用任何外部进程。 - -### 2.3 PostToolUse Hook(计划中,尚未实现) - -未来如果实现 PostToolUse,必须继续满足同一跨平台约束: +已有 legacy authority 时,普通初始化会拒绝覆盖。使用 `mancode migrate context --dry-run` 检查,再通过 stage、resolve 和显式 activation 迁移。 -- 使用 Node 脚本,不调用 Bash 或 POSIX 管道。 -- 默认 opt-in,只处理本次修改的项目内文本文件。 -- 不格式化整仓,不修改未触达文件。 -- 子进程使用 `execFile` / `spawn` 参数数组,不拼接 shell 字符串。 -- 单次执行设置超时;超时提示但不无限阻塞编辑流程。 -- lint/typecheck 的失败必须保留真实退出状态。 +## 会话与任务 ---- - -## 3. 团队检测 - -团队检测发生在 CLI 初始化和状态查询阶段,不在 hook 内执行。 - -判定为团队需要同时满足: - -1. Git 历史贡献者超过 1 人。 -2. 存在 GitHub、GitLab 或 Bitbucket remote。 -3. 最近 30 天活跃贡献者超过 1 人。 - -实现通过 Node `execFile('git', args)` 直接调用 Git,并在 Node 中拆分、去重邮箱; -不使用 `/bin/bash`、`sort`、`grep`、重定向或管道。以下情况统一安全降级: - -- Git 未安装或不在 PATH。 -- 项目来自 ZIP,没有 `.git`。 -- Git 历史为空或命令执行失败。 -- 当前目录不是独立仓库根目录。 - -降级结果为 `isTeam: false`、`contributors: 1`、`recentActive: 1`,不会阻止 -`mancode init`。用户仍可通过 `--team` 或 `--no-team` 明确覆盖。 - ---- - -## 4. `.mancode/state.json` - -```json -{ - "version": "0.3.14", - "currentMode": "solo", - "lastMode": "solo", - "platform": "claude-code", - "initializedAt": "2026-07-11T10:30:00.000Z", - "techStack": "JavaScript/TypeScript + React", - "uiLibrary": "Tailwind CSS", - "currentTask": null, - "currentWorkflowMode": null, - "skippedSteps": [], - "activeSoloPlan": null, - "teamModeAutoDetected": false, - "contributors": 1 -} -``` - -| 字段 | 说明 | -|---|---| -| `currentMode` / `lastMode` | 当前和上一次工作流模式 | -| `platform` | 初始化时选择的默认适配器 | -| `techStack` / `uiLibrary` | 从 project profile 得到的摘要 | -| `currentTask` / `currentWorkflowMode` | 当前持久化工作流信息 | -| `skippedSteps` | 用户明确跳过的工作流步骤 | -| `activeSoloPlan` | `/man` 计划交给 solo 后保留的 taskId 与 planVersion;solo 验证完成后由 `workflow handoff --complete` 原子清理 | -| `teamModeAutoDetected` | 自动检测或显式配置后的团队状态 | -| `contributors` | 检测到的贡献者数量;降级时为 1 | - -CLI 负责写入 state;hooks 只读,不在会话启动或 prompt 提交时修改磁盘状态。 - ---- - -## 5. Claude Code 集成 - -### 5.1 settings.json - -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \".mancode/hooks/session-start.mjs\"" - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node \".mancode/hooks/user-prompt-submit.mjs\"" - } - ] - } - ] - } -} +```text +宿主或用户创建 session + → workflow create 生成 TaskRef 与四个治理实体 + → context resume 绑定当前 session + → context show 解析一致的 Context Pack + → mutation 使用 expected revision 执行 ``` -命令只使用 `node` 和带引号的相对路径,在 CMD、PowerShell、Git Bash 与 POSIX -shell 中语义一致。 - -### 5.2 旧版本迁移 - -`mancode init --force` 或 `mancode install claude-code --force` 会: - -1. 识别并移除 mancode 生成的旧命令: - `bash .mancode/hooks/session-start.sh` 与 - `bash .mancode/hooks/user-prompt-submit.sh`。 -2. 注册新的 Node hook 命令。 -3. 删除两个旧 `.sh` 文件并生成 `.mjs` 文件。 -4. 保留无法精确识别为 mancode 生成内容的用户 hook。 - -卸载同样同时识别新旧 mancode hook,不会删除无关用户命令。 +session 是本地便利状态。关闭一个 session 不会关闭任务,也不会影响其他 session。宿主 identity key 只保存不可逆 lookup hash;显式 session 不保存原始宿主 key。 ---- +## 写入与恢复 -## 6. `/man` 受管验证门禁 +简单单实体 cache 写入可以原子替换。影响任务语义、claim、handoff、迁移或 transport 的写入必须经过 operation journal。 -新创建的 `/man` 与 `/manteam` workflow 使用 policy v2: +发现 `operation_pending`、reservation、task-head fence 漂移或未完成 git-ref receipt 时: -1. Step 2 把目标、非空范围、排除项、技术决策、默认值、阻塞未知项、七个 coverage 维度和带稳定 ID 的验收标准写入结构化输入。 -2. `workflow requirements finalize --file ` 校验输入,并确定性生成 `requirements.json` 和 `requirements.md`;readiness 由 `blockingUnknowns` 是否为空推导。 -3. Step 6 初始化 `verification-ledger.json`。每个 required 验收项按 `automated`、`manual` 或 `hybrid` 记录证据;自动 passed/failed 包含命令和退出码。 -4. `manual_required` 会把主 workflow 标为 blocked;普通 `workflow update --status in_progress` 不能绕过,必须在取得用户明确确认后通过 `verify ... confirm-manual` 恢复。 -5. 台账绑定 requirements digest、planVersion 与 remediation round。任一发生变化,旧验证自动失效;remediation 后在 Step 9 重新登记全部 required 验收。 -6. 验证未全部通过时,CLI 同时拒绝 Step 7、review 初始化和 completed。完成还要求 `summary.md`。review 只能在 Step 6 通过专用 skip 命令和理由跳过。 +- read-only 命令返回可证明的一致子集和 repair 信息。 +- 普通 mutation 被拒绝。 +- `context doctor`、`operation repair` 或 transport recovery 继续原 operation。 +- 只有尚未产生可见业务写时才允许 abort。 -review policy v2 只允许 `clarification` 和用户明确要求跳过的整个 `review` 出现在 `skippedSteps`。targeted review 的第二领域是不适用,不写入 skippedSteps。旧 policy v1 workflow 保持兼容读取。 +## 完成与保留 ---- +完成门禁读取整个 Task Aggregate。requirements、plan、review、verification、子任务、claim 与 repair 状态必须同时满足策略。 -## 7. 验证矩阵 +`mancode context compact` 先列出候选,再删除符合 retention policy 的本地 cache、终态 workflow 产物或 repair journal。active task、milestone、被引用 checkpoint 和未完成 operation 不会被静默删除;shared 删除需要显式确认。 -本地测试覆盖: +## 团队与跨 clone -- Git 存在与 PATH 中没有 Git。 -- 非 Git manifest 项目正常初始化。 -- 团队检测的单人、多人、remote 与近期活跃组合。 -- Node hooks 的上下文输出、自动路由、token cap 与不可信 token 清洗。 -- 旧 Bash hook 设置迁移、新 hook 状态检查与卸载。 +本地 transport 在同一 Git common directory 内共享协调权威。git-ref transport 只在显式 pull/push 后更新,并通过 remote revision 与 ownership fence 做 CAS。 -Windows CI 会在同一个 `windows-latest` runner 上分别从 CMD、PowerShell 和 Git -Bash 执行 smoke test。测试清空子进程 PATH 来模拟 Git/Bash 不可用,验证 Codex -初始化、solo 降级、Claude Code 初始化和两个 Node hooks 均能完成。它还会让 -PowerShell 以不允许删除的共享模式实际打开 session evidence 文件,再运行一次 -`context session spike`;原子替换必须在句柄释放后自行重试并成功。该 job 名为 -`Windows compatibility gate`;其 `windows-shells` check 已配置为 `main` 的发布必需检查。 +业务 Git 内容、分支和 worktree 不由 mancode 自动同步。跨 clone handoff 的接收者必须同时取得对应代码基线。 -发布候选还必须在目标项目执行内部发布证据命令。该门禁会拒绝 -未激活或不兼容的 mancode 工作流、未安装的平台 bootstrap、未完成的 repair journal、 -缺失 checkout binding,以及没有绑定当前发布候选的五个平台真实双窗口、子命令传播和子 agent -继承证据的项目。session evidence 文件不保存宿主 session key;`context session spike` 不会从临时 -环境变量自动推断传播结果,只有在每个平台宿主中完成真实 spike 后才能通过。 +## 发布证据 ---- +`context session spike` 记录真实宿主的窗口隔离、子命令传播、子 agent 继承和 hook approval,但不保存原始宿主 session key 或显式 session ID。证据模式为 `host` 或 `explicit`:前者通过后可授权受信宿主身份,后者只证明两个已存在、active、client 匹配的显式 session 隔离,不改变运行时的 `explicit_required` 策略。内部 `context beta` 接受与平台能力匹配的任一路径,并要求所有证据绑定同一个 immutable release candidate。 -## 8. 实施状态 +`npm run release:check -- --candidate <完整提交 SHA>` 从 `origin/develop` 创建干净 checkout,运行完整自动化、真实双 clone/legacy fixture、audit、pack 与 tarball 安装 smoke,并把报告和候选 tarball 保存在 `.mancode/local/release-evidence/`。该命令不会执行 `npm publish` 或修改 dist-tag。 -| 阶段 | 内容 | -|---|---| -| MVP-1 | SessionStart、UserPromptSubmit、state.json | -| MVP-2 | 多模式切换;PostToolUse 仍为计划项 | -| MVP-3 | Claude Code、Cursor、Codex、Copilot、ZCode 适配 | -| Windows native | Node hooks、无 shell 团队检测、Git 可选、三 shell smoke | +尚未完成的验收见 [release-acceptance.md](./release-acceptance.md)。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c394bae --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# mancode 开发文档 + +这里记录当前实现的稳定契约。历史 MVP 计划、一次性审核报告和已完成的网站计划不再保留在仓库中。 + +## 信息来源 + +发生冲突时,按以下优先级判断: + +1. `src/` 中的 schema、CLI 注册和运行时代码。 +2. 对应的自动化测试。 +3. `README.md` 与 `README.en.md` 的公开使用说明。 +4. 本目录中的开发说明。 + +文档不能把计划中的功能写成已支持,也不能用旧测试数量或版本号证明当前状态。 + +## 文档索引 + +| 文档 | 内容 | +|---|---| +| [architecture.md](./architecture.md) | Continuity 权威模型、目录、Task Aggregate 和一致性 | +| [workflows.md](./workflows.md) | 模式、工作流状态、治理门禁与团队协作 | +| [project-intelligence.md](./project-intelligence.md) | 项目检测、设计资产扫描和 preseason | +| [platform-adapters.md](./platform-adapters.md) | 五个平台的 bootstrap、能力差异与边界 | +| [12-lifecycle.md](./12-lifecycle.md) | 初始化、会话、任务、恢复和迁移生命周期 | +| [engineering.md](./engineering.md) | 开发原则、验证要求和代码地图 | +| [release-acceptance.md](./release-acceptance.md) | 尚未完成的真实宿主与发布验收 | + +法律和许可证边界单独保存在仓库根目录的 `LEGAL.md`。 + +## 维护规则 + +- 新文档优先更新现有专题,不创建新的阶段计划副本。 +- 已完成计划应删除;仍有长期价值的决策应写成当前约束。 +- 仅在真实宿主验证完成后更新平台能力声明。 +- 命令示例必须能在当前 `src/cli.ts` 中找到对应入口。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0a9e8ff --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,74 @@ +# Continuity 架构 + +mancode Continuity(跨会话与团队协作运行时)把跨会话任务状态、治理证据和团队协调放在显式、可校验的本地权威中。平台适配器只负责入口与 bootstrap,不保存任务副本。内部 layout/schema version 仍为 3;它是存储兼容版本,不是产品名称。 + +## 核心模型 + +一个任务由 `TaskRef` 标识: + +```text +local: +shared: +``` + +`local` 任务只属于当前 checkout;`shared` 任务可以参与团队协调。可见性与协作方式是两个维度:`visibility=local|shared`,`coordination=single|team`。 + +任务的稳定视图是 Task Aggregate,由以下实体共同组成: + +- `metadata.json`:生命周期、owner、revision、scope 和治理摘要。 +- `requirements.json`:目标、范围、未知项和验收标准。 +- `review-ledger.json`:审查领域、报告与 blocker。 +- `verification-ledger.json`:自动或人工验证证据。 +- checkpoint、claim、handoff 和 task-head fence:团队协调与恢复状态。 + +Markdown 计划和报告是人类可读产物。完成门禁以结构化实体及其 digest 为准。 + +## 目录与权威 + +```text +.mancode/ +├── schema.json # Continuity 激活状态和兼容门禁 +├── shared/ +│ ├── config.json # 项目策略与 transport 配置 +│ ├── context/project.json # 可共享项目事实 +│ ├── workflows/ # shared Task Aggregate +│ ├── team/ # actor、claim、handoff、checkpoint +│ └── memory/decisions/ # 明确确认的共享决策 +├── local/ +│ ├── sessions/ # checkout-local 会话 +│ ├── workflows/ # local Task Aggregate +│ ├── cache/ # 可重建扫描与 transport 缓存 +│ └── preseason-* # 本地健康扫描产物 +└── runtime/ # operation journal、reservation、repair +``` + +旧架构的 `state.json`、`config.json`、`project-profile.json`、`workflows/` 和 `memory/` 与 Continuity 目录物理隔离。普通 `mancode init` 创建 Continuity 布局;只有显式 `--legacy` 才创建旧布局。 + +## 一致性与恢复 + +所有跨实体业务写入都使用 durable operation: + +1. 写入带预期 revision 的 operation journal。 +2. 获取本地锁并校验 session、Task Aggregate、checkout binding 和 coordination freshness。 +3. 为受影响实体写 reservation 或 `operation_pending` 状态。 +4. 按 operation definition 幂等应用步骤。 +5. 最后发布稳定 metadata,并清理 reservation。 + +进程中断后,普通 writer 不会把新旧实体拼成稳定结果。`mancode context doctor` 和 `mancode operation` 根据 journal 继续 repair;只有能证明没有可见业务写时才允许 abort。 + +## 版本与兼容 + +`schema.json` 支持 manifest version 1 和 2,layout version 固定为 3。0.4.0 新初始化项目直接写入 V2;历史 V1 项目只有完成显式 Policy 2 upgrade 后才写入 V2。激活状态包括 `initializing`、`dual_read`、`activating`、`v3_active` 和 `repair_required`。 + +Reader 和 writer 必须先通过兼容门禁。legacy 迁移采用隔离 stage、显式确认和 journaled activation;不能把当前 Git HEAD 或当前用户伪装成历史事实。 + +## Transport + +默认 `local` transport 在同一 Git common directory 内协调。可选 `git-ref` transport 使用 `refs/mancode/team` 在不同 clone 间显式同步: + +```bash +mancode team sync pull +mancode team sync push shared: --expected-task-revision N --session +``` + +远端不会自动同步业务代码。bundle、ownership fence 和 remote revision 只协调 mancode 权威;调用者仍需自行同步 Git 分支。 diff --git a/docs/engineering.md b/docs/engineering.md new file mode 100644 index 0000000..e3d6cbd --- /dev/null +++ b/docs/engineering.md @@ -0,0 +1,49 @@ +# 工程约定 + +mancode 的实现原则是:外科手术式修改、可验证、可恢复。 + +## 开发规则 + +- 只修改需求涉及的路径,不顺带重构相邻模块。 +- 优先复用现有实现、标准库、平台能力和已安装依赖。 +- schema parser 拒绝未知字段;跨实体写入必须有 revision 和 operation journal。 +- shared 内容先做隐私筛查,再 canonicalize 和计算 digest。 +- adapter 只管理自己的文件或明确标记的托管区。 +- 探测失败应安全降级;业务一致性失败应停止写入并进入 repair。 + +## 代码地图 + +| 目录 | 职责 | +|---|---| +| `src/context/` | schema、Task Aggregate、任务 mutation 和 Context Pack | +| `src/runtime/` | session、锁、operation、reservation、recovery 和 retention | +| `src/team/` | actor、claim、handoff、checkpoint 和 transport | +| `src/installers/` | 平台 bootstrap、managed block 和 capability 检查 | +| `src/commands/` | CLI 解析后的应用服务边界 | +| `src/system/` | 项目检测、扫描和 legacy 辅助功能 | +| `tests/` | contract、crash matrix、E2E 和 adapter 回归 | + +## 验证 + +从最窄验证开始,再按风险扩大: + +```bash +npx vitest run tests/.test.ts +npm run typecheck +npm run lint +npm test +npm run build +npm run test:dist +``` + +发布候选先运行 `npm run prepublishOnly`。提交并推送最终 `develop` 后,运行 `npm run release:check -- --candidate <完整提交 SHA>`,由远端候选的干净 checkout 重跑完整门禁、跨 clone、legacy、audit、pack 和 tarball 安装 smoke,并生成本地 SHA-256 证据。涉及 Windows 原子文件行为时追加 `npm run test:windows-smoke`;涉及网站时运行对应网站测试和浏览器检查。 + +不要把历史测试数量写入长期文档。报告当前命令、退出码和失败原因即可。 + +## 文档与发布 + +- `README.md` 和 `README.en.md` 面向用户并保持功能声明一致。 +- `docs/` 只描述当前契约和未完成验收,不保存已完成实施计划。 +- 新 CLI 入口必须同步 help、测试和公开参考。 +- 平台能力声明必须区分自动化 contract 与真实宿主证据。 +- 发布版本由 `package.json` 与 `src/version.ts` 共同约束。 diff --git a/docs/planning-governance-evolution.md b/docs/planning-governance-evolution.md new file mode 100644 index 0000000..0eced37 --- /dev/null +++ b/docs/planning-governance-evolution.md @@ -0,0 +1,449 @@ +# 规划治理演进与安全发布契约 + +本文是 mancode 0.4.0 的规划治理实施与发布契约。它描述目标行为、兼容边界、验证证据和停止条件。`4dc2e7e` 是当前已验证的开发集成候选;后续契约修复必须进入新的唯一候选。在最终发布门禁和 npm 发布完成前,本文的“已完成”只表示代码与自动化验证完成,不表示已经发布。 + +## 1. 决策摘要 + +| 阶段 | 发布内容 | 明确不做的事 | 放行前提 | +| --- | --- | --- | --- | +| 0.4.0 | additive advisory、adapter digest/upgrade、policy parser/capability gate、显式 project upgrade、已升级项目的 Policy 2 默认值,以及 local transport 的原子 `reframe` | 不强制 `solo -> /man`;不批量重写旧 workflow;不把项目升级伪装成普通 `refresh`;不自动降级 policy;git-ref transport 不执行 reframe | A–F 工作包、完整测试、真实宿主验收和 npm 发布前检查全部通过;只有完成 project upgrade 的项目启用 Policy 2 | + +这是唯一对外版本。工作包可以分 PR、独立合并和独立评审,但不产生中间发布。任何能力都必须一起通过 0.4.0 release gate;在此之前不得通过 feature flag、环境变量、手工 metadata 或 npm 预发布提前启用。 + +### 当前状态(2026-07-22) + +| 类别 | 状态 | 证据 | +| --- | --- | --- | +| A–F 实现工作包 | 工作树生产加固完成 | Policy 2、adapter recovery、reframe、旧 CLI 黑盒、decision-readiness、requirements draft 和 Continuity 命名已经完成;本轮继续修正平台发布证据与最终 release-check | +| 本地发布前检查 | 新候选待重跑 | 最终候选必须通过 `npm run prepublishOnly`,并由 `npm run release:check -- --candidate ` 从远端干净 checkout 重跑跨 clone、legacy、audit、pack 和 tarball 安装 smoke | +| GitHub Quality gate | 历史候选通过 | `2c9d697` 的 [Quality gate run 29930349142](https://github.com/whitelonng/mancode/actions/runs/29930349142) 成功;本轮形成新候选后必须重跑 | +| GitHub Windows gate | 历史候选通过 | `2c9d697` 的 [Windows gate run 29930353690](https://github.com/whitelonng/mancode/actions/runs/29930353690) 在 CMD、PowerShell、Git Bash 全部成功;本轮形成新候选后必须重跑 | +| `develop` 远程一致性 | 新候选待形成 | `2c9d697` 当前与 `origin/develop` 对齐;本轮工作树形成新候选后需重新推送和对齐,`main` 不得修改 | +| Claude Code 条件式澄清实测 | 工作树通过 | Claude Code 2.1.142 使用本地 0.4.0 tarball:清晰请求直接执行,需求不清晰时提问等待,明确但与证据冲突的请求被拦截;真实测试同时发现并修复了隐藏 bootstrap skill 未稳定加载的问题,最终改为 `CLAUDE.md` 受控区,候选形成后仍需重跑 | +| 最终发布验收 | 未完成 | 五平台真实宿主与跨宿主恢复、自动 release-check、最终 Beta gate 和候选 tarball 验收仍待完成 | +| npm 发布 | 未执行 | 在上项全部完成前保持禁止 | + +## 2. 目标、不变量与非目标 + +### 2.1 目标 + +1. 让 `/man` 在入口、语义 owner、source of truth、状态和 contract 发生不确定时停下来,而不是用默认值继续推进。 +2. 让旧 CLI 在 V2 manifest 的 reader/writer 边界显式拒绝,并让 0.4.x parser 拒绝未知 policy,避免“看起来成功但执行了旧规则”。 +3. 让新 policy 只影响新建 workflow;历史 workflow 按创建时记录的 policy 继续运行。 +4. 让 0.4.0 的 local `reframe` 具备单一 journal、单一 revision 线和可证明的中断恢复结果。 + +### 2.2 必须保持的不变量 + +- `.mancode/shared|local/workflows//metadata.json`、`requirements.json`、`plan.md`、review ledger、verification ledger、claim、handoff 和 task-head fence 的权威关系不变。 +- Markdown 是人类可读产物;完成门禁和兼容门禁读取结构化实体及 digest,不以文本存在代替权威状态。 +- 所有跨实体写入都经过 durable operation、expected revision、锁和 recovery action。 +- 任何重新对齐触发都先保留当前权威文件;不能通过手工编辑 `currentStep`、`planning`、requirements 或 plan 制造“回退”。 +- policy 版本是 workflow 创建时的事实。后续 CLI 升级不会静默把 `planning: 1` 改成 `2`。 + +### 2.3 非目标 + +- advisory 不改变 Solo 的执行授权,也不把提示性升级变成自动升级。 +- advisory 本身不写 `planning: 2`、`blocked`、`reframe` 事件或其他状态来模拟回 Step 2。 +- 不创建独立的 Domain Matrix 权威文件;它只能是高风险 `plan.md` 的可选章节。 +- 不用 scope-change 表达需求重构。scope-change 只处理 implementation scope 和 claim successor。 +- git-ref reframe 不属于本计划;在其具备跨 clone 原子语义前始终稳定拒绝,不发布本地成功、远端稍后同步的变体。 + +## 3. 基线证据与代码 owner + +以下证据以当前工作树的 Continuity 实现为准。候选形成前后都应重新运行对应 contract;如果在途改动改变了行号或行为,更新证据而不是沿用旧结论。 + +| 事实 | 当前证据 | 对本计划的影响 | +| --- | --- | --- | +| workflow create 保留 Policy 1 基线,只在 V2 已升级项目的新 `/man` 上使用 Policy 2 默认值 | 以 release candidate 的 public `workflow create` contract 和 [`src/context/workflow-create.ts`](../src/context/workflow-create.ts#L277) 共同复核 | 不以单个 builder 行号宣称基线;项目升级状态和 workflow mode 共同决定默认值 | +| policy parser 只接受各组件显式支持的版本白名单 | [`src/context/workflow-metadata.ts`](../src/context/workflow-metadata.ts#L621) | 未知版本产生稳定的 `WorkflowPolicyVersionUnsupportedError`,不能被当成未来版本静默接受 | +| context compatibility 使用 adapter 的实际磁盘 inventory | [`src/commands/context.ts`](../src/commands/context.ts#L881) | manifest echo 不作为磁盘内容证据;inventory、renderer version 和内容状态必须共同匹配 | +| adapter inspection 重建 expected bytes/digest,并分类 `ready`、`missing`、`stale`、`unreadable` | [`src/installers/v3-adapter.ts`](../src/installers/v3-adapter.ts#L626) | status 与 mutation gate 都以物理 target 为准,非 ready 状态不能与 manifest version 混淆 | +| requirements finalize 与 plan revise 显式把旧 review/verification ledger 标为 stale | [`src/context/requirements-finalize.ts`](../src/context/requirements-finalize.ts#L188)、[`src/context/plan-revision.ts`](../src/context/plan-revision.ts#L347) | 复用 ledger stale 的验证模式,但 reframe 仍使用独立原子 operation | +| scope-change 会处理旧 claim 与 successor claim | [`src/context/scope-change.ts`](../src/context/scope-change.ts#L637) | 它不是需求重构;reframe 使用独立 operation type 和 eligibility | +| parent contract 变化会使 child snapshot stale | [`src/context/child-result-merge.ts`](../src/context/child-result-merge.ts#L331) | reframe 在入口拒绝 active child,而不是事后合并 stale 结果 | +| reframe eligibility 显式拒绝 active child、open handoff 和 active solo assignment | [`src/context/reframe.ts`](../src/context/reframe.ts#L497) | advisory 只返回诊断;不得通过局部清理绕过跨实体约束 | + +**实现 owner 约定:** workflow metadata/parser 负责 policy 结构和状态转换; adapter installer/status 负责渲染、digest 和物理文件; compatibility/resolver 负责 reader/writer 门;workflow operation 模块负责跨实体写入;templates 只负责 advisory 文本,不得直接写权威状态。 + +## 4. 0.4.0:Additive Advisory + +### 4.1 Scout 报告字段 + +在 `scout-report.md` 增加以下可选小节。它们是调研证据,不进入 workflow metadata,也不替代 requirements 的确认。 + +```markdown +## Current Behavior Evidence +- Observation: <当前真实行为> +- Evidence: `` +- Reproduction/validation: <如何复现或验证> + +## Candidate Semantic Owner +- Candidate: <模块、实体或命令> +- Confidence: high | medium | low +- Unresolved conflict: + +## Source of Truth +- Authority: <结构化文件、ledger、manifest 或外部系统> +- Readers: <读取方> +- Writers: <唯一写入路径或 operation> +- Derived copies: + +## Historical / Compatibility Impact +- Existing workflows: <影响或 no change> +- Legacy/transport: <影响或 no change> +- Migration/rollback: <需要的证据> +``` + +字段规则: + +- “可选”表示只有相关或能从项目事实验证时才写;不得为了填满模板编造 owner 或 source of truth。 +- `Current Behavior Evidence` 至少包含一个可复现观察和一个仓库路径、测试或命令证据;只有推测时必须标为未知。 +- `Candidate Semantic Owner` 可以不是最终 owner,但必须写置信度和冲突;存在两个同等候选时,Plan Coach 必须停在 `NEEDS_CLARIFICATION`。 +- `Source of Truth` 必须区分 authority 与 derived copy。adapter、cache、Markdown 默认不是任务状态的 owner。 +- `Historical / Compatibility Impact` 至少回答旧 workflow、legacy fixture、transport 和 rollback 是否受影响;不受影响也要明确写 `no change`。 + +### 4.2 Plan Coach 规则 + +Plan Coach 继续是只读组件。它在产生计划前执行以下检查: + +1. 所有候选方案是否针对同一个用户目标、同一验收边界和同一 scope。若某个选项偷偷改变目标或把问题改成另一个问题,返回 `NEEDS_CLARIFICATION`。 +2. 每个选项的复杂度由谁承担:实现代码、迁移、运行维护、用户操作、兼容层或测试。不能只写“更简单”,要写承担者和可观察成本。 +3. 输出唯一推荐,并给出拒绝其他选项的主要理由。推荐不是“由用户决定”;用户仍可否决,但计划不能同时保留多个未决方向。 +4. 简单任务可以只列一个明显可行方向,并标注为什么没有真实替代方案;不能为了满足“多选”制造伪选项。 +5. Continuity mode entry 在 requirements finalize 前执行条件式 decision-readiness gate:同时检查清晰度、与项目证据的一致性和风险;清晰且可靠的需求不制造形式问题,仍有会改变目标、范围、行为、验收、owner/source of truth 或关键约束的歧义时,必须停下提问并等待用户回答,不得把未验证假设写成 confirmed requirements。 + +建议的输出契约: + +```text +READY_FOR_PLAN +goal: +options: + - id: + solves: + complexity_bearer: + tradeoffs: +recommendation: +stop_conditions: +``` + +违反同目标、复杂度承担者或唯一推荐规则时,不写 `plan.md`,只返回 `NEEDS_CLARIFICATION` 及缺失决策。简单任务的单一方向仍必须有 `recommendation` 和停止条件。 + +### 4.3 Domain Matrix(仅高风险任务) + +Domain Matrix 是 `plan.md` 内的可选章节,不另建权威文件。以下任一条件成立时建议加入:入口或流程跨平台不一致、owner/source of truth 不清、状态或 contract 语义会变化、跨 workflow/child/team/transport、迁移或兼容影响超过一个版本。 + +推荐表格: + +| Domain | Current behavior/evidence | Candidate owner | Source of truth | Contract/state impact | Compatibility/history | Validation | Rollback/stop | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `` | `` | `` | `` | `` | `` | `` | `` | + +表格只帮助计划审查,不产生运行时决策。最终实现仍必须由对应 authority writer 和 operation contract 证明。 + +### 4.4 Solo 到 `/man` 的升级条件 + +advisory 只增加建议,不改变 Solo 权限或当前 workflow: + +- 入口或流程不一致; +- semantic owner 不清; +- source of truth 不清; +- 状态或 contract 语义会变化; +- 任务范围、架构、成本或验收发生跨文件/跨模块变化; +- 需要历史兼容、迁移、跨平台或团队协调证据。 + +命中条件时,Solo/Head Coach 应建议改用 `/man` 并说明原因。除非用户明确发起新 `/man` workflow,否则不得自动改变 mode、current step、policy 或 authority。 + +### 4.5 停止与重新对齐诊断 + +以下任一条件出现,实施立即停止: + +- 新证据推翻已确认的目标、owner、source of truth 或验收; +- 发现入口/流程在平台间不一致,且会导致不同语义; +- 需要改变 status、contract、policy 或 workflow transition 的含义; +- 发现 adapter stale、writer 不兼容、未完成 operation、active child、open handoff 或 active solo assignment; +- 用户提出的变化已经超出当前 requirements/plan 的 scope。 + +advisory 的唯一结果是停止当前代理执行,并返回只读诊断 `NEEDS_REALIGNMENT` 与原因 `MANCODE_REFRAME_REQUIRED`。该诊断不写入 workflow authority,也不调用通用 `workflow update`。 + +- 保留当前 `requirements.json`、`plan.md`、review/verification ledger、claims 和 handoff,且不写入任何 metadata; +- 不手改 `currentStep`,不写 `planning: 2`,不归档旧文件; +- 不释放 claim,不取消 handoff,不宣称任务已回到 Step 2; +- 用户可以查看当前 authority,并明确选择新建 `/man` workflow 或显式执行第 7 节的 local `reframe`。旧 workflow 不因该诊断而自动获得新的状态转换。 + +这是“停止并保留证据”,不是 reframe。advisory 不得把诊断伪装成持久 blocker;真正返回 Step 2 只由第 7 节的独立 operation 完成。 + +## 5. 0.4.0 的 Policy 2 基础能力 + +以下四个能力是 0.4.0 的独立工作包和共同 release gate,不是中间发布版本。它们分别有失败测试;不能用 `VERSION` 常量、managed marker 存在或 manifest 回显互相替代。 + +### 5.1 Adapter 内容 digest 与 stale 检测 + +目标契约: + +- 对每个平台的每一个 managed target,以 `UTF-8("mancode-adapter-digest-v1") || 0x00 || UTF-8(target identity) || 0x00 || managed bytes` 作为 hash 输入,计算 `sha256:<64 hex>`。renderer 的 expected bytes 必须是规范化 UTF-8/LF;磁盘的 actual bytes 不先把 CRLF 转成 LF,否则换行漂移和截断会被掩盖。整文件 target 的 managed bytes 是整个文件,嵌入式 target 的 managed bytes 仅为 marker 边界内的 block,用户托管区域不参与 digest。 +- status 同时读取磁盘内容、manifest 的 renderer version 和 renderer 重建的期望内容,返回 `ready`、`missing`、`stale` 或 `unreadable`,并提供 target、actual digest、expected digest、renderer version 和修复建议。 +- 只有所有 required adapter 均 `ready`,compatibility gate 才允许需要 adapter 的 mutation。stale 读可以返回诊断,但不得继续执行新 policy。 +- digest 必须覆盖实际 managed 内容;仅检查 marker 或版本常量不能证明用户没有改动、文件没有截断、换行没有变化。 +- 首次落地时先对五个平台、共享 target(如 AGENTS)和 legacy marker 做 fixture,确认同一内容在支持的平台上得到同一 digest。 + +**本计划的选择:** 不把 content digest 写入 V1 或 V2 manifest。manifest 继续保存 renderer/schema version;status 每次从 renderer 重建 expected digest,并与磁盘 actual digest 比较。可重建的性能 cache 只能放在 local cache,不能成为 authority。性能问题不能在 0.4.0 临时扩展 schema;它必须作为后续独立设计重新评审。 + +`managedAdapters` 的 key 是 required adapter inventory,不是五个平台的固定全集。greenfield 只登记初始化时选择的平台;后续新增平台由 journaled adapter upgrade 同时发布 managed targets 和 inventory。compatibility 必须双向比较 manifest inventory 与实际主 target;required target 全部丢失时,空的实际集合不能通过门禁。 + +### 5.2 显式 adapter 升级命令 + +0.4.0 已固定并由 CLI contract 覆盖的命令: + +```bash +mancode adapter upgrade --all --dry-run +mancode adapter upgrade --all --confirm --operation-id --session --client +mancode adapter upgrade --platform --dry-run +mancode adapter status --json +``` + +升级命令必须: + +1. 先读取 project manifest、实际 adapter inventory、profile 和当前 writer capability;stale、路径冲突、未完成 operation 或版本门禁失败时只报告,不写文件。 +2. 在 staging 目录渲染所有目标,展示变更 target、digest 和托管区域差异;用户确认后才进入 journal。 +3. 在 adapter locks 下按目标写入,使用 before/target digest recovery action;中断后由 `operation repair` 继续或在无业务写的前提下 abort。 +4. 写完重新读取并验证每个 target;仅在 renderer version 变化时更新 manifest 的 adapter version/inventory,不写 content digest。任何一个 target 失败都不能报告整体成功。 +5. 不修改 task、requirements、plan、policy 或 step;adapter 升级与 workflow 语义升级是两个 operation。 + +`refresh-project` 可以发现并报告 stale,但不能悄悄完成 adapter upgrade。`install --force` 是否复用底层 writer 由实现决定,但必须保留一个语义明确、可审计的显式 upgrade 入口。 + +### 5.3 Policy parser 版本白名单 + +版本解析必须从“任意正整数”改为按组件白名单: + +| CLI/release | 可读取/写入的 planning policy | +| --- | --- | +| 既有 0.3.x CLI | 只支持 V1/Policy 1;已发布的 0.3.18 在 V2 manifest schema parser 边界拒绝未知的 `workflowPolicyDefaults`,并在 policy 执行和 authority mutation 前终止;能解析 V2 的 reader/writer 仍由 `minReaderVersion`/`minWriterVersion` 拒绝 | +| 0.4.0(未升级项目) | 读取和写入已有 `1`;新建仍按项目记录的默认值 | +| 0.4.0(已升级项目) | `1` 和 `2`;新建 `/man` 默认 `2` | + +解析失败必须发生在 workflow mutation 之前,并携带 component、observed version、supported versions 和 required writer。不得把未知版本降为 1,也不得把缺省值自动解释成 2。 + +### 5.4 Writer capability 与 `minWriterVersion` + +兼容门分两层: + +- CLI 启动 mutation 前声明 capability 集合,例如 `planning-policy:1|2`、`adapter-digest:1`、`reframe-local:1`;resolver 根据 task/project 和 transport 需要的 capability 逐项检查。git-ref transport 不声明 `reframe` capability。 +- project manifest 使用已有的 `minWriterVersion` 作为粗粒度下限。启用 Policy 2 的升级操作必须把 `minReaderVersion` 和 `minWriterVersion` 都提高到能完整理解 Policy 2、adapter digest 和 recovery contract 的版本,确保旧 CLI 在解析 workflow policy 前被 manifest gate 阻断。 + +门禁顺序固定为:读取 manifest → 检查 reader/writer semver → 检查 capability → 检查 adapter 内容 digest → 解析 workflow policy → 获取 locks。任一失败都没有业务写入。 + +建议的稳定错误:`MANCODE_WRITER_VERSION_TOO_OLD`、`MANCODE_WRITER_CAPABILITY_MISSING`、`MANCODE_ADAPTER_CONTENT_STALE`、`MANCODE_POLICY_VERSION_UNSUPPORTED`。错误输出应指向显式升级命令,而不是建议手工编辑 manifest。 + +## 6. 0.4.0:项目升级后的 Policy 2 默认值 + +### 6.1 项目升级边界 + +0.4.0 已固定并由 CLI contract 覆盖的命令: + +```bash +mancode project upgrade --policy 2 --dry-run +mancode project upgrade --policy 2 --operation-id --session --client +``` + +升级是 project-level journaled operation,至少包含: + +1. 预检:所有 adapter digest fresh;没有 `operation_pending`/`repair_required`;没有正在进行的迁移;当前 writer 满足新 policy 和 adapter capability。 +2. 生成 staging manifest 和新默认策略,记录 before/after digest、minReader/minWriter、操作 ID 和 release version。 +3. 用户确认后原子提交 project policy marker/default 和兼容门;不改任何已有 workflow 的 `governance.policyVersions`。 +4. 重新读取 project 与 workflow fixture,证明新建路径选择 2、旧路径仍按记录选择 1。 + +升级失败或进程中断时,必须由 journal repair 恢复到升级前的 project authority;不能留下“默认值已变但门禁未变”的半状态。 + +**推荐的 source of truth:** 0.4.0 引入支持 V1/V2 白名单的 schema manifest parser;V2 保持 `layoutVersion=3`,只新增 `workflowPolicyDefaults: { planning }`,并提高 `minReaderVersion`/`minWriterVersion`。review 与 verification 没有本次升级的 policy 语义,不能提前加入 defaults。现有 V1 项目必须显式升级到 V2;0.4.0 新初始化项目只有在 adapter digest 校验完成后才创建 V2,因此视为已升级。不要把 planning policy 放进现有 `TeamPolicyV1`:该实体的 owner 是团队推荐、可见性和 retention,不是 workflow 治理版本。 + +### 6.2 新旧 workflow 规则 + +- 只有已完成项目升级的项目,之后新建的 `/man` workflow 才默认 `policyVersions.planning=2`。 +- 现有 workflow 的 planning/review/verification policy 按创建时记录保持不变;读取、plan revise、review 和 verification 不得因为 CLI 升级而批量重写。 +- 0.4.0 在未升级项目中不得隐式写 2。若用户需要 Policy 2,必须先完成显式 project upgrade 并通过所有门禁。 +- 删除、复制或导出 workflow 时保留 policy provenance;不能以“当前 CLI 版本”重算历史 policy。 + +### 6.3 回滚边界 + +- 在 project upgrade journal commit 前可 repair/abort,前提是没有可见业务写。 +- 一旦已有 Policy 2 workflow 被创建,不允许把 project `minWriterVersion` 或默认 policy 静默降回旧值;发布回滚只能停止创建新 workflow,保留已有 policy 事实。 +- 发现 adapter stale、旧 writer 或解析不支持时,阻塞 mutation 并要求 adapter/project upgrade;不能自动降级到 Policy 1。 + +## 7. 0.4.0:local transport 的独立原子 `reframe` operation + +`reframe` 是需求语义重构,不是 scope-change 的别名,也不是设置一个 step 数字。它随 0.4.0 发布,但只有 local transport 支持;完整 operation 和 recovery 是 0.4.0 的 release gate。 + +### 7.1 Eligibility 与拒绝条件 + +在获取 task、task-head、claims、handoffs、review、verification 的锁后,以 expected task revision 做一次完整检查: + +- task 必须是可继续的 active workflow,不能是终态、`operation_pending` 或 `repair_required`; +- 有 active child 时拒绝:`MANCODE_REFRAME_ACTIVE_CHILD`; +- 有 draft/offered/open handoff 时拒绝:`MANCODE_REFRAME_OPEN_HANDOFF`; +- 有 active solo assignment 时拒绝:`MANCODE_REFRAME_ACTIVE_SOLO`; +- git-ref transport 一律拒绝:`MANCODE_REFRAME_GIT_REF_UNSUPPORTED`;其跨 clone receipt/CAS 设计不属于本计划; +- 任一 claim、task head、review/verification digest 或 session freshness 过期时拒绝并要求重新读取 context。 + +拒绝发生在任何 authority 写入之前。特别是不能先释放 claim 再发现 child 或 handoff,不能先改 currentStep 再等待用户确认。 + +### 7.2 单一 journal 的原子步骤 + +建议 operation type 为 `reframe`,并为每个目标写 recovery action: + +1. 记录 before revision、requirements digest、plan digest、review/verification digest、active claim IDs、handoff IDs 和 code head。 +2. 写 `transitionState=operation_pending`,固定 operation ID 和 expected revisions。 +3. 将旧 `requirements.json` 和 `plan.md` 复制为不可变 archive artifact,保存内容 digest、来源 revision、operation ID 和时间;旧文件不被覆盖或删除。 +4. 在同一 operation 中释放所有 active claims;不能复用 claim identity,也不能把旧 claim 标记成新范围的 claim。 +5. 重置旧 plan decision,并将 review 与 verification ledger 标为 `stale`,保留原证据和 stale reason。`soloExecution` 必须已为 `null`;active assignment 在 eligibility 阶段拒绝,不能在 operation 中清除它来绕过拒绝。 +6. 写入新的 requirements draft/clarification-needed 状态、`currentStep=2`、`status=in_progress` 和 reframe checkpoint;这些字段只能作为同一 journal 的最终提交出现。 +7. 更新 aggregate 和 task-head fence,提交 journal,清理 reservation;最后才允许新的 Step 2 writer 继续。 + +中断恢复必须能重跑每一步而不重复归档、重复释放或生成两个合法 task head。若无法证明前后状态,保持 `operation_pending`/`repair_required` 并拒绝普通 mutation。 + +### 7.3 与现有操作的边界 + +| 操作 | 能改变什么 | 不能代替什么 | +| --- | --- | --- | +| requirements finalize | 确认当前需求并使旧 ledger stale | 不能归档旧需求并回 Step 2 | +| plan revise | 在允许的步骤更新计划并使 ledger stale | 不能清理 solo/claim/handoff | +| scope-change | 更换 implementation scope,释放/重建 successor claim | 不能改变需求语义或 workflow step | +| `reframe` | 归档旧 requirements/plan、释放 claim、使 ledger stale、回 Step 2 | 不能清除 active assignment 来绕过 eligibility,也不能在 active child/open handoff/git-ref transport 中强行执行 | + +## 8. 兼容矩阵 + +| Writer / project | 旧 Policy 1 workflow | Policy 2 workflow | 新建 `/man` | 结果 | +| --- | --- | --- | --- | --- | +| 0.3.x + V1 未升级项目 | 读写 Policy 1 | 不提供 Policy 2 创建 | 写 1 | 保持旧行为;Policy 2 需要显式升级 | +| 0.4.x + 未升级项目 | 读写 | 不创建、不接受未满足门禁的 2 | 写 1 | CLI 新,但 project policy 未升级 | +| 0.4.x + 已升级项目 | 读写且保留 provenance | 读写,要求 digest/capability | 写 2 | 目标 Policy 2 路径 | +| 0.3.x + V2 已升级项目 | 不允许写入 | 不允许读取后执行 | 不允许创建 | 0.3.18 在 manifest parser 边界拒绝;能解析 V2 的旧 reader/writer 返回 version-too-old failure | +| 任意 CLI + stale adapter | 读诊断 | 拒绝 mutation | 拒绝创建 | 先执行显式 adapter upgrade | +| 任意 CLI + git-ref transport | 正常既有 transport contract | Policy 2 仍可按 transport contract 运行 | 不能 reframe | `MANCODE_REFRAME_GIT_REF_UNSUPPORTED` | + +“读”不等于“执行”:旧 CLI 只能在 V1/Policy 1 的兼容范围内展示安全诊断;遇到 V2 manifest 必须先由 manifest parser 或 reader gate 阻断,不能生成执行指令或写入任何 authority。 + +## 9. 验证矩阵 + +### 9.1 单元和 contract + +- policy parser:1 可接受;0、负数、小数、未知正整数、缺失值和错误 component 均拒绝。 +- workflow create:未升级项目只写 1;升级项目新 `/man` 写 2;已有 workflow 的 policy provenance 不变。 +- Plan Coach:不同目标、无复杂度承担者、多个 recommendation、无 stop condition 均返回 `NEEDS_CLARIFICATION`;简单任务单方向通过。 +- Continuity `/man`、`/manteam` 和 Solo mode entry 的条件式 decision-readiness gate:清晰且可靠的请求直接继续,决策性歧义在 requirements finalize 或代码修改前停下并等待用户回答;不强制无意义提问。 +- stop/re-align:触发时返回 `NEEDS_REALIGNMENT + MANCODE_REFRAME_REQUIRED`,且 authority 的前后 digest 完全相同。 +- Scout/Domain Matrix:可选字段不进入 metadata;高风险 plan 可包含章节;普通任务不被强制增加文件。 +- digest:内容、marker、路径、换行、截断、用户托管区域变化分别得到预期结果;manifest echo 与磁盘不一致必为 stale。 +- manifest:V1/V2 都拒绝 content digest 字段;expected digest 只能由 renderer 和 managed target 重建。 +- compatibility:reader、writer、capability、adapter digest、policy parser 按规定顺序短路,且失败前没有 journal 或业务写。 +- reframe:每个拒绝条件都在第一步失败;每个 recovery action 幂等;中断后不出现部分 Step 2。 + +### 9.2 集成和恢复 + +- 五个平台生成、status、stale 编辑、dry-run、确认升级、升级后重新读取。 +- adapter upgrade 在每个 target 写入前、写入后和中断点恢复;共享 `AGENTS` target 不能被两个平台重复覆盖。 +- local transport 下的 Policy 2 create/plan/review/verification 与旧 workflow 并存。 +- 两个 clone 的 git-ref 兼容门、旧 writer 拒绝、receipt recovery;所有 git-ref reframe 请求必须确认硬拒绝。 +- active child、open handoff、active solo assignment 的并发 race:先取得锁的一方决定,另一方得到 expected-revision 或明确 reframe error。 + +### 9.3 真实宿主和跨平台 + +发布候选必须在 Claude Code、Codex、Cursor、GitHub Copilot、ZCode 以及 Windows CMD/PowerShell/Git Bash 路径上验证:入口传播、session identity、adapter 文件、status/stale 诊断和升级命令。缺少真实宿主证据的平台只能标记 `not_applicable` 并阻止对应能力发布,不能用另一个宿主代替。 + +## 10. 单版本 release gate + +### 10.1 实施工作包 + +| 工作包 | 主要模块 | 必须新增的证据 | 完成定义 | +| --- | --- | --- | --- | +| A. advisory | `src/templates/agents/scout.ts`、Plan Coach template、`src/templates/skills/man.ts`、共享 adapter renderer | template snapshot、八类路由样例、简单任务单方向样例 | 所有 renderer 文案一致;advisory 不直接修改 metadata/schema | +| B. stop/re-align diagnostic | Scout/Plan Coach template、context 输出 | 触发器 contract、authority before/after digest、重复调用测试 | 只返回 `NEEDS_REALIGNMENT + MANCODE_REFRAME_REQUIRED`;authority、step、requirements、plan、claim 不变 | +| C. adapter digest/upgrade | `src/installers/v3-adapter.ts`、status/context callsite、operation recovery、CLI registration | 五平台 target fixture、stale 分类、dry-run、每个 crash point | compatibility 使用实际磁盘 digest;显式 upgrade 可 repair | +| D. policy/version gate | workflow metadata parser、manifest parser、compatibility/resolver、workflow create | V1/V2 白名单、0.3.x + V2 reader/writer fixture、capability/minWriter matrix | 未知 policy/manifest 在写前拒绝;没有 fallback | +| E. project Policy 2 upgrade | 新 project upgrade command/operation、manifest V2 writer、workflow create | V1→V2 dry-run/commit/repair、旧 workflow provenance、新 workflow default | 只有 V2 项目新建 `/man` 写 2;历史 metadata 无 diff | +| F. atomic local reframe | 新 `reframe` context module、operation definition/recovery、archive store、claim/ledger writers | eligibility、并发、每步 crash、archive digest、task-head fence | 单 journal 全前/全后;0.4.0 local transport 发布,git-ref 一律硬拒绝 | + +每个工作包单独合并和评审。C、D 可以并行开发,E 依赖 C 和 D;F 与 E 可以分别完成,但二者都属于同一个 0.4.0 release gate,不能提前开启默认路径。 + +### 10.2 0.4.0 gate + +当前 gate 结果如下。`[x]` 仅表示实现和自动化证据完成;真实宿主或发布验收未完成时,仍不得发布 npm。 + +- [x] 模板和文档 contract 通过;advisory 不直接执行 schema migration、policy 写入或自动 step 变更。 +- [x] 旧 layout-version-3 fixture、legacy fixture、已有 adapter 和历史 workflow 通过原有测试。 +- [x] 触发 stop/re-align 时只返回 `NEEDS_REALIGNMENT + MANCODE_REFRAME_REQUIRED`,authority 文件内容和 claims 不变。 +- [x] Scout/Plan Coach 新字段和规则覆盖正常、缺失证据、冲突 owner、单方向简单任务。 +- [x] Continuity mode entry 恢复条件式需求澄清:清晰需求不机械追问,决策性歧义必须在 finalize/实施前向用户提问并等待回答;五平台生成契约覆盖。 +- [x] adapter digest algorithm、status 分类、stale error、explicit upgrade command 和 recovery contract 完成。 +- [x] policy parser 白名单、writer capability、`minWriterVersion`/reader gate 和 0.3.x + V2 manifest 拒绝 contract 完成;本轮已增加真实发布版 0.3.18 CLI 的黑盒写入拒绝与 authority 全树字节不变证据,后续候选仍需重跑。 +- [x] project upgrade dry-run、确认、commit、repair、abort 和 provenance 完成;现有 workflow 未被批量重写,新 `/man` Policy 2 默认值有明确创建证据。 +- [x] local transport 的完整原子 reframe、recovery、archive retention,以及与 child create、handoff create/start、solo handoff start 的真实并发竞争通过;没有复用 scope-change,也没有部分 authority 或 eligibility 绕过路径。 +- [x] 已升级/未升级项目、旧/新 workflow、local/git-ref transport 的自动化兼容矩阵通过;git-ref reframe 稳定返回 `MANCODE_REFRAME_GIT_REF_UNSUPPORTED`。 +- [x] 5 个 adapter renderer、Windows path、line ending、用户托管区和每个 target 写前/写后中断恢复在 `4dc2e7e` 通过;后续候选仍需重跑 dist/Windows/recovery gate。 +- [x] 平台证据区分 `host_session_verified` 与 `explicit_session_verified`;显式路径验证两个 active、不同且 client 匹配的 session,只满足发布证据,不提升运行时宿主信任。 +- [x] release-check 从远端候选创建干净 checkout,自动运行完整门禁、双 clone、legacy、audit、pack、tarball CLI/module smoke,并输出提交绑定的 SHA-256 证据;脚本不包含 npm publish 或 dist-tag 操作。 +- [ ] Claude Code、Codex、Cursor、GitHub Copilot、ZCode 的最终候选真实宿主验收,以及 [`docs/release-acceptance.md`](./release-acceptance.md) 要求的跨 clone、legacy、Beta gate 和干净 checkout tarball 验收。 +- [x] 按开发集成要求将候选 `4dc2e7e` 推送到远程 `develop`,且 Quality 与 Windows gate 均通过;这不等同于 release gate 通过。 +- [ ] 完成剩余发布验收后才允许进入 npm 发布检查;任何条件失败都不得发布 npm。 + +## 11. 发布回滚与停止策略 + +| 发现的问题 | 立即动作 | 禁止动作 | 恢复方式 | +| --- | --- | --- | --- | +| advisory 模板误导但无 authority 写 | 停止发布、修模板、重跑 snapshot | 不要求用户迁移 workflow | 发布前替换 adapter/模板;已有 authority 不变 | +| adapter stale 或 renderer mismatch | 阻塞 mutation,提示显式 upgrade | 自动 `--force`、手工改 digest | upgrade dry-run → 确认 → journal repair | +| Policy 2 parser/门禁错误 | 关闭 Policy 2 创建入口 | 把 2 降为 1 或修改旧 workflow | 修复 CLI 后重新读取;project marker 保持可审计 | +| project upgrade 中断 | 标记 pending/repair,禁止普通写 | 直接删除半成品或手改 minWriter | `operation repair` 恢复 before/target 状态 | +| reframe 中断或前置条件冲突 | 保留 operation journal,维持 blocked/repair_required | 手动写 Step 2、释放部分 claim | 修复原 operation;无法证明时人工处理后再重试 | +| git-ref reframe 请求 | 稳定返回 `MANCODE_REFRAME_GIT_REF_UNSUPPORTED` | 只在本 clone 先执行、以后同步 | 本计划不提供恢复路径;后续若要支持,必须另立协议设计与发布评审 | + +版本回滚只回滚代码入口和默认创建策略,不回写历史 policy、requirements、plan 或 task step。任何需要修改现有权威内容的“回滚”都必须成为另一个明确、journaled 的 migration,并经过独立评审。 + +## 12. 实施顺序与停止/重新对齐条件 + +截至 `4dc2e7e`,步骤 1–4 是已验证的开发集成候选;本轮生产加固修复形成新候选后,受影响的自动化证据和步骤 5、7 仍需重新完成。 + +1. [x] 冻结本文件中的错误码、字段语义、digest 规范和兼容矩阵,并复核 public workflow create 的 Policy 1 基线。 +2. [x] 完成 A–F 工作包;工作包可以独立合并,但不发布中间 npm 版本。 +3. [x] 完成 adapter digest/upgrade 和 parser/capability gate;当前工作树已通过 0.3.x + V2 manifest reader/writer fixture,新候选仍需重跑。 +4. [x] 完成 project upgrade 和 local reframe 的独立 operation/recovery;只在 upgraded marker 下打开 Policy 2 create default。 +5. [ ] 在同一个 release candidate 上通过第 10.2 节和 [`docs/release-acceptance.md`](./release-acceptance.md) 的全部真实宿主、release-check、Beta gate 和 tarball 检查。 +6. [ ] 将本轮加固后的唯一候选提交推送到远程 `develop`;`4dc2e7e` 的历史推送不替代新候选,且禁止推送、合并或创建以 `main` 为目标的发布操作。 +7. [ ] 确认远程 `develop` 与完整验收证据完全一致后,才允许发布唯一的 npm 版本 0.4.0。git-ref reframe 始终拒绝,不进入本计划范围。 + +任何阶段出现下列情况,都回到“证据和契约评审”,而不是继续编码: + +- 发现两个入口给同一任务不同的 owner、scope 或状态语义; +- authority writer 与 derived copy 的责任无法唯一确定; +- status、contract、policy 或完成门禁的意义需要改变; +- digest 只反映版本常量、无法重建 expected bytes,或跨平台结果不稳定; +- 旧 CLI 能读到新 policy 却没有可靠的 reader/writer/capability 拒绝; +- operation 无法在任意中断点证明“全前或全后”; +- 需求变化需要同时改 requirements、plan、claims、handoff 或 step,但当前 operation 没有覆盖它们。 + +重新对齐完成的最低证据是:新的目标、候选 owner、source of truth、兼容影响、停止条件和唯一 recommendation 已被记录,并由相应 authority writer 重新确认。仅仅修改 Markdown 或手动调整 step 不算重新对齐。 + +## 13. 0.4.0 已定稿的四个实现选择 + +以下选择已由 compatibility contract 固定;发布前不得在缺少重新评审和对应证据时改变: + +1. **Public create 基线:** 用未升级项目的端到端 create contract 证明只写 Policy 1;不得把 CLI 选项、模板默认值或 builder 默认值中的任一项当成唯一证据。 +2. **Digest 的持久化位置:** 固定采用“renderer 重建 expected digest + local 非权威 cache”,不在 V1 或 V2 持久化 content digest。性能不足必须另立设计,不能阻塞后临时扩 schema。 +3. **升级命令的公开名称:** 以现有 `adapter status/upgrade` 命令注册、JSON 输出和 shell 退出码测试确定名称;文档和实现必须只保留一个正式入口。 +4. **Capability 的编码:** 默认由 CLI 内置 capability set 加 project `minWriterVersion` 双门控制,不新增可被旧 CLI 忽略的 manifest capability 字段;local reframe 与 git-ref hard reject 必须使用不同 capability 结果。 + +这四项仍须与第 10.2 节的共同 release gate 一起通过,才允许发布 0.4.0。git-ref reframe 不在支持范围内。 + +## 14. 远程分支与 npm 发布顺序 + +0.4.0 只允许按以下顺序发布: + +`2c9d697` 是上一已验证开发候选,其 [Quality gate](https://github.com/whitelonng/mancode/actions/runs/29930349142) 和 [Windows gate](https://github.com/whitelonng/mancode/actions/runs/29930353690) 均成功。候选后的任何实现、测试或发布文档修改都会使这些证据不再适用于新候选;新的唯一候选必须重新完成第 1–3 步并推送到 `origin/develop`,第 4–6 步必须在任何正式 npm 发布前补齐。 + +1. 所有实现、修复和文档进入本地 `develop`,形成唯一 release candidate commit。 +2. 在该提交上完成第 10.2 节的全部自动化、恢复、跨平台和真实宿主验收。任何失败都必须修复并从头重跑受影响的 release gate。 +3. [ ] 对本轮形成的新候选只执行 `git push origin develop`。禁止直接或间接更新远程 `main`,包括 push、merge、rebase、以 `main` 为目标的 PR 或自动化发布工作流。 +4. 从远程 `develop` 的同一提交运行 `npm run release:check -- --candidate <完整 SHA>`;使用其保留的 tarball 完成真实宿主证据,再运行最终 Beta gate。 +5. 确认 release candidate commit、远程 `develop` commit、测试证据和待发布 package version 完全一致。 +6. 前五步全部成功后才允许执行 `npm publish`。不发布 `0.4.0-beta`、`0.4.0-rc` 或其他中间 npm 版本。 + +若 `npm publish` 前发生任何代码、依赖、构建配置或发布文档变化,候选提交立即失效,必须重新执行步骤 2–5。npm 发布成功不授权更新远程 `main`。 diff --git a/docs/platform-adapters.md b/docs/platform-adapters.md new file mode 100644 index 0000000..6bacc47 --- /dev/null +++ b/docs/platform-adapters.md @@ -0,0 +1,61 @@ +# 平台适配器 + +平台适配器把相同的 Continuity workflow 暴露给不同宿主。它们安装 bootstrap 和 mode 入口,但任务、session、review 与 verification 只存于 `.mancode/`。 + +## 能力矩阵 + +| 平台 | 入口与文件 | 动态能力 | 当前边界 | +|---|---|---|---| +| Claude Code | `CLAUDE.md` 托管区、`.claude/skills/` | 项目 memory 与 mode skills;legacy 模式可用 hooks | 默认 Continuity 不依赖旧 `state.json` hooks | +| Cursor | `.cursor/rules/`、`.cursor/commands/` | rules 与 commands | 无 mancode 原生 session API | +| Codex | `AGENTS.md` 托管区、`.agents/skills/` | `$man*` skills | session 传播需真实宿主证据 | +| GitHub Copilot | instruction 托管区、`.github/prompts/` | instructions 与 prompts | 能力依具体 Copilot 宿主而异 | +| ZCode | `AGENTS.md` 托管区、`.agents/skills/` | provisional `$man*` skills | 项目级发现与命令路径仍需真实 UI 验证 | + +安装示例: + +```bash +mancode init --platform codex,cursor +mancode adapter upgrade --platform copilot --dry-run +mancode adapter upgrade --platform copilot --confirm --operation-id --session --client +mancode status +``` + +manifest 的 `managedAdapters` key 是项目登记的 required 平台集合。greenfield init 只登记所选平台;后续新增或修复平台必须通过带 active session 和显式确认的 adapter upgrade journal。`AGENTS.md` 和 Copilot instruction 文件中托管区外的用户内容必须原样保留。 + +## Bootstrap 合约 + +每个平台都必须: + +- 能发现原来的 `man`、`manba`、`manteam`、`manps` 和 `mansolo` 入口。 +- 在开始任务前解析 Continuity manifest、session、TaskRef 和 Context Pack。 +- 不保存易过期的 task/session 状态副本。 +- 未证明宿主 session 传播时要求显式 session。 +- 保留用户自写配置,并支持重复安装和安全卸载。 + +## Legacy hooks + +只有 `mancode init --legacy` 安装读取 `.mancode/state.json` 的旧 Claude Code hooks。Continuity adapter 不应创建、读取或刷新 legacy authority。 + +Continuity 的 Claude Code bootstrap 位于根目录 `CLAUDE.md` 的 `mancode:continuity:claude` 托管区,确保普通 Solo 请求也会加载;原有 mode skills 仍位于 `.claude/skills/`。Cursor bootstrap 位于 `.cursor/rules/mancode-continuity.mdc`,其他嵌入式托管区同样使用 `mancode:continuity:*` 标记。升级时只自动移除带 mancode 旧管理标记的 `mancode-v3`/旧 Continuity bootstrap 或托管区;用户在 `CLAUDE.md` 和同名旧文件中的自写内容会保留。 + +Windows 上的脚本与文件替换不能依赖 Bash、jq 或 Unix rename 行为。发布流程包含 CMD、PowerShell 和 Git Bash smoke test。 + +## Session 发布证据 + +真实宿主证据必须显式选择 `--session-mode host` 或 `--session-mode explicit`。`host` 模式使用一次性宿主 key 比较双窗口,并且只有完整证明传播、继承和必要 hook approval 后才允许 `host_verified`。`explicit` 模式读取 `MANCODE_SPIKE_SESSION_ID` 与 `MANCODE_SPIKE_SECOND_SESSION_ID`,验证两个 session 已存在、active、互不相同且 client 与平台一致;写入证据前丢弃原始 ID。它可以得到 `explicit_session_verified` 发布证据,但运行时继续要求显式 `--session`。 + +```bash +MANCODE_SPIKE_SESSION_ID= \ +MANCODE_SPIKE_SECOND_SESSION_ID= \ +./node_modules/.bin/mancode context session spike \ + --platform cursor --session-mode explicit --host-session-source none \ + --command-propagation proven \ + --subagent-inheritance not_applicable \ + --subagent-inheritance-reason "Host has no child-agent API" \ + --host-version --release-candidate --json +``` + +## 发布声明 + +“文件能生成”不等于“宿主已验证”。每个平台的双窗口 session、子命令传播和子 agent 继承必须在同一发布候选上记录,才能通过内部 Beta gate。ZCode 在完成该验证前保持 provisional 描述。 diff --git a/docs/project-intelligence.md b/docs/project-intelligence.md new file mode 100644 index 0000000..50b9056 --- /dev/null +++ b/docs/project-intelligence.md @@ -0,0 +1,50 @@ +# 项目检测与健康扫描 + +mancode 先检测项目事实,再决定可用工作流和验证方式。扫描结果是证据,不是对技术栈的猜测。 + +## Project Profile + +Project Profile 记录: + +- 项目类型:backend、web、mobile、desktop、CLI、library、data、mixed 或 unknown。 +- 语言、framework、manifest 和源码根目录。 +- 可用的 build、lint、test 与平台验证命令。 +- 是否检测到 UI 资产和浏览器自动化能力。 +- high、medium 或 low 置信度。 + +普通 Continuity 初始化把可共享项目事实写入 `.mancode/shared/context/project.json`。项目结构变化后运行: + +```bash +mancode refresh-project +``` + +检测不到的 framework 不会写入 profile。Git、manifest 或源码目录缺失时,初始化可以安全降级,但不会把 unknown 项目伪装成 Web 项目。 + +## 设计资产扫描 + +只有 profile 确认存在 UI 资产时,mancode 才扫描设计信号。当前实现识别: + +- Tailwind 配置中的顶层颜色、字体和 dark mode。 +- CSS custom properties。 +- 常见组件文件名。 +- 已检测到的 UI library。 + +结果写入 checkout-local cache。`matchLevel=high` 表示存在可复用配置、CSS token 或组件;`low` 只表示依赖提示;`none` 表示没有可靠资产。 + +```bash +mancode refresh-style +``` + +扫描器不解析任意 `theme.json`、Design Tokens Community Group 文件、Figma 或运行时动态主题。Agent 可以人工读取这些资料,但必须标明它们不是自动检测结果。 + +## Preseason + +`mancode manps` 是确定性健康扫描,支持 `all`、`deps`、`security`、`dead-code` 和 `config`。它检查脚本、依赖重叠、TODO、测试、配置、审美、架构和基础安全信号。 + +```bash +mancode manps deps +mancode manps all --json +mancode manps config --remediate +``` + +报告和问题库保存在 `.mancode/local/`。`--remediate` 仍逐项要求决定,只自动执行白名单内的低风险修复。扫描结果不授权批量改代码,也不能代替项目测试或人工安全审查。 diff --git a/docs/release-acceptance.md b/docs/release-acceptance.md new file mode 100644 index 0000000..ce1fb02 --- /dev/null +++ b/docs/release-acceptance.md @@ -0,0 +1,49 @@ +# 0.4.0 Continuity 发布验收 + +自动化 contract 必须覆盖 Continuity runtime/schema、条件式需求澄清、Policy 2、project upgrade、local reframe、operation recovery、团队协调、git-ref hard reject、五个平台生成结果和 Windows smoke path。条件式需求澄清必须证明清晰请求不被机械提问阻塞,决策性歧义会在 requirements finalize 或实施前停下、提问并等待用户回答。以下证据完成前,不得发布 mancode 0.4.0 或执行 `npm publish`。 + +## 发布条件 + +- 在最终发布候选运行 `npm run release:check -- --candidate <完整提交 SHA>`;该命令必须从 `origin/develop` 创建干净 checkout,运行 `npm ci`、`npm run prepublishOnly`、跨 clone、legacy、audit、pack 和安装 smoke,并输出绑定提交与 tarball SHA-256 的本地 JSON 证据。 +- release-check 先运行 `npm pack --dry-run`,再执行实际 `npm pack`,保留生成的 tarball 并完成 CLI/module smoke;npm beta/rc 不能替代该字节级验证。 +- Windows required check 在 CMD、PowerShell 和 Git Bash 上通过。 +- Claude Code、Codex、Cursor、GitHub Copilot 和 ZCode 都完成真实双窗口 session 验证。每个平台可证明受信宿主 session,或证明两个真实显式 session 隔离;显式证据只满足发布验收,不授权运行时信任宿主 key。 +- 每个平台证明子命令传播;支持子 agent 的平台还需证明继承,或记录合法的 `not_applicable` 原因。缺失、伪造、错误 client、关闭或碰撞的显式 session 证据必须拒绝。 +- 完成跨真实宿主 resume、claim、handoff 和恢复路径。 +- release-check 中两个真实独立 clone 完成 git-ref pull、并发 CAS、handoff、receipt recovery 和代码基线交接;不再重复要求同一流程的人工双 clone。 +- release-check 中的真实 0.3.18 legacy fixture 完成 stage、activation、每个中断点恢复和严格条件下的 rollback;人工 smoke 不能替代 crash matrix。 +- 最终执行 `mancode context beta --release-candidate --json` 返回 `ready: true` 和空 blockers。 +- 候选提交已经推送到 `origin/develop`,且本地 `HEAD`、`origin/develop`、全部验收证据和待发布 package version 指向同一提交。 +- 记录验收前后的 `origin/main` commit,并证明它没有变化。禁止 push、merge、rebase 或创建以 `main` 为目标的发布操作。 +- 上述条件全部成功后才允许执行 `npm publish`;npm 发布成功后也不得更新 `main`。 + +## 发布顺序 + +1. 在本地 `develop` 完成唯一 release candidate。 +2. 完成全部自动化、跨平台、恢复和真实宿主验收。 +3. 只执行 `git push origin develop`。 +4. 从远程 `develop` 的同一提交运行 release-check,使用它保留的 tarball 完成真实宿主 spike,再运行最终 Beta gate。 +5. 所有证据一致且 `origin/main` 未变化后,执行唯一一次正式 `npm publish`。 + +任何测试失败或候选提交变化都会使已有验收失效。修复后必须重新验证;不得先发布 npm 再补证据。 + +## 证据规则 + +所有证据必须绑定同一个 `origin/develop` commit、mancode version、操作系统和宿主版本。历史祖先提交、未推送提交或替代宿主结果只能用于调试,不能用于最终 Beta gate。 + +session evidence 不保存原始 key、token、绝对业务路径或任务正文。屏幕截图和日志应脱敏,并保存在发布流程约定的本地证据目录,而不是长期开发文档中。 + +## 当前未完成项 + +- 本轮发布门禁修复形成的新候选上的 Quality gate,以及 Windows CMD、PowerShell、Git Bash required gate。 +- 五个平台在最终候选上的完整 session spike。 +- 跨真实宿主协作与恢复验收。 +- 最终候选上的 release-check,包括自动双 clone git-ref 和 legacy 验收。 +- ZCode 项目级 skill 发现和 workspace command 路径确认。 +- release-check 输出的候选 tarball、SHA-256、安装与 CLI/module smoke 证据。 +- 验收前后 `origin/main` 未变化的记录。 +- 汇总全部证据后的最终 Beta gate。 + +上一开发候选 `2c9d697` 的 [Quality gate](https://github.com/whitelonng/mancode/actions/runs/29930349142) 和 [Windows compatibility gate](https://github.com/whitelonng/mancode/actions/runs/29930353690) 均已通过,后者覆盖 Windows CMD、PowerShell 和 Git Bash。该证据是历史基线;本轮门禁修复形成新候选后必须重新执行。当前候选 SHA 和运行链接保存在外部发布证据中,避免把自引用 SHA 写进候选提交。 + +完成一项时更新本文件的未完成列表;不要创建新的平行验收计划。 diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 0000000..34751b0 --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,130 @@ +# 工作流与团队协作 + +mancode 不把“当前模式”保存成全局开关。平台入口创建或恢复显式 session 与 TaskRef,再读取 Context Pack。 + +## 模式 + +| 模式 | 用途 | 持久任务 | +|---|---|---| +| `solo` | 小改、最窄验证和一次受限 diff 自检;先判断需求是否清晰 | 否;可执行已确认的 solo handoff | +| `manba` | 复现、根因诊断、最小修复或真实验证 | 是,5 步 | +| `man` | 需求对齐、计划及可选完整治理 | 是,9 步 | +| `manteam` | 带 owner、participant、scope 和 claim 的团队治理 | 是,9 步 | +| `manps` | 确定性项目健康扫描 | 否 | + +平台入口分别表现为 `/man`、`$man` 或 prompt,具体映射见 [platform-adapters.md](./platform-adapters.md)。 + +### 条件式需求澄清 + +`solo` 和 `/man` 都先判断需求是否足够清晰,不机械追问。目标、范围、验收边界和关键约束可以从用户请求、项目事实或明确的安全默认值确定时,直接继续;不需要为了形式制造问题。 + +如果仍有会改变目标、范围、用户可见行为、验收、架构、数据、安全、兼容性、owner 或 source of truth 的歧义,必须先向用户提出聚焦问题并等待回答。回答前不得把假设写成 confirmed requirements;复杂度、owner、迁移、跨模块或团队决策超出 Solo 边界时,应推荐 `/man` 并等待用户选择。该判断属于 Continuity mode entry 的固定行为契约。 + +| 输入状态 | 处理方式 | +| --- | --- | +| 目标与需求清晰、与项目证据一致、风险低 | 默认 Solo 直接执行最窄改动,不创建 session 或 TaskRef,不做形式化追问 | +| 目标清晰、需求有缺口 | 先把未知项分成 blocking、recommendable、defaultable;只为会改变决策的 blocking 项停下提问 | +| 表述明确但与项目证据冲突,或涉及认证、支付、敏感数据、删除、迁移、公开 API、并发、基础设施等高风险边界 | 展示证据和影响,推荐更安全路径,取得聚焦确认后再继续;“明确”不等于“正确或安全” | +| 用户明确请求计划、架构、迁移设计或正式验收 | 可直接进入 `/man` 规划路径;普通实现中途遇到这类决策时只推荐 `/man`,不得静默切换权威 | + +受治理任务在等待 blocking 回答前,必须把已知事实、部分决定和开放问题写入 requirements draft: + +```bash +mancode workflow requirements local: draft \ + --file requirements.json --expected-revision N --session +``` + +draft 的 `blockingUnknowns` 必须列出开放决定;scope、coverage、technical decisions 或 acceptance 可以暂不完整。后续会话通过 TaskRef 恢复同一澄清状态,每次回答后更新 draft;只有 blocking 项清零且 requirements 完整时才能 `finalize`。`manba` 在修复前还必须先从复现、测试、文档、历史或语义 owner 建立预期行为,无法确定时先问一个聚焦问题。 + +## `man` 流程 + +1. 调研已有实现、复用点、风险和未知项。 +2. 澄清会改变范围、架构、成本或验收的问题。 +3. 建立带版本的计划。 +4. 用户选择只保留计划、solo handoff、完整治理或修订计划。 +5. 按确认范围实施。 +6. 运行验证并确定 targeted/full 审查范围。 +7. 质量审查。 +8. 仅在 full 深度执行安全与边界审查。 +9. 最多一轮 blocker 修复、复验、summary 和完成。 + +需求未 ready、计划未确认、验证失败、审查 blocker 未清零、存在活动子任务或未完成 repair 时,任务不能完成。 + +## 状态与 revision + +工作流状态为 `in_progress`、`planned`、`blocked`、`completed` 或 `abandoned`。终态不可恢复;`blocked` 只能在阻塞条件被显式处理后回到 `in_progress`。 + +每个写命令都需要当前 `expected-revision`。这是一条 compare-and-swap 约束,不是可选提示。revision、requirements digest 或 plan version 变化后,旧 review 和 verification 可能变为 `stale`。 + +典型路径: + +```bash +mancode context session new --client codex --json +mancode context session show --session --client codex --json +mancode workflow create man "添加导出功能" --session --json +mancode workflow requirements local: finalize \ + --file requirements.json --expected-revision 1 --session +mancode workflow plan local: revise \ + --file plan.md --expected-revision 2 --session +mancode workflow plan local: confirm \ + --plan-decision governed_execution --expected-revision 3 --session +``` + +`plan revise` 必须通过 `--file ` 读取 Markdown 计划。修订与确认是两个独立写操作;每次写入后都应从命令结果或 `mancode workflow show --json` 获取最新 revision,再用于下一次 `--expected-revision`。只保留计划时把确认参数改为 `--plan-decision plan_only`。不要手工编辑 metadata 或 ledger。 + +## Policy 2 与需求重新对齐 + +项目治理升级是显式操作,不会批量重写已有 workflow: + +```bash +mancode project upgrade --policy 2 --dry-run +mancode project upgrade --policy 2 --operation-id --session --client +``` + +升级后的项目只对新建 `/man` workflow 默认使用 planning Policy 2;历史 workflow 继续使用创建时记录的 policy。 + +当新证据推翻已确认需求时,local workflow 可以从现有 checkpoint 执行原子 reframe: + +```bash +mancode context resume local: --session --client --json +mancode workflow reframe local: \ + --expected-revision N \ + --checkpoint-id \ + --summary "新证据为何使当前需求失效" \ + --next-action "回到 Step 2 后要澄清的事项" \ + --session --json +``` + +仅在 session 尚未指向目标 TaskRef 时执行 `context resume`。`--checkpoint-id` 必须是本次操作新生成的规范 ULID,不能复用旧 checkpoint。reframe 会原子归档当前 requirements、plan 和 ledger,释放有效 claim,清除 plan decision,并把任务带回 Step 2 的 draft requirements;完成命令后应停止实施,先重新澄清、finalize requirements、revise plan,再由用户确认计划。它只接受 requirements 已确认、且不存在 active child、open handoff 或 active solo assignment 的 local workflow;git-ref transport 明确拒绝该操作。 + +reframe 的 JSON 结果会返回 `archive.archiveId` 与 `checkpoint.checkpointId`。可通过只读 CLI 检查证据,无需读取 `.mancode` 私有 authority 文件: + +```bash +mancode workflow archive local: show --json +mancode workflow checkpoint local: show --json +``` + +archive 输出会校验归档摘要,并返回 reframe 前的 requirements 与 plan;checkpoint 输出返回该次 reframe 的完整 checkpoint。这两个命令不修改 workflow,也不需要 `--session`。 + +## Session 与 Context Pack + +session 是 checkout-local 的调用身份,不决定任务是否完成。没有真实宿主传播证据时,mutating command 必须显式传 `--session`。 + +`mancode context show` 按 `bootstrap`、`task` 或 `full` 级别生成 Context Pack。它只返回与当前 revision 和 digest 一致的内容;发现未完成 operation 时返回 repair envelope。 + +## 团队协作 + +团队任务使用显式 actor、participant 和 implementation scope: + +```bash +mancode team identity create --name "Alice" +mancode team join --name "Alice" --session +mancode team claim shared: \ + --path 'src/api/**' --expected-task-revision N --session +``` + +claim 声明 path、module、API 或 schema 边界。任务或代码基线漂移后需要 revalidate;lease 过期不自动授权另一个 writer 接管。 + +handoff 必须经过 `draft → offered → accepted|rejected|cancelled`。accept 会在同一个 journaled operation 中更新 owner、claim、checkpoint 和 task head,避免出现两个合法 owner。 + +只有经过明确确认且通过隐私筛查的决策才能进入 shared memory。任务文本、绝对路径、凭据和宿主 session key 不应写入共享 transport。 diff --git a/package-lock.json b/package-lock.json index 61744e8..3ad16a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mancode", - "version": "0.3.18", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mancode", - "version": "0.3.18", + "version": "0.4.0", "license": "AGPL-3.0-only", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index b2e9989..021e2a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mancode", - "version": "0.3.18", + "version": "0.4.0", "description": "AI coding agent workflow harness with mancode Continuity for cross-conversation tasks, decisions, verification, and team coordination.", "type": "module", "license": "AGPL-3.0-only", @@ -63,6 +63,7 @@ "test:windows-smoke": "node scripts/windows-smoke.mjs", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "release:check": "node scripts/release-check.mjs", "lint": "biome check src tests", "typecheck": "tsc --noEmit", "format": "biome format --write src tests", diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..f7b8283 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,317 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const sourceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const options = parseOptions(process.argv.slice(2)); + +if (options.help) { + console.log( + 'Usage: npm run release:check -- --candidate [--output ]', + ); + process.exit(0); +} + +const sourceStatus = runCaptured('git', ['status', '--porcelain'], sourceRoot); +assert(sourceStatus === '', 'release check requires a clean source checkout'); + +const candidate = normalizeCandidate( + options.candidate ?? runCaptured('git', ['rev-parse', 'HEAD'], sourceRoot), +); +const sourceHead = runCaptured('git', ['rev-parse', 'HEAD'], sourceRoot); +assert(sourceHead === candidate, 'release candidate must equal source HEAD'); + +const remote = runCaptured('git', ['remote', 'get-url', 'origin'], sourceRoot); +const originDevelop = remoteRef(remote, 'refs/heads/develop'); +const originMainBefore = remoteRef(remote, 'refs/heads/main'); +assert( + originDevelop === candidate, + 'release candidate must equal origin/develop', +); + +const outputPath = path.resolve( + sourceRoot, + options.output ?? + path.join( + '.mancode', + 'local', + 'release-evidence', + `${candidate}.json`, + ), +); +const outputDirectory = path.dirname(outputPath); +const temporaryRoot = await mkdtemp( + path.join(tmpdir(), 'mancode-release-check-'), +); +const checkout = path.join(temporaryRoot, 'checkout'); +const installFixture = path.join(temporaryRoot, 'install-smoke'); +const checks = []; + +try { + runCheck(checks, 'clean_clone', 'git', [ + 'clone', + '--branch', + 'develop', + '--single-branch', + remote, + checkout, + ], temporaryRoot); + assert( + runCaptured('git', ['rev-parse', 'HEAD'], checkout) === candidate, + 'clean checkout does not match the release candidate', + ); + + runCheck(checks, 'npm_ci', 'npm', ['ci'], checkout); + runCheck( + checks, + 'prepublish', + 'npm', + ['run', 'prepublishOnly'], + checkout, + ); + runCheck( + checks, + 'cross_clone', + 'npx', + [ + 'vitest', + 'run', + 'tests/git-ref-cross-clone-e2e.test.ts', + 'tests/git-ref-clock-skew-contracts.test.ts', + 'tests/git-ref-handoff-repair-contracts.test.ts', + ], + checkout, + ); + runCheck( + checks, + 'legacy_migration', + 'npx', + [ + 'vitest', + 'run', + 'tests/migrate-contracts.test.ts', + 'tests/migrate-command-contracts.test.ts', + 'tests/migration-parity-contracts.test.ts', + 'tests/legacy-cli-compatibility-contracts.test.ts', + ], + checkout, + ); + + const audit = JSON.parse( + runCaptured('npm', ['audit', '--omit=dev', '--json'], checkout), + ); + const vulnerabilities = audit.metadata?.vulnerabilities?.total; + assert(vulnerabilities === 0, 'production dependency audit is not clean'); + checks.push({ name: 'production_audit', status: 'passed' }); + + const dryRun = parseSinglePackResult( + runCaptured('npm', ['pack', '--dry-run', '--json'], checkout), + ); + checks.push({ name: 'pack_dry_run', status: 'passed' }); + const packed = parseSinglePackResult( + runCaptured('npm', ['pack', '--json'], checkout), + ); + const tarballPath = path.join(checkout, packed.filename); + const tarballBytes = await readFile(tarballPath); + const tarballSha256 = createHash('sha256') + .update(tarballBytes) + .digest('hex'); + + await mkdir(installFixture, { recursive: true }); + await writeFile( + path.join(installFixture, 'package.json'), + `${JSON.stringify({ name: 'mancode-release-install-smoke', private: true })}\n`, + ); + runCheck( + checks, + 'tarball_install', + 'npm', + ['install', '--no-audit', '--no-fund', tarballPath], + installFixture, + ); + const installedCli = path.join( + installFixture, + 'node_modules', + 'mancode', + 'dist', + 'cli.js', + ); + const installedVersion = runCaptured( + process.execPath, + [installedCli, '--version'], + installFixture, + ); + const packageMetadata = JSON.parse( + await readFile(path.join(checkout, 'package.json'), 'utf8'), + ); + assert( + installedVersion === packageMetadata.version, + 'installed CLI version does not match package.json', + ); + runCheck( + checks, + 'tarball_cli', + process.execPath, + [installedCli, 'init', '--empty', '--platform', 'all', '--lang', 'en'], + installFixture, + ); + const status = JSON.parse( + runCaptured( + process.execPath, + [installedCli, 'status', '--brief', '--json'], + installFixture, + ), + ); + assert( + status.runtime === 'mancode-continuity' && status.ready === true, + 'installed tarball did not produce a ready Continuity runtime', + ); + runCheck( + checks, + 'tarball_module', + process.execPath, + [ + '--input-type=module', + '--eval', + "const mod = await import('mancode'); if (Object.keys(mod).length === 0) process.exit(1);", + ], + installFixture, + ); + + const originMainAfter = remoteRef(remote, 'refs/heads/main'); + assert(originMainAfter === originMainBefore, 'origin/main changed during release check'); + checks.push({ name: 'origin_main_unchanged', status: 'passed' }); + + await mkdir(outputDirectory, { recursive: true }); + const artifactFile = `mancode-${packageMetadata.version}-${candidate.slice(0, 12)}.tgz`; + await copyFile(tarballPath, path.join(outputDirectory, artifactFile)); + const report = { + schemaVersion: 1, + localReady: true, + releaseCandidate: candidate, + packageVersion: packageMetadata.version, + generatedAt: new Date().toISOString(), + source: { + branch: 'develop', + originDevelop, + originMainBefore, + originMainAfter, + }, + environment: { + nodeVersion: process.version, + npmVersion: runCaptured('npm', ['--version'], checkout), + platform: process.platform, + arch: process.arch, + }, + tarball: { + artifactFile, + sha256: tarballSha256, + npmShasum: packed.shasum, + npmIntegrity: packed.integrity, + size: packed.size, + unpackedSize: packed.unpackedSize, + fileCount: packed.entryCount ?? packed.files?.length ?? null, + dryRunFileCount: dryRun.entryCount ?? dryRun.files?.length ?? null, + }, + checks, + externalGates: { + platformSessionEvidence: 'pending', + betaGate: 'pending', + githubQuality: 'pending', + githubWindows: 'pending', + }, + }; + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Local release check passed: ${outputPath}`); + console.log(`Candidate tarball: ${path.join(outputDirectory, artifactFile)}`); + console.log(`SHA-256: ${tarballSha256}`); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function runCheck(checks, name, command, args, cwd) { + run(command, args, cwd, 'inherit'); + checks.push({ name, status: 'passed' }); +} + +function runCaptured(command, args, cwd) { + return run(command, args, cwd, 'pipe').trim(); +} + +function run(command, args, cwd, stdio) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = [result.stderr, result.stdout].filter(Boolean).join('\n'); + throw new Error( + `${command} ${args.join(' ')} failed (${result.status})${detail ? `:\n${detail}` : ''}`, + ); + } + return typeof result.stdout === 'string' ? result.stdout : ''; +} + +function remoteRef(remote, ref) { + const output = runCaptured('git', ['ls-remote', remote, ref], sourceRoot); + const [commit] = output.split(/\s+/u); + return normalizeCandidate(commit ?? ''); +} + +function parseSinglePackResult(output) { + const parsed = JSON.parse(output); + assert( + Array.isArray(parsed) && parsed.length === 1, + 'npm pack returned an unexpected result', + ); + return parsed[0]; +} + +function normalizeCandidate(value) { + const candidate = String(value).trim(); + assert( + /^[a-f0-9]{40}$/u.test(candidate), + 'release candidate must be a full Git commit SHA', + ); + return candidate; +} + +function parseOptions(args) { + const parsed = { candidate: null, output: null, help: false }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--help') { + parsed.help = true; + continue; + } + if (argument !== '--candidate' && argument !== '--output') { + throw new Error(`unknown release-check argument: ${argument}`); + } + const value = args[index + 1]; + assert(value !== undefined, `${argument} requires a value`); + if (argument === '--candidate') parsed.candidate = value; + else parsed.output = value; + index += 1; + } + return parsed; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} diff --git a/scripts/verify-dist-adapters.mjs b/scripts/verify-dist-adapters.mjs index 7f2caac..157a49f 100644 --- a/scripts/verify-dist-adapters.mjs +++ b/scripts/verify-dist-adapters.mjs @@ -19,9 +19,9 @@ try { runCli(['init', '--empty', '--platform', 'all', '--lang', 'en']); const generatedFiles = [ - '.claude/skills/mancode-v3/SKILL.md', + 'CLAUDE.md', '.claude/skills/man/SKILL.md', - '.cursor/rules/mancode-v3.mdc', + '.cursor/rules/mancode-continuity.mdc', '.cursor/commands/man.md', 'AGENTS.md', '.agents/skills/man/SKILL.md', @@ -30,7 +30,10 @@ try { ]; for (const relativePath of generatedFiles) { - const content = await readFile(path.join(projectRoot, relativePath), 'utf8'); + const content = await readFile( + path.join(projectRoot, relativePath), + 'utf8', + ); assertGeneratedContract(relativePath, content); } @@ -79,6 +82,8 @@ function assertGeneratedContract(relativePath, content) { 'mancode status --json', 'authority: "v3"', 'v3_active', + 'mancode-v3', + 'mancode:v3', ]) { assert( !content.includes(forbidden), diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs index 259f48e..3f206b6 100644 --- a/scripts/windows-smoke.mjs +++ b/scripts/windows-smoke.mjs @@ -36,7 +36,7 @@ try { ); await readFile(path.join(emptyProject, 'AGENTS.md'), 'utf8'); await readFile( - path.join(emptyProject, '.cursor', 'rules', 'mancode-v3.mdc'), + path.join(emptyProject, '.cursor', 'rules', 'mancode-continuity.mdc'), 'utf8', ); await readFile( @@ -215,6 +215,8 @@ async function assertV3SessionEvidenceRenameUnderOpenWindowsHandle() { 'spike', '--platform', 'codex', + '--session-mode', + 'host', '--host-session-source', 'api', '--command-propagation', diff --git a/src/cli.ts b/src/cli.ts index 2f1e0e2..320c2b7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Option, program } from 'commander'; +import { adapterStatus, adapterUpgrade } from './commands/adapter.js'; import { contextBeta, contextClose, @@ -10,6 +11,7 @@ import { contextReconcileTaskHead, contextResume, contextSessionNew, + contextSessionShow, contextSessionSpike, contextShow, contextWorktreeRegister, @@ -24,6 +26,7 @@ import { operationRepair, operationShow, } from './commands/operation.js'; +import { projectUpgrade } from './commands/project.js'; import { refreshProject } from './commands/refresh-project.js'; import { refreshStyle } from './commands/refresh-style.js'; import { status } from './commands/status.js'; @@ -99,6 +102,10 @@ program '--shadow', 'Stage a mancode bootstrap candidate without changing live files', ) + .option('--confirm', 'Confirm a journaled adapter install or repair') + .option('--operation-id ', 'Operation ID returned by adapter dry-run') + .option('--session ', 'mancode session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') .action(async (platform, options) => { const code = await install( process.cwd(), @@ -108,6 +115,34 @@ program process.exitCode = code; }); +const adapterProgram = program + .command('adapter') + .description('Inspect and explicitly upgrade managed platform adapters'); + +adapterProgram + .command('status') + .description('Inspect managed adapter content on disk') + .option('--platform ', 'Inspect one platform adapter') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await adapterStatus(process.cwd(), options); + }); + +adapterProgram + .command('upgrade') + .description('Preview or repair managed adapter content') + .option('--all', 'Upgrade all platform adapters') + .option('--platform ', 'Upgrade one platform adapter') + .option('--dry-run', 'Stage and report changes without writing live targets') + .option('--confirm', 'Confirm the journaled adapter upgrade') + .option('--operation-id ', 'Operation ID returned by adapter dry-run') + .option('--session ', 'mancode session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await adapterUpgrade(process.cwd(), options); + }); + program .command('status') .description('Show current mancode project status') @@ -118,6 +153,23 @@ program process.exitCode = code; }); +const projectProgram = program + .command('project') + .description('Manage project-level mancode policy and compatibility'); + +projectProgram + .command('upgrade') + .description('Upgrade project governance policy explicitly') + .requiredOption('--policy ', 'Target planning policy version (2)') + .option('--dry-run', 'Preview the upgrade without writing') + .option('--operation-id ', 'Operation ID returned by project dry-run') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await projectUpgrade(process.cwd(), options); + }); + program .command('list-platforms') .description('List available and installed mancode platform adapters') @@ -159,6 +211,7 @@ program .option('--session ', 'mancode session ID (otherwise MANCODE_SESSION_ID)') .option('--client ', 'Client identity (default: mancode-cli)') .option('--expected-revision ', 'Expected task revision for mutations') + .option('--checkpoint-id ', 'Checkpoint ID for a requirements reframe') .option('--child-revision ', 'Expected child task revision for merge') .option('--summary ', 'Privacy-screened child result summary') .option('--next-action ', 'Next parent action after a child merge') @@ -197,7 +250,7 @@ program ) .option( '--file ', - 'Semantic or canonical structured requirements JSON input file', + 'Semantic requirements JSON, plan Markdown, or ledger input file', ) .option('--acceptance ', 'Acceptance criterion id (for example AC-1)') .option('--method ', 'Verification method: automated or manual') @@ -250,6 +303,16 @@ contextSessionProgram process.exitCode = await contextSessionNew(process.cwd(), options); }); +contextSessionProgram + .command('show') + .description('Show one explicit session without changing it') + .requiredOption('--session ', 'Session ID') + .option('--client ', 'Expected client identity') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextSessionShow(process.cwd(), options); + }); + contextSessionProgram .command('spike') .description('Record real-host session evidence without persisting host keys') @@ -257,9 +320,10 @@ contextSessionProgram '--platform ', 'claude-code, codex, cursor, copilot, or zcode', ) + .requiredOption('--session-mode ', 'Evidence path: host or explicit') .requiredOption( '--host-session-source ', - 'hook_stdin, environment, or api', + 'hook_stdin, environment, api, or none for explicit sessions', ) .requiredOption( '--command-propagation ', diff --git a/src/commands/adapter.ts b/src/commands/adapter.ts new file mode 100644 index 0000000..c8ebdda --- /dev/null +++ b/src/commands/adapter.ts @@ -0,0 +1,150 @@ +import { assertUlid } from '../context/ids.js'; +import { + managedAdapterInventoriesMatch, + managedAdapterNames, +} from '../context/manifest.js'; +import { V3ContextStore } from '../context/store.js'; +import { upgradeV3Adapters } from '../installers/adapter-upgrade.js'; +import { + type PlatformName, + getPlatformInstaller, +} from '../installers/registry.js'; +import { + V3_ADAPTER_PLATFORMS, + inspectV3Adapter, + v3AdapterVersionsFromStatuses, +} from '../installers/v3-adapter.js'; +import { + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; + +export interface AdapterStatusOptions { + platform?: string; + json?: boolean; +} + +export interface AdapterUpgradeOptions extends AdapterStatusOptions { + all?: boolean; + dryRun?: boolean; + confirm?: boolean; + operationId?: string; + session?: string; + client?: string; +} + +export async function adapterStatus( + rootDir: string, + options: AdapterStatusOptions, +): Promise { + try { + const platforms = selectStatusPlatforms(options.platform); + const project = await new V3ContextStore(rootDir).readProjectSnapshot(); + const entries = await Promise.all( + platforms.map( + async (platform) => + [platform, await inspectV3Adapter(rootDir, platform)] as const, + ), + ); + const adapters = Object.fromEntries(entries); + const readinessPlatforms = + options.platform === undefined + ? new Set( + managedAdapterNames(project.manifest.managedAdapters), + ) + : new Set(platforms); + const manifestAdapters = Object.fromEntries( + platforms.flatMap((platform) => { + const version = project.manifest.managedAdapters[platform]; + return version === undefined ? [] : [[platform, version]]; + }), + ); + const actualAdapterVersions = v3AdapterVersionsFromStatuses( + entries, + readinessPlatforms, + ); + const actualAdaptersReady = entries.every( + ([platform, status]) => + actualAdapterVersions[platform] === undefined || status.ready, + ); + return printV3Result(options.json, { + schemaVersion: 1, + renderer: 'mancode-adapter-digest-v1', + ready: + actualAdaptersReady && + managedAdapterInventoriesMatch(manifestAdapters, actualAdapterVersions), + manifestAdapters, + adapters, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_ADAPTER_STATUS_FAILED'), + error instanceof Error ? error.message : 'Unable to inspect adapters.', + ); + } +} + +export async function adapterUpgrade( + rootDir: string, + options: AdapterUpgradeOptions, +): Promise { + try { + const platforms = selectUpgradePlatforms(options); + if (options.operationId !== undefined) { + assertUlid(options.operationId, 'adapter upgrade operationId'); + } + let sessionId: string | undefined; + if (options.dryRun !== true && options.confirm === true) { + const project = await readV3CommandProject(rootDir); + sessionId = ( + await resolveV3CommandSession(project, { + session: options.session, + client: options.client, + }) + ).sessionId; + } + const result = await upgradeV3Adapters({ + projectRoot: rootDir, + platforms, + dryRun: options.dryRun, + explicitConfirmation: options.confirm, + ...(options.operationId === undefined + ? {} + : { operationId: options.operationId }), + ...(sessionId === undefined ? {} : { sessionId }), + }); + return printV3Result(options.json, result); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_ADAPTER_UPGRADE_FAILED'), + error instanceof Error ? error.message : 'Unable to upgrade adapters.', + ); + } +} + +function selectStatusPlatforms(platform: string | undefined): PlatformName[] { + if (platform === undefined) return [...V3_ADAPTER_PLATFORMS]; + const installer = getPlatformInstaller(platform); + if (installer === null) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PLATFORM_INVALID'); + } + return [installer.name]; +} + +function selectUpgradePlatforms( + options: AdapterUpgradeOptions, +): PlatformName[] { + if ((options.all === true) === (options.platform !== undefined)) { + throw new Error( + 'MANCODE_ADAPTER_UPGRADE_SELECTION_REQUIRED: choose exactly one of --all or --platform.', + ); + } + return options.all === true + ? [...V3_ADAPTER_PLATFORMS] + : selectStatusPlatforms(options.platform); +} diff --git a/src/commands/context.ts b/src/commands/context.ts index 9d64da9..fbfc977 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -1,4 +1,7 @@ +import { CURRENT_WRITER_CAPABILITIES } from '../context/compatibility.js'; import type { ContextLevel, ContextPurpose } from '../context/context-pack.js'; +import { isUlid } from '../context/ids.js'; +import { managedAdapterNames } from '../context/manifest.js'; import { previewV3TaskPromotion, promoteV3Task, @@ -10,6 +13,7 @@ import { reconcileV3TaskHead, } from '../context/task-head-reconcile.js'; import { parseTaskRef } from '../context/task-ref.js'; +import { inspectV3AdapterVersions } from '../installers/v3-adapter.js'; import { evaluateV3BetaGate } from '../runtime/beta-gate.js'; import { readLocalDiagnostics, @@ -29,6 +33,7 @@ import { type HookApprovalStatus, type HostSessionSource, SESSION_SPIKE_PLATFORMS, + type SessionEvidenceMode, type SessionSpikePlatform, type SpikeEvidenceStatus, createPlatformSessionSpike, @@ -52,6 +57,7 @@ import { type SessionStateV1, closeSession, createBootstrapSession, + readSession, resumeSession, } from '../runtime/session.js'; import { readLocalActor } from '../team/actor.js'; @@ -82,8 +88,15 @@ export interface ContextSessionNewOptions { json?: boolean; } +export interface ContextSessionShowOptions { + session?: string; + client?: string; + json?: boolean; +} + export interface ContextSessionSpikeOptions { platform?: string; + sessionMode?: string; hostSessionSource?: string; commandPropagation?: string; subagentInheritance?: string; @@ -180,6 +193,49 @@ export async function contextSessionNew( } } +/** Implements `mancode context session show --session ...`. */ +export async function contextSessionShow( + rootDir: string, + options: ContextSessionShowOptions, +): Promise { + if (options.session === undefined) { + return printV3Error( + options.json, + 'MANCODE_SESSION_REQUIRED', + 'context session show requires --session .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + if (!isUlid(options.session)) { + return printV3Error( + options.json, + 'MANCODE_SESSION_INVALID', + 'context session show requires a canonical ULID in --session .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await readSession(project.projectRoot, options.session); + if (session === null) { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + if ( + options.client !== undefined && + session.client !== commandClient(options.client) + ) { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + return printV3Result(options.json, { schemaVersion: 1, session }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_SESSION_SHOW_FAILED'), + error instanceof Error ? error.message : 'Unable to show the session.', + ); + } +} + /** * Records one real-host session spike without accepting or persisting host * keys as command-line arguments. Operators supply the two window values via @@ -191,10 +247,8 @@ export async function contextSessionSpike( ): Promise { try { const platform = parseSpikePlatform(options.platform); + const sessionMode = parseSessionEvidenceMode(options.sessionMode); const hostSessionSource = parseHostSessionSource(options.hostSessionSource); - if (hostSessionSource === 'none') { - throw new Error('MANCODE_PLATFORM_SPIKE_HOST_SOURCE_REQUIRED'); - } const releaseCandidate = parseReleaseCandidate(options.releaseCandidate); const hostVersion = parseHostVersion(options.hostVersion); const commandPropagation = parseRequiredSpikeEvidenceStatus( @@ -205,23 +259,35 @@ export async function contextSessionSpike( options.subagentInheritance, 'subagent inheritance', ); - const firstWindowHostSessionKey = - process.env.MANCODE_SPIKE_HOST_SESSION_KEY ?? null; - const secondWindowHostSessionKey = - process.env.MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY ?? null; - if ( - firstWindowHostSessionKey === null || - secondWindowHostSessionKey === null - ) { + const project = await readV3CommandProject(rootDir); + const [firstWindowSessionKey, secondWindowSessionKey] = + sessionMode === 'host' + ? [ + process.env.MANCODE_SPIKE_HOST_SESSION_KEY ?? null, + process.env.MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY ?? null, + ] + : [ + process.env.MANCODE_SPIKE_SESSION_ID ?? null, + process.env.MANCODE_SPIKE_SECOND_SESSION_ID ?? null, + ]; + if (firstWindowSessionKey === null || secondWindowSessionKey === null) { throw new Error('MANCODE_PLATFORM_SPIKE_WINDOW_EVIDENCE_REQUIRED'); } - const project = await readV3CommandProject(rootDir); + if (sessionMode === 'explicit') { + await assertExplicitSpikeSessions( + project.projectRoot, + platform, + firstWindowSessionKey, + secondWindowSessionKey, + ); + } const spike = createPlatformSessionSpike({ platform, observedAt: new Date().toISOString(), + sessionMode, hostSessionSource, - firstWindowHostSessionKey, - secondWindowHostSessionKey, + firstWindowSessionKey, + secondWindowSessionKey, commandPropagation, subagentInheritance, subagentInheritanceReason: options.subagentInheritanceReason ?? null, @@ -261,6 +327,32 @@ export async function contextSessionSpike( } } +async function assertExplicitSpikeSessions( + projectRoot: string, + platform: SessionSpikePlatform, + firstSessionId: string, + secondSessionId: string, +): Promise { + if (firstSessionId === secondSessionId) { + throw new Error('MANCODE_PLATFORM_SPIKE_SESSION_COLLISION'); + } + const [first, second] = await Promise.all([ + readSession(projectRoot, firstSessionId), + readSession(projectRoot, secondSessionId), + ]); + if ( + first === null || + second === null || + first.status !== 'active' || + second.status !== 'active' + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_SESSION_NOT_FOUND'); + } + if (first.client !== platform || second.client !== platform) { + throw new Error('MANCODE_PLATFORM_SPIKE_SESSION_CLIENT_MISMATCH'); + } +} + /** Reports internal release readiness and exits non-zero until every gate passes. */ export async function contextBeta( rootDir: string, @@ -301,7 +393,7 @@ export async function contextResume( const project = await readV3CommandProject(rootDir); const session = await resolveV3CommandSession(project, options); const taskRef = parseTaskRef(task); - const resolution = await resolveContext(project, session, { + let resolution = await resolveContext(project, session, { taskRef, level: 'bootstrap', purpose: 'orient', @@ -310,15 +402,29 @@ export async function contextResume( if (resolution.metadata === null || resolution.aggregate === null) { throw new Error('MANCODE_CONTEXT_WRITE_BLOCKED'); } - const resumed = await resumeSession( - project.projectRoot, - session.sessionId, - { + let resumed = session; + let packIsCurrent = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + resumed = await resumeSession(project.projectRoot, session.sessionId, { taskRef: resolution.taskRef, workflowMode: resolution.metadata.workflowMode, taskRevision: resolution.aggregate.taskRevision, - }, - ); + }); + resolution = await resolveContext(project, resumed, { + taskRef, + level: 'bootstrap', + purpose: 'orient', + intent: 'mutate', + }); + if (resolution.metadata === null || resolution.aggregate === null) { + throw new Error('MANCODE_CONTEXT_WRITE_BLOCKED'); + } + if (resolution.aggregate.taskRevision === resumed.lastSeenRevision) { + packIsCurrent = true; + break; + } + } + if (!packIsCurrent) throw new Error('MANCODE_CONTEXT_CHANGED'); return printV3Result(options.json, { schemaVersion: 1, session: resumed, @@ -824,6 +930,10 @@ async function resolveContext( ), ) : undefined; + const adapterVersions = await inspectV3AdapterVersions( + project.projectRoot, + managedAdapterNames(project.project.manifest.managedAdapters), + ); return resolver.resolve({ session, taskRef: request.taskRef, @@ -834,7 +944,8 @@ async function resolveContext( expectedSchemaEpoch: project.project.manifest.epoch, readerVersion: VERSION, writerVersion: VERSION, - adapterVersions: project.project.manifest.managedAdapters, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions, }, codeHead: await readCheckoutCodeHead(project.projectRoot), ...(capabilities === undefined ? {} : { capabilities }), @@ -874,6 +985,15 @@ function parseSpikePlatform(value: string | undefined): SessionSpikePlatform { return value as SessionSpikePlatform; } +function parseSessionEvidenceMode( + value: string | undefined, +): SessionEvidenceMode { + if (value !== 'host' && value !== 'explicit') { + throw new Error('MANCODE_PLATFORM_SPIKE_SESSION_MODE_REQUIRED'); + } + return value; +} + function parseHostSessionSource(value: string | undefined): HostSessionSource { if ( value !== 'hook_stdin' && diff --git a/src/commands/init.ts b/src/commands/init.ts index e609552..9fe6785 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,8 +15,9 @@ import { getPlatformInstallers, } from '../installers/registry.js'; import { + V3_ADAPTER_VERSION, assertV3AdapterInstallable, - installV3Adapter, + inspectV3Adapter, } from '../installers/v3-adapter.js'; import { detectTeamStatus } from '../system/detect-team.js'; import { detectSystemDeps } from '../system/detect.js'; @@ -39,7 +40,7 @@ import { VERSION } from '../version.js'; import { initializeV3Project } from './v3-init.js'; /** - * 退出码契约 — 见 docs/08-cli-spec.md §2.5 + * 退出码契约见 docs/workflows.md。 */ export const EXIT_OK = 0; export const EXIT_ALREADY_INITIALIZED = 1; @@ -123,7 +124,7 @@ export function resolveInitAuthority(options: InitOptions): 'legacy' | 'v3' { /** * `mancode init` 命令(完整版)。 * - * 职责(docs/08-cli-spec.md §2.1-2.4): + * 职责见 docs/12-lifecycle.md: * 1. 检测可选系统依赖(git) * 2. 检测中立 project profile(类型、语言、框架与验证能力) * 3. 创建 8 个文件/目录(.mancode/ + .claude/) @@ -698,6 +699,7 @@ async function initializeV3( } const schemaPath = path.join(rootDir, '.mancode', 'schema.json'); let existingV3 = false; + let registeredAdapters = new Set(); if (await pathExists(schemaPath)) { try { const manifest = parseSchemaManifest( @@ -709,6 +711,9 @@ async function initializeV3( ); } existingV3 = true; + registeredAdapters = new Set( + Object.keys(manifest.managedAdapters) as PlatformName[], + ); if (options.platform === undefined) { console.log('ℹ️ mancode is already initialized.'); return EXIT_ALREADY_INITIALIZED; @@ -744,29 +749,39 @@ async function initializeV3( return EXIT_INIT_FAILED; } try { + if ( + existingV3 && + selectedPlatforms.some((platform) => !registeredAdapters.has(platform)) + ) { + throw new Error( + 'MANCODE_ADAPTER_REGISTRATION_REQUIRED: run `mancode adapter upgrade --platform --dry-run`, then confirm that operation with an active session.', + ); + } for (const platform of selectedPlatforms) { await assertV3AdapterInstallable(rootDir, platform); } if (existingV3) { - for (const platform of selectedPlatforms) { - await installV3Adapter(rootDir, platform); - } - console.log('ℹ️ mancode is already initialized.'); - if (selectedPlatforms.length > 0) { - console.log( - ` mancode bootstrap repaired: ${selectedPlatforms.join(', ')}`, + const statuses = await Promise.all( + selectedPlatforms.map((platform) => + inspectV3Adapter(rootDir, platform), + ), + ); + if (statuses.some((status) => !status.ready)) { + throw new Error( + 'MANCODE_ADAPTER_REPAIR_REQUIRED: run `mancode adapter status --json`, then preview and confirm the required platform upgrade with an active session.', ); } + console.log('ℹ️ mancode is already initialized.'); return EXIT_ALREADY_INITIALIZED; } const result = await initializeV3Project({ projectRoot: rootDir, + managedAdapters: Object.fromEntries( + selectedPlatforms.map((platform) => [platform, V3_ADAPTER_VERSION]), + ), teamPolicy: options.team === undefined ? 'auto' : options.team ? 'on' : 'off', }); - for (const platform of selectedPlatforms) { - await installV3Adapter(rootDir, platform); - } console.log('✓ Initialized mancode project.'); console.log(` workspace: ${result.runtime.workspaceId}`); console.log(` operation: ${result.journal.operationId}`); @@ -777,7 +792,7 @@ async function initializeV3( } if (selectedPlatforms.length === 0) { console.log( - ' No platform bootstrap selected. Run `mancode install `.', + ' No platform bootstrap selected. Run `mancode adapter upgrade --platform --dry-run`, then confirm that operation with an active session.', ); } else { console.log(` mancode bootstrap: ${selectedPlatforms.join(', ')}`); diff --git a/src/commands/install.ts b/src/commands/install.ts index 01641bb..7278433 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,22 +1,25 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { assertUlid } from '../context/ids.js'; import { V3ContextStore } from '../context/store.js'; +import { upgradeV3Adapters } from '../installers/adapter-upgrade.js'; import { checkPlatformStatus } from '../installers/platform-status.js'; import { formatPlatformName, getPlatformInstaller, getPlatformInstallers, } from '../installers/registry.js'; -import { installV3Adapter, stageV3Adapter } from '../installers/v3-adapter.js'; +import { stageV3Adapter } from '../installers/v3-adapter.js'; import { detectProjectProfile, primaryUiLibrary, } from '../system/project-profile.js'; import { DEFAULT_CONFIG } from '../templates/defaults.js'; +import { readV3CommandProject, resolveV3CommandSession } from './v3-support.js'; /** - * 退出码契约 — 见 docs/08-cli-spec.md §3 + * 退出码契约见 docs/workflows.md。 */ export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; @@ -35,12 +38,17 @@ export interface InstallOptions { minimal?: boolean; /** Render a V3 adapter candidate under staging without changing live files. */ shadow?: boolean; + /** Confirm a journaled V3 adapter install or repair. */ + confirm?: boolean; + operationId?: string; + session?: string; + client?: string; } /** * `mancode install ` 命令。 * - * 职责(docs/08-cli-spec.md §3 + docs/15-adapters.md §8): + * 职责见 docs/workflows.md 和 docs/platform-adapters.md: * 1. 检查项目已初始化(state.json 存在) * 2. 验证平台名 * 3. 调用对应适配器安装 @@ -205,11 +213,35 @@ async function installV3( 'ℹ️ mancode adapters are already bootstrap-only; --minimal has no additional effect.', ); } - const installed = await installV3Adapter(rootDir, installer.name); + let sessionId: string | undefined; + if (options.operationId !== undefined) { + assertUlid(options.operationId, 'adapter install operationId'); + } + if (options.confirm === true) { + const commandProject = await readV3CommandProject(rootDir); + sessionId = ( + await resolveV3CommandSession(commandProject, { + session: options.session, + client: options.client, + }) + ).sessionId; + } + const installed = await upgradeV3Adapters({ + projectRoot: rootDir, + platforms: [installer.name], + explicitConfirmation: options.confirm, + ...(options.operationId === undefined + ? {} + : { operationId: options.operationId }), + ...(sessionId === undefined ? {} : { sessionId }), + }); console.log( - `✓ ${formatPlatformName(platform)} mancode bootstrap installed.`, + installed.state === 'already_ready' + ? `ℹ️ ${formatPlatformName(platform)} mancode bootstrap is already ready.` + : `✓ ${formatPlatformName(platform)} mancode bootstrap installed.`, ); - console.log(` ${installed.target}`); + for (const target of installed.filePlans) + console.log(` ${target.target}`); return EXIT_OK; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/commands/project.ts b/src/commands/project.ts new file mode 100644 index 0000000..1ee0c5f --- /dev/null +++ b/src/commands/project.ts @@ -0,0 +1,72 @@ +import { assertUlid } from '../context/ids.js'; +import { + dryRunProjectPolicyUpgrade, + upgradeProjectPolicy, +} from '../context/project-policy-upgrade.js'; +import { + EXIT_V3_INVALID_ARGUMENT, + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; + +export interface ProjectUpgradeOptions { + policy?: string; + dryRun?: boolean; + session?: string; + client?: string; + operationId?: string; + json?: boolean; +} + +export async function projectUpgrade( + rootDir: string, + options: ProjectUpgradeOptions, +): Promise { + if (options.policy !== '2') { + return printV3Error( + options.json, + 'MANCODE_POLICY_VERSION_UNSUPPORTED', + 'project upgrade currently supports only --policy 2.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + if (options.operationId !== undefined) { + assertUlid(options.operationId, 'project upgrade operationId'); + } + if (options.dryRun === true) { + return printV3Result( + options.json, + await dryRunProjectPolicyUpgrade({ + projectRoot: rootDir, + policyVersion: 2, + ...(options.operationId === undefined + ? {} + : { operationId: options.operationId }), + }), + ); + } + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + return printV3Result( + options.json, + await upgradeProjectPolicy({ + projectRoot: project.projectRoot, + policyVersion: 2, + sessionId: session.sessionId, + ...(options.operationId === undefined + ? {} + : { operationId: options.operationId }), + }), + ); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_PROJECT_UPGRADE_FAILED'), + error instanceof Error ? error.message : 'Project upgrade failed.', + ); + } +} diff --git a/src/commands/refresh-style.ts b/src/commands/refresh-style.ts index 1c3636d..d2b525a 100644 --- a/src/commands/refresh-style.ts +++ b/src/commands/refresh-style.ts @@ -13,7 +13,7 @@ import { import { scanAesthetics } from '../system/scan-aesthetics.js'; /** - * 退出码契约 — 见 docs/08-cli-spec.md §7 + * 退出码契约见 docs/workflows.md。 */ export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; @@ -22,13 +22,13 @@ export const EXIT_V3_REFRESH_FAILED = 2; /** * `mancode refresh-style` 命令。 * - * 职责(docs/08-cli-spec.md §7 + docs/06-aesthetics.md §9.2): + * 职责见 docs/project-intelligence.md: * 1. 检查项目已初始化 * 2. 重新扫描项目审美 token * 3. 覆盖 .mancode/aesthetics/style-tokens.json * 4. 输出扫描结果摘要 * - * 触发场景(docs/06-aesthetics.md §9.2): + * 触发场景见 docs/project-intelligence.md: * - 用户改了 tailwind.config.js * - 用户改了 package.json dependencies * - 用户手动执行 diff --git a/src/commands/requirements-input.ts b/src/commands/requirements-input.ts index 0552485..18b130c 100644 --- a/src/commands/requirements-input.ts +++ b/src/commands/requirements-input.ts @@ -19,6 +19,7 @@ export function normalizeRequirementsInput( value: unknown, requestedTaskRef: TaskRef, now: Date = new Date(), + options: { allowIncomplete?: boolean } = {}, ): RequirementsLedgerV1 { if (isRecord(value) && 'schemaVersion' in value) { return parseRequirementsLedger(value); @@ -28,7 +29,7 @@ export function normalizeRequirementsInput( if (serialized === undefined) { throw new Error('requirements input must be valid JSON'); } - const semantic = parseSemanticRequirementsLedger(serialized); + const semantic = parseSemanticRequirementsLedger(serialized, options); return buildCanonicalRequirements(semantic, requestedTaskRef, now); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 499a0bb..60939a2 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -2,9 +2,13 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -import { evaluateCompatibilityGate } from '../context/compatibility.js'; +import { + CURRENT_WRITER_CAPABILITIES, + evaluateCompatibilityGate, +} from '../context/compatibility.js'; import { assertUlid } from '../context/ids.js'; import { scanLegacyAuthority } from '../context/layout.js'; +import { managedAdapterNames } from '../context/manifest.js'; import { V3ContextStore } from '../context/store.js'; import { type PlatformStatus, @@ -18,7 +22,9 @@ import { import { type V3PlatformAdapterStatus, inspectV3Adapter, + inspectV3AdapterVersions, } from '../installers/v3-adapter.js'; +import { listUnfinishedOperationRecoveries } from '../runtime/operation-recovery-executor.js'; import { listPlatformSessionSpikes } from '../runtime/platform-spike-store.js'; import { type SessionSpikePlatform, @@ -51,7 +57,7 @@ import { VERSION } from '../version.js'; const HOOK_ESTIMATE_TIMEOUT_MS = 2000; /** - * 退出码契约 — 见 docs/08-cli-spec.md §4 + * 退出码契约见 docs/workflows.md。 */ export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; @@ -141,7 +147,7 @@ export interface V3StatusResult { state: string; epoch: string; activatedAt: string | null; - managedAdapters: Record; + managedAdapters: Partial>; }; compatibility: { readAllowed: boolean; @@ -201,6 +207,11 @@ export interface V3StatusResult { explicitRequiredPlatforms: SessionSpikePlatform[]; }; adapters: Record; + unfinishedOperations: Array<{ + operationId: string; + type: string; + state: string; + }>; legacyAuthorityPresent: boolean; } @@ -233,7 +244,7 @@ interface StatusConfig { /** * `mancode status` 命令。 * - * 职责(docs/08-cli-spec.md §4 + progress.md Step 3): + * 职责见 docs/workflows.md: * 1. 读取 .mancode/state.json(未初始化则报错退出) * 2. 显示项目状态(mode/platform/techStack/uiLibrary) * 3. 显示初始化时间 @@ -333,8 +344,29 @@ async function statusV3( ): Promise { try { const store = new V3ContextStore(rootDir); + const snapshot = await store.readProjectSnapshot(); + let runtime: V3StatusResult['runtime']; + try { + const context = await readProjectRuntimeContext(rootDir); + runtime = { + binding: 'ready', + workspaceId: context.workspaceId, + checkoutId: context.checkoutId, + repositoryBindingId: context.repositoryBindingId, + gitCommonDir: context.gitCommonDir, + error: null, + }; + } catch (error) { + runtime = { + binding: 'registration_required', + workspaceId: snapshot.config.workspaceId, + checkoutId: null, + repositoryBindingId: null, + gitCommonDir: null, + error: error instanceof Error ? error.message : String(error), + }; + } const [ - snapshot, project, legacy, actor, @@ -342,8 +374,9 @@ async function statusV3( sessionSpikes, assessmentSignals, currentSession, + adapterVersions, + unfinishedOperations, ] = await Promise.all([ - store.readProjectSnapshot(), getProjectName(rootDir), scanLegacyAuthority(rootDir), readLocalActor(rootDir).catch(() => null), @@ -358,13 +391,21 @@ async function statusV3( listPlatformSessionSpikes(rootDir), detectTeamAssessmentSignals(rootDir), readV3StatusSession(rootDir), + inspectV3AdapterVersions( + rootDir, + managedAdapterNames(snapshot.manifest.managedAdapters), + ), + runtime.binding === 'ready' + ? listUnfinishedOperationRecoveries(rootDir) + : Promise.resolve([]), ]); const compatibility = evaluateCompatibilityGate({ manifest: snapshot.manifest, expectedSchemaEpoch: snapshot.manifest.epoch, readerVersion: VERSION, writerVersion: VERSION, - adapterVersions: snapshot.manifest.managedAdapters, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions, currentLegacyBaseline: legacy.baseline, legacyAuthorityPresent: legacy.authorityPresent, operation: 'read', @@ -392,27 +433,6 @@ async function statusV3( revision: task.metadata.revision, })) .catch(() => null); - let runtime: V3StatusResult['runtime']; - try { - const context = await readProjectRuntimeContext(rootDir); - runtime = { - binding: 'ready', - workspaceId: context.workspaceId, - checkoutId: context.checkoutId, - repositoryBindingId: context.repositoryBindingId, - gitCommonDir: context.gitCommonDir, - error: null, - }; - } catch (error) { - runtime = { - binding: 'registration_required', - workspaceId: snapshot.config.workspaceId, - checkoutId: null, - repositoryBindingId: null, - gitCommonDir: null, - error: error instanceof Error ? error.message : String(error), - }; - } const result: V3StatusResult = { schemaVersion: 1, authority: 'v3', @@ -465,6 +485,11 @@ async function statusV3( currentTask, sessionEvidence: platformSpikeFreezeStatus(sessionSpikes), adapters, + unfinishedOperations: unfinishedOperations.map((operation) => ({ + operationId: operation.journal.operationId, + type: operation.journal.type, + state: operation.journal.state, + })), legacyAuthorityPresent: legacy.authorityPresent, }; const output = options.brief ? toContinuityStatus(result) : result; @@ -488,12 +513,16 @@ function toContinuityStatus(result: V3StatusResult): ContinuityStatusResult { const blockers = [ ...result.compatibility.failures, ...(result.runtime.error === null ? [] : [result.runtime.error]), + ...(result.unfinishedOperations.length === 0 + ? [] + : ['MANCODE_OPERATION_REPAIR_REQUIRED']), ]; const ready = result.activation.state === 'v3_active' && result.compatibility.readAllowed && result.compatibility.writeAllowed && - result.runtime.binding === 'ready'; + result.runtime.binding === 'ready' && + result.unfinishedOperations.length === 0; return { schemaVersion: 1, runtime: 'mancode-continuity', @@ -805,7 +834,7 @@ function runHookEstimate(rootDir: string, hookPath: string): Promise { } /** - * 文本格式输出(默认),字段命名对齐 docs/08-cli-spec.md §4.3。 + * 文本格式输出(默认),字段命名对齐 docs/workflows.md。 */ function printText(r: StatusResult): void { const publicMode = r.mode === 'mamba' ? 'manba' : r.mode; @@ -931,6 +960,11 @@ function printV3Text(result: V3StatusResult): void { if (result.compatibility.failures.length > 0) { console.log(`Compatibility: ${result.compatibility.failures.join(', ')}`); } + if (result.unfinishedOperations.length > 0) { + console.log( + `Recovery: required (${result.unfinishedOperations.map((operation) => operation.operationId).join(', ')})`, + ); + } if (result.legacyAuthorityPresent) { console.log( 'Legacy: legacy authority detected; mancode writes may be blocked.', diff --git a/src/commands/v3-init.ts b/src/commands/v3-init.ts index c9be17a..de18fc2 100644 --- a/src/commands/v3-init.ts +++ b/src/commands/v3-init.ts @@ -3,9 +3,8 @@ import { initializeGreenfield, } from '../context/greenfield-init.js'; import { type Ulid, createUlid } from '../context/ids.js'; -import type { ManagedAdapter } from '../context/manifest.js'; +import type { ManagedAdapterInventory } from '../context/manifest.js'; import { createProjectFacts } from '../context/project-facts.js'; -import { V3_ADAPTER_VERSION } from '../installers/v3-adapter.js'; import { type ProjectRuntimeContext, ensureProjectRuntimeContext, @@ -23,7 +22,7 @@ export interface InitializeV3ProjectInput { operationId?: Ulid; workspaceId?: Ulid; schemaEpoch?: Ulid; - managedAdapters?: Record; + managedAdapters?: ManagedAdapterInventory; /** Explicit init preference; omitted keeps V3's evidence-based auto mode. */ teamPolicy?: TeamRecommendationPolicy; now?: Date; @@ -84,7 +83,7 @@ export async function initializeV3Project( schemaEpoch, minReaderVersion: VERSION, minWriterVersion: VERSION, - managedAdapters: input.managedAdapters ?? defaultManagedAdapters(), + managedAdapters: input.managedAdapters ?? {}, projectConfig, teamPolicy, projectFacts, @@ -102,13 +101,3 @@ export async function initializeV3Project( } return { journal, runtime }; } - -function defaultManagedAdapters(): Record { - return { - 'claude-code': V3_ADAPTER_VERSION, - codex: V3_ADAPTER_VERSION, - cursor: V3_ADAPTER_VERSION, - copilot: V3_ADAPTER_VERSION, - zcode: V3_ADAPTER_VERSION, - }; -} diff --git a/src/commands/version.ts b/src/commands/version.ts index 9873949..66856d1 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -4,7 +4,7 @@ import { VERSION } from '../version.js'; /** * `mancode version` 命令。 * - * 输出格式(docs/08-cli-spec.md §5.3): + * 输出格式见 docs/workflows.md: * ``` * mancode/0.1.0 * node/20.10.0 diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index cc8c133..092156e 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -8,7 +8,11 @@ import { previewV3TaskPromotion, promoteV3Task, } from '../context/publish-promote.js'; -import { finalizeV3Requirements } from '../context/requirements-finalize.js'; +import { reframeV3Workflow } from '../context/reframe.js'; +import { + finalizeV3Requirements, + saveV3RequirementsDraft, +} from '../context/requirements-finalize.js'; import { applyV3ReviewLedger } from '../context/review-remediation.js'; import { changeV3WorkflowScope } from '../context/scope-change.js'; import { @@ -16,11 +20,26 @@ import { startV3SoloHandoff, } from '../context/solo-handoff.js'; import { completeV3Task } from '../context/task-complete.js'; -import { formatTaskRef, parseTaskRef } from '../context/task-ref.js'; +import { + formatTaskRef, + parseTaskRef, + sameTaskRef, +} from '../context/task-ref.js'; import { recordV3Verification } from '../context/verification-record.js'; import { createV3Workflow } from '../context/workflow-create.js'; import type { WorkflowMetadataV3 } from '../context/workflow-metadata.js'; import { updateV3Workflow } from '../context/workflow-update.js'; +import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; +import { + type TaskArchiveRecoveryAction, + taskArchiveManifest, +} from '../runtime/operation-recovery-payload.js'; +import { readOperationRecoveryPayload } from '../runtime/operation-recovery-store.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { + readTaskArchiveDigestAtRoot, + readTaskCheckpointAtRoot, +} from '../runtime/task-operation.js'; import { detectTeamAssessmentSignals } from '../system/detect-team.js'; import { parseRequirementsLedger, @@ -129,6 +148,7 @@ export interface WorkflowOptions { exitCode?: string; evidenceFile?: string; reason?: string; + checkpointId?: string; } interface WorkflowView extends WorkflowMeta { @@ -250,6 +270,12 @@ async function workflowV3( if (subcommand === 'scope') { return workflowScopeChangeV3(rootDir, args, options); } + if (subcommand === 'reframe') { + return workflowReframeV3(rootDir, args, options); + } + if (subcommand === 'archive' || subcommand === 'checkpoint') { + return workflowArtifactShowV3(rootDir, subcommand, args, options); + } if (subcommand === 'child') { return workflowChildResultMergeV3(rootDir, args, options); } @@ -380,6 +406,80 @@ async function workflowShowV3( } } +async function workflowArtifactShowV3( + rootDir: string, + kind: 'archive' | 'checkpoint', + args: string[], + options: WorkflowOptions, +): Promise { + const [task, action, artifactId] = args; + if (!task || action !== 'show' || !artifactId || args.length !== 3) { + return printV3Error( + options.json, + `MANCODE_${kind.toUpperCase()}_SHOW_ARGUMENT_INVALID`, + `Use: workflow ${kind} show <${kind}Id> [--json].`, + EXIT_INVALID_ARG, + ); + } + try { + assertUlid(artifactId, `${kind}Id`); + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(task); + const snapshot = await project.store.readTaskSnapshot(taskRef); + if (kind === 'checkpoint') { + const checkpoint = await readTaskCheckpointAtRoot( + snapshot.location.taskRoot, + artifactId, + ); + if (checkpoint === null || !sameTaskRef(checkpoint.taskRef, taskRef)) { + throw new Error('MANCODE_CHECKPOINT_NOT_FOUND'); + } + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + checkpoint, + }); + } + + const runtime = await readProjectRuntimeContext(project.projectRoot); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + const payload = await readOperationRecoveryPayload(homeStore, artifactId); + const archive = payload?.actions.find( + (candidate): candidate is TaskArchiveRecoveryAction => + candidate.kind === 'task_archive' && + candidate.archiveId === artifactId && + sameTaskRef(candidate.taskRef, taskRef), + ); + if ( + archive === undefined || + (await readTaskArchiveDigestAtRoot( + snapshot.location.taskRoot, + archive, + )) === null + ) { + throw new Error('MANCODE_REFRAME_ARCHIVE_NOT_FOUND'); + } + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + archive: taskArchiveManifest(archive), + requirements: JSON.parse(archive.requirementsContent), + plan: archive.planContent, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, `MANCODE_V3_${kind.toUpperCase()}_SHOW_FAILED`), + error instanceof Error + ? error.message + : `Unable to show the mancode ${kind}.`, + ); + } +} + function workflowCleanV3(options: WorkflowOptions): number { return printV3Error( options.json, @@ -846,6 +946,75 @@ async function workflowScopeChangeV3( } } +async function workflowReframeV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + if (!task || args.length !== 1) { + return printV3Error( + options.json, + 'MANCODE_REFRAME_ARGUMENT_INVALID', + 'Use: workflow reframe --expected-revision --checkpoint-id .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Workflow reframe requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + if (!options.checkpointId) { + return printV3Error( + options.json, + 'MANCODE_REFRAME_CHECKPOINT_REQUIRED', + 'Workflow reframe requires --checkpoint-id .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + assertUlid(options.checkpointId, 'checkpointId'); + const result = await reframeV3Workflow({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + checkpointId: options.checkpointId, + summary: options.summary, + nextAction: options.nextAction, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + requirements: result.requirements, + review: result.review, + verification: result.verification, + checkpoint: result.checkpoint, + releasedClaims: result.releasedClaims, + archive: result.archive, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_REFRAME_FAILED'), + error instanceof Error + ? error.message + : 'Unable to reframe the mancode workflow.', + ); + } +} + async function workflowCompleteV3( rootDir: string, args: string[], @@ -1135,11 +1304,11 @@ async function workflowRequirementsV3( ): Promise { const task = args[0]; const action = args[1]; - if (!task || action !== 'finalize' || !options.file) { + if (!task || (action !== 'draft' && action !== 'finalize') || !options.file) { return printV3Error( options.json, 'MANCODE_REQUIREMENTS_ARGUMENT_INVALID', - 'Use: workflow requirements finalize --expected-revision --file .', + 'Use: workflow requirements --expected-revision --file .', EXIT_INVALID_ARG, ); } @@ -1148,7 +1317,7 @@ async function workflowRequirementsV3( return printV3Error( options.json, 'MANCODE_EXPECTED_REVISION_REQUIRED', - 'Requirements finalization requires --expected-revision .', + 'Requirements draft/finalization requires --expected-revision .', EXIT_INVALID_ARG, ); } @@ -1160,8 +1329,15 @@ async function workflowRequirementsV3( project.projectRoot, options.file, ); - const requirements = normalizeRequirementsInput(requirementsInput, taskRef); - const result = await finalizeV3Requirements({ + const requirements = normalizeRequirementsInput( + requirementsInput, + taskRef, + new Date(), + { allowIncomplete: action === 'draft' }, + ); + const writeRequirements = + action === 'draft' ? saveV3RequirementsDraft : finalizeV3Requirements; + const result = await writeRequirements({ projectRoot: project.projectRoot, taskRef, sessionId: session.sessionId, @@ -1185,7 +1361,7 @@ async function workflowRequirementsV3( v3ErrorCode(error, 'MANCODE_V3_REQUIREMENTS_FINALIZE_FAILED'), error instanceof Error ? error.message - : 'Unable to finalize mancode requirements.', + : `Unable to ${action === 'draft' ? 'save the mancode requirements draft' : 'finalize mancode requirements'}.`, ); } } diff --git a/src/context/compatibility.ts b/src/context/compatibility.ts index 4140b4d..9c95098 100644 --- a/src/context/compatibility.ts +++ b/src/context/compatibility.ts @@ -1,20 +1,39 @@ import type { LegacyBaseline, ManagedAdapter, - SchemaManifestV1, + SchemaManifest, } from './manifest.js'; +import { managedAdapterInventoriesMatch } from './manifest.js'; export type CompatibilityOperation = | 'read' | 'v3_business_write' + | 'reframe' | 'migration_stage' | 'activation_repair' - | 'greenfield_initialize'; + | 'greenfield_initialize' + | 'adapter_upgrade' + | 'project_policy_upgrade'; + +export type WriterCapability = + | 'planning-policy:1' + | 'planning-policy:2' + | 'adapter-digest:1' + | 'reframe-local:1'; + +export const CURRENT_WRITER_CAPABILITIES: readonly WriterCapability[] = [ + 'planning-policy:1', + 'planning-policy:2', + 'adapter-digest:1', + 'reframe-local:1', +]; export type CompatibilityFailureCode = | 'MANCODE_SCHEMA_EPOCH_MISMATCH' | 'MANCODE_READER_VERSION_TOO_OLD' | 'MANCODE_WRITER_VERSION_TOO_OLD' + | 'MANCODE_WRITER_CAPABILITY_MISSING' + | 'MANCODE_ADAPTER_CONTENT_STALE' | 'MANCODE_ADAPTER_VERSION_MISMATCH' | 'MANCODE_LEGACY_BASELINE_CHANGED' | 'MANCODE_LEGACY_AUTHORITY_PRESENT' @@ -23,16 +42,27 @@ export type CompatibilityFailureCode = | 'MANCODE_REPAIR_REQUIRED'; export interface CompatibilityGateInput { - manifest: SchemaManifestV1; + manifest: SchemaManifest; expectedSchemaEpoch: string; readerVersion: string; writerVersion: string; + writerCapabilities: readonly WriterCapability[]; adapterVersions: Partial>; currentLegacyBaseline: LegacyBaseline | null; legacyAuthorityPresent: boolean; operation: CompatibilityOperation; } +export class CompatibilityGateError extends Error { + constructor( + readonly code: CompatibilityFailureCode, + readonly details: Record, + ) { + super(`${code}: ${JSON.stringify(details)}`); + this.name = 'CompatibilityGateError'; + } +} + export interface CompatibilityGateResult { readAllowed: boolean; writeAllowed: boolean; @@ -60,8 +90,12 @@ export function evaluateCompatibilityGate( ) { failures.push('MANCODE_WRITER_VERSION_TOO_OLD'); } - if (!adaptersMatch(input.manifest, input.adapterVersions)) { - failures.push('MANCODE_ADAPTER_VERSION_MISMATCH'); + if (missingWriterCapabilities(input).length > 0) { + failures.push('MANCODE_WRITER_CAPABILITY_MISSING'); + } + const adapterFailure = adapterFailureFor(input); + if (adapterFailure !== null) { + failures.push(adapterFailure); } const baselineFailure = baselineFailureFor(input); if (baselineFailure !== null) failures.push(baselineFailure); @@ -79,7 +113,12 @@ export function assertCompatibilityGate(input: CompatibilityGateInput): void { const allowed = input.operation === 'read' ? result.readAllowed : result.writeAllowed; if (!allowed) { - throw new Error(result.failures[0] ?? 'MANCODE_COMPATIBILITY_BLOCKED'); + const code = result.failures[0]; + if (code === undefined) throw new Error('MANCODE_COMPATIBILITY_BLOCKED'); + throw new CompatibilityGateError( + code, + compatibilityFailureDetails(input, code), + ); } } @@ -157,15 +196,105 @@ function stateFailureFor( } } -function adaptersMatch( - manifest: SchemaManifestV1, - actual: Partial>, -): boolean { - return (Object.keys(manifest.managedAdapters) as ManagedAdapter[]).every( - (adapter) => actual[adapter] === manifest.managedAdapters[adapter], +function adapterFailureFor( + input: CompatibilityGateInput, +): CompatibilityFailureCode | null { + if (input.operation === 'adapter_upgrade') return null; + const entries = Object.entries(input.adapterVersions) as Array< + [ManagedAdapter, string] + >; + if ( + entries.some( + ([, version]) => + version === 'missing' || + version === 'stale' || + version === 'unreadable', + ) + ) { + return 'MANCODE_ADAPTER_CONTENT_STALE'; + } + return managedAdapterInventoriesMatch( + input.manifest.managedAdapters, + input.adapterVersions, + ) + ? null + : 'MANCODE_ADAPTER_VERSION_MISMATCH'; +} + +function requiredWriterCapabilities( + input: CompatibilityGateInput, +): readonly WriterCapability[] { + if (input.operation === 'read') return []; + if (input.operation === 'adapter_upgrade') { + return ['planning-policy:1', 'adapter-digest:1']; + } + if (input.operation === 'reframe') { + return [ + 'planning-policy:1', + ...(input.manifest.manifestVersion === 2 + ? (['planning-policy:2'] as const) + : []), + 'adapter-digest:1', + 'reframe-local:1', + ]; + } + if ( + input.operation === 'project_policy_upgrade' || + input.manifest.manifestVersion === 2 + ) { + return ['planning-policy:1', 'planning-policy:2', 'adapter-digest:1']; + } + return ['planning-policy:1']; +} + +function missingWriterCapabilities( + input: CompatibilityGateInput, +): WriterCapability[] { + const declared = new Set(input.writerCapabilities); + return requiredWriterCapabilities(input).filter( + (capability) => !declared.has(capability), ); } +function compatibilityFailureDetails( + input: CompatibilityGateInput, + code: CompatibilityFailureCode, +): Record { + switch (code) { + case 'MANCODE_WRITER_VERSION_TOO_OLD': + return { + observedVersion: input.writerVersion, + requiredWriter: input.manifest.minWriterVersion, + }; + case 'MANCODE_WRITER_CAPABILITY_MISSING': + return { + missingCapabilities: missingWriterCapabilities(input), + declaredCapabilities: [...input.writerCapabilities], + requiredWriter: input.manifest.minWriterVersion, + }; + case 'MANCODE_ADAPTER_CONTENT_STALE': { + const adapters = Object.fromEntries( + Object.entries(input.adapterVersions).filter( + ([, version]) => + version === 'missing' || + version === 'stale' || + version === 'unreadable', + ), + ); + return { + adapters, + repair: Object.keys(adapters).map((platform) => ({ + platform, + previewCommand: `mancode adapter upgrade --platform ${platform} --dry-run`, + confirmCommand: `mancode adapter upgrade --platform ${platform} --confirm --operation-id --session `, + })), + }; + } + default: + return {}; + } +} + function sameBaseline(left: LegacyBaseline, right: LegacyBaseline): boolean { return ( left.stateDigest === right.stateDigest && diff --git a/src/context/greenfield-init.ts b/src/context/greenfield-init.ts index f8912d4..d4ddd44 100644 --- a/src/context/greenfield-init.ts +++ b/src/context/greenfield-init.ts @@ -7,6 +7,14 @@ import { writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { + type V3AdapterFilePlan, + type V3AdapterFileTarget, + V3_ADAPTER_FILE_TARGETS, + applyV3AdapterFilePlan, + inspectV3AdapterVersions, + planV3AdapterUpgradeFiles, +} from '../installers/v3-adapter.js'; import { throwIfOperationCrashInjected } from '../runtime/operation-crash-injection.js'; import { type ProjectConfigV1, @@ -16,13 +24,17 @@ import { parseTeamPolicy, } from '../team/policy.js'; import { digestCanonicalJson } from './canonical.js'; +import { compareSemver } from './compatibility.js'; import { type Ulid, assertUlid } from './ids.js'; import { assertGreenfieldInitializationPreflight } from './layout.js'; import { - type ManagedAdapter, - type SchemaManifestV1, + type ManagedAdapterInventory, + type SchemaManifest, + type SchemaManifestV2, assertSchemaManifestTransition, + managedAdapterNames, parseSchemaManifest, + parseSchemaManifestV2, } from './manifest.js'; import { type ProjectFactsV1, @@ -49,6 +61,7 @@ export interface GreenfieldInitializationJournalV1 { configDigest: string; policyDigest: string; projectFactsDigest: string; + adapterPlans: V3AdapterFilePlan[]; bindingRegistered: boolean; createdAt: string; updatedAt: string; @@ -61,7 +74,7 @@ export interface GreenfieldInitializationInput { schemaEpoch: Ulid; minReaderVersion: string; minWriterVersion: string; - managedAdapters: Record; + managedAdapters: ManagedAdapterInventory; projectConfig: ProjectConfigV1; teamPolicy: TeamPolicyV1; /** Optional detected facts; omitted inputs receive a safe unknown record. */ @@ -122,6 +135,14 @@ export async function stageGreenfieldInitialization( const now = (normalized.now ?? new Date()).toISOString(); const manifest = initializationManifest(normalized, now); + const requiredAdapters = managedAdapterNames(normalized.managedAdapters); + const adapterPlans = + requiredAdapters.length === 0 + ? [] + : await planV3AdapterUpgradeFiles( + normalized.projectRoot, + requiredAdapters, + ); const config = initializationConfig( normalized.projectConfig, normalized, @@ -140,6 +161,7 @@ export async function stageGreenfieldInitialization( configDigest: digestCanonicalJson(config), policyDigest: digestCanonicalJson(policy), projectFactsDigest: digestCanonicalJson(projectFacts), + adapterPlans, bindingRegistered: false, createdAt: now, updatedAt: now, @@ -180,6 +202,12 @@ export async function publishGreenfieldInitialization( throw new Error('MANCODE_GREENFIELD_STAGING_STATE_INVALID'); } await assertStageMatchesJournal(stagingRoot, journal); + const stagedManifest = await readManifest(stagingRoot); + await assertAdapterPlansAtBeforeState( + root, + managedAdapterNames(stagedManifest.managedAdapters), + journal.adapterPlans, + ); if ((await lstatOrNull(targetRoot)) !== null) { throw new Error('MANCODE_V3_TARGET_EXISTS'); } @@ -320,6 +348,7 @@ export function parseGreenfieldInitializationJournal( 'configDigest', 'policyDigest', 'projectFactsDigest', + 'adapterPlans', 'bindingRegistered', 'createdAt', 'updatedAt', @@ -374,6 +403,7 @@ export function parseGreenfieldInitializationJournal( value.projectFactsDigest, 'projectFactsDigest', ), + adapterPlans: parseAdapterPlans(value.adapterPlans), bindingRegistered: value.bindingRegistered, createdAt: parseTimestamp(value.createdAt, 'createdAt'), updatedAt: parseTimestamp(value.updatedAt, 'updatedAt'), @@ -407,7 +437,41 @@ async function finishPublishedInitialization( 'register-workspace-binding', ); const manifest = await readManifest(targetRoot); - const activeManifest: SchemaManifestV1 = { + try { + for (const plan of published.adapterPlans) { + await applyV3AdapterFilePlan(input.projectRoot, plan); + throwIfOperationCrashInjected( + 'greenfield_initialize', + `publish-managed-adapters:${plan.target}`, + ); + } + const requiredAdapters = managedAdapterNames(manifest.managedAdapters); + const actualAdapters = await inspectV3AdapterVersions( + input.projectRoot, + requiredAdapters, + ); + if ( + requiredAdapters.some( + (adapter) => + actualAdapters[adapter] !== manifest.managedAdapters[adapter], + ) + ) { + throw new Error('MANCODE_GREENFIELD_ADAPTER_VERIFY_FAILED'); + } + } catch (error) { + await writeJournalAt(targetRoot, { + ...published, + state: 'repair_required', + bindingRegistered: true, + updatedAt: (input.now ?? new Date()).toISOString(), + }); + throw error; + } + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'publish-managed-adapters', + ); + const activeManifest: SchemaManifest = { ...manifest, activationState: 'v3_active', activatedAt: (input.now ?? new Date()).toISOString(), @@ -430,7 +494,7 @@ async function finishPublishedInitialization( async function writeGreenfieldLayout( stagingRoot: string, - manifest: SchemaManifestV1, + manifest: SchemaManifest, config: ProjectConfigV1, policy: TeamPolicyV1, projectFacts: ProjectFactsV1, @@ -502,7 +566,7 @@ async function readGreenfieldJournal( } } -async function readManifest(root: string): Promise { +async function readManifest(root: string): Promise { try { return parseSchemaManifest( JSON.parse(await readFile(path.join(root, 'schema.json'), 'utf8')), @@ -679,21 +743,85 @@ function normalizeInput( function initializationManifest( input: GreenfieldInitializationInput, _now: string, -): SchemaManifestV1 { - return parseSchemaManifest({ - manifestVersion: 1, +): SchemaManifestV2 { + return parseSchemaManifestV2({ + manifestVersion: 2, layoutVersion: 3, epoch: input.schemaEpoch, activationState: 'initializing', - minReaderVersion: input.minReaderVersion, - minWriterVersion: input.minWriterVersion, + minReaderVersion: minimumVersion(input.minReaderVersion, '0.4.0'), + minWriterVersion: minimumVersion(input.minWriterVersion, '0.4.0'), activatedAt: null, legacyBaseline: null, managedAdapters: input.managedAdapters, lastOperationId: input.operationId, + workflowPolicyDefaults: { planning: 2 }, }); } +function minimumVersion(observed: string, required: string): string { + return compareSemver(observed, required) >= 0 ? observed : required; +} + +function parseAdapterPlans(value: unknown): V3AdapterFilePlan[] { + if (!Array.isArray(value)) { + throw new Error( + 'greenfield initialization journal adapterPlans is invalid', + ); + } + const seen = new Set(); + return value.map((candidate) => { + assertRecord(candidate, 'greenfield initialization adapter plan'); + assertKnownKeys( + candidate, + ['target', 'beforeContent', 'targetContent'], + 'greenfield initialization adapter plan', + ); + if ( + typeof candidate.target !== 'string' || + !V3_ADAPTER_FILE_TARGETS.includes( + candidate.target as V3AdapterFileTarget, + ) || + seen.has(candidate.target as V3AdapterFileTarget) || + (candidate.beforeContent !== null && + typeof candidate.beforeContent !== 'string') || + typeof candidate.targetContent !== 'string' || + !candidate.targetContent.trim() + ) { + throw new Error( + 'greenfield initialization journal adapterPlans is invalid', + ); + } + const target = candidate.target as V3AdapterFileTarget; + seen.add(target); + return { + target, + beforeContent: candidate.beforeContent as string | null, + targetContent: candidate.targetContent, + }; + }); +} + +async function assertAdapterPlansAtBeforeState( + projectRoot: string, + requiredAdapters: ReturnType, + plans: readonly V3AdapterFilePlan[], +): Promise { + if (requiredAdapters.length === 0) { + if (plans.length !== 0) { + throw new Error('MANCODE_GREENFIELD_ADAPTER_TARGET_CONFLICT'); + } + return; + } + const currentPlans = await planV3AdapterUpgradeFiles( + projectRoot, + requiredAdapters, + ); + if (JSON.stringify(currentPlans) !== JSON.stringify(plans)) { + throw new Error('MANCODE_GREENFIELD_ADAPTER_TARGET_CONFLICT'); + } +} + function initializationConfig( config: ProjectConfigV1, input: GreenfieldInitializationInput, diff --git a/src/context/manifest.ts b/src/context/manifest.ts index dbbf6df..09ffd84 100644 --- a/src/context/manifest.ts +++ b/src/context/manifest.ts @@ -15,6 +15,8 @@ export type ManagedAdapter = | 'copilot' | 'zcode'; +export type ManagedAdapterInventory = Partial>; + export interface LegacyBaseline { stateDigest: string; workflowIndexDigest: string; @@ -29,10 +31,28 @@ export interface SchemaManifestV1 { minWriterVersion: string; activatedAt: string | null; legacyBaseline: LegacyBaseline | null; - managedAdapters: Record; + managedAdapters: ManagedAdapterInventory; + lastOperationId: Ulid | null; +} + +export interface SchemaManifestV2 { + manifestVersion: 2; + layoutVersion: 3; + epoch: Ulid; + activationState: ActivationState; + minReaderVersion: string; + minWriterVersion: string; + activatedAt: string | null; + legacyBaseline: LegacyBaseline | null; + managedAdapters: ManagedAdapterInventory; lastOperationId: Ulid | null; + workflowPolicyDefaults: { + planning: 2; + }; } +export type SchemaManifest = SchemaManifestV1 | SchemaManifestV2; + const ACTIVATION_STATES = new Set([ 'initializing', 'dual_read', @@ -50,8 +70,14 @@ const MANAGED_ADAPTERS: ManagedAdapter[] = [ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; -export function parseSchemaManifest(value: unknown): SchemaManifestV1 { +export function parseSchemaManifest(value: unknown): SchemaManifest { assertRecord(value, 'schema manifest'); + if (value.manifestVersion !== 1 && value.manifestVersion !== 2) { + throw new Error( + `MANCODE_MANIFEST_VERSION_UNSUPPORTED: observed=${String(value.manifestVersion)} supported=1,2 requiredWriter=0.4.0`, + ); + } + const manifestVersion = value.manifestVersion; assertKnownKeys( value, [ @@ -65,13 +91,12 @@ export function parseSchemaManifest(value: unknown): SchemaManifestV1 { 'legacyBaseline', 'managedAdapters', 'lastOperationId', + ...(manifestVersion === 2 ? ['workflowPolicyDefaults'] : []), ], 'schema manifest', ); - if (value.manifestVersion !== 1 || value.layoutVersion !== 3) { - throw new Error( - 'schema manifest must use manifestVersion 1 and layoutVersion 3', - ); + if (value.layoutVersion !== 3) { + throw new Error('schema manifest must use layoutVersion 3'); } assertUlid(value.epoch, 'schema manifest epoch'); if ( @@ -80,9 +105,8 @@ export function parseSchemaManifest(value: unknown): SchemaManifestV1 { ) { throw new Error('schema manifest activationState is invalid'); } - const manifest: SchemaManifestV1 = { - manifestVersion: 1, - layoutVersion: 3, + const common = { + layoutVersion: 3 as const, epoch: value.epoch, activationState: value.activationState as ActivationState, minReaderVersion: parseVersion(value.minReaderVersion, 'minReaderVersion'), @@ -92,19 +116,70 @@ export function parseSchemaManifest(value: unknown): SchemaManifestV1 { managedAdapters: parseManagedAdapters(value.managedAdapters), lastOperationId: parseUlidOrNull(value.lastOperationId, 'lastOperationId'), }; + const manifest: SchemaManifest = + manifestVersion === 1 + ? { manifestVersion: 1, ...common } + : { + manifestVersion: 2, + ...common, + workflowPolicyDefaults: parseWorkflowPolicyDefaults( + value.workflowPolicyDefaults, + ), + }; assertManifestStateShape(manifest); + if ( + manifest.manifestVersion === 2 && + (compareVersions(manifest.minReaderVersion, '0.4.0') < 0 || + compareVersions(manifest.minWriterVersion, '0.4.0') < 0) + ) { + throw new Error( + 'schema manifest V2 requires minReaderVersion and minWriterVersion 0.4.0 or newer', + ); + } + return manifest; +} + +export function serializeSchemaManifest(value: unknown): string { + return `${JSON.stringify(parseSchemaManifest(value), null, 2)}\n`; +} + +export function managedAdapterNames( + inventory: ManagedAdapterInventory, +): ManagedAdapter[] { + return MANAGED_ADAPTERS.filter((adapter) => inventory[adapter] !== undefined); +} + +export function parseSchemaManifestV1(value: unknown): SchemaManifestV1 { + const manifest = parseSchemaManifest(value); + if (manifest.manifestVersion !== 1) { + throw new Error( + `MANCODE_MANIFEST_VERSION_UNSUPPORTED: observed=${manifest.manifestVersion} supported=1 requiredWriter=0.3.x`, + ); + } + return manifest; +} + +export function parseSchemaManifestV2(value: unknown): SchemaManifestV2 { + const manifest = parseSchemaManifest(value); + if (manifest.manifestVersion !== 2) { + throw new Error( + `MANCODE_MANIFEST_VERSION_UNSUPPORTED: observed=${manifest.manifestVersion} supported=2 requiredWriter=0.4.0`, + ); + } return manifest; } export function assertSchemaManifestTransition( - previous: SchemaManifestV1, - next: SchemaManifestV1, + previous: SchemaManifest, + next: SchemaManifest, ): void { if ( previous.manifestVersion !== next.manifestVersion || previous.layoutVersion !== next.layoutVersion || previous.epoch !== next.epoch || - !sameLegacyBaseline(previous.legacyBaseline, next.legacyBaseline) + !sameLegacyBaseline(previous.legacyBaseline, next.legacyBaseline) || + compareVersions(next.minReaderVersion, previous.minReaderVersion) < 0 || + compareVersions(next.minWriterVersion, previous.minWriterVersion) < 0 ) { throw new Error( 'schema manifest identity and legacy baseline are immutable', @@ -122,14 +197,41 @@ export function assertSchemaManifestTransition( } } +export function assertSchemaManifestPolicyUpgrade( + previous: SchemaManifest, + next: SchemaManifest, +): asserts next is SchemaManifestV2 { + if ( + previous.manifestVersion !== 1 || + next.manifestVersion !== 2 || + previous.layoutVersion !== next.layoutVersion || + previous.epoch !== next.epoch || + previous.activationState !== 'v3_active' || + next.activationState !== 'v3_active' || + previous.activatedAt !== next.activatedAt || + !sameLegacyBaseline(previous.legacyBaseline, next.legacyBaseline) || + !managedAdapterInventoriesMatch( + previous.managedAdapters, + next.managedAdapters, + ) || + compareVersions(next.minReaderVersion, previous.minReaderVersion) < 0 || + compareVersions(next.minWriterVersion, previous.minWriterVersion) < 0 || + next.workflowPolicyDefaults.planning !== 2 || + next.lastOperationId === null || + next.lastOperationId === previous.lastOperationId + ) { + throw new Error('invalid schema manifest Policy 2 upgrade'); + } +} + /** * Activation rollback is intentionally not a general manifest transition. * The caller must separately prove that the activation's exact targets have * not been followed by a V3 business write. */ export function assertActivationRollbackManifestTransition( - previous: SchemaManifestV1, - next: SchemaManifestV1, + previous: SchemaManifest, + next: SchemaManifest, ): void { if ( previous.manifestVersion !== next.manifestVersion || @@ -145,6 +247,23 @@ export function assertActivationRollbackManifestTransition( } } +function parseWorkflowPolicyDefaults( + value: unknown, +): SchemaManifestV2['workflowPolicyDefaults'] { + assertRecord(value, 'schema manifest workflowPolicyDefaults'); + assertKnownKeys( + value, + ['planning'], + 'schema manifest workflowPolicyDefaults', + ); + if (value.planning !== 2) { + throw new Error( + `MANCODE_POLICY_VERSION_UNSUPPORTED: component=planning observed=${String(value.planning)} supported=2 requiredWriter=0.4.0`, + ); + } + return { planning: 2 }; +} + function parseVersion(value: unknown, label: string): string { if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) { throw new Error(`schema manifest ${label} must be a semantic version`); @@ -186,15 +305,19 @@ function parseLegacyBaseline(value: unknown): LegacyBaseline | null { }; } -function parseManagedAdapters(value: unknown): Record { +function parseManagedAdapters(value: unknown): ManagedAdapterInventory { assertRecord(value, 'schema manifest managedAdapters'); assertKnownKeys(value, MANAGED_ADAPTERS, 'schema manifest managedAdapters'); - const adapters = {} as Record; + const adapters: ManagedAdapterInventory = {}; for (const adapter of MANAGED_ADAPTERS) { - if (typeof value[adapter] !== 'string' || !value[adapter].trim()) { - throw new Error(`schema manifest managedAdapters.${adapter} is required`); + const version = value[adapter]; + if (version === undefined) continue; + if (typeof version !== 'string' || !version.trim()) { + throw new Error( + `schema manifest managedAdapters.${adapter} must be a non-empty version`, + ); } - adapters[adapter] = value[adapter]; + adapters[adapter] = version; } return adapters; } @@ -205,7 +328,7 @@ function parseUlidOrNull(value: unknown, label: string): Ulid | null { return value; } -function assertManifestStateShape(manifest: SchemaManifestV1): void { +function assertManifestStateShape(manifest: SchemaManifest): void { if ( manifest.activationState === 'initializing' && manifest.legacyBaseline !== null @@ -241,6 +364,60 @@ function assertManifestStateShape(manifest: SchemaManifestV1): void { } } +export function managedAdapterInventoriesMatch( + left: ManagedAdapterInventory, + right: ManagedAdapterInventory, +): boolean { + const leftKeys = managedAdapterNames(left); + const rightKeys = managedAdapterNames(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (adapter, index) => + adapter === rightKeys[index] && left[adapter] === right[adapter], + ) + ); +} + +function compareVersions(left: string, right: string): number { + const [leftCore = '', leftPrerelease] = left.split('-', 2); + const [rightCore = '', rightPrerelease] = right.split('-', 2); + const leftParts = leftCore.split('.').map(Number); + const rightParts = rightCore.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (delta !== 0) return delta; + } + if (leftPrerelease === rightPrerelease) return 0; + if (leftPrerelease === undefined) return 1; + if (rightPrerelease === undefined) return -1; + return comparePrerelease(leftPrerelease, rightPrerelease); +} + +function comparePrerelease(left: string, right: string): number { + const leftParts = left.split('.'); + const rightParts = right.split('.'); + for ( + let index = 0; + index < Math.max(leftParts.length, rightParts.length); + index += 1 + ) { + const leftPart = leftParts[index]; + const rightPart = rightParts[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumeric = /^\d+$/.test(leftPart); + const rightNumeric = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) + return Number(leftPart) - Number(rightPart); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return leftPart.localeCompare(rightPart, 'en'); + } + return 0; +} + function sameLegacyBaseline( left: LegacyBaseline | null, right: LegacyBaseline | null, diff --git a/src/context/migrate.ts b/src/context/migrate.ts index 5e3b5a7..10937a9 100644 --- a/src/context/migrate.ts +++ b/src/context/migrate.ts @@ -97,7 +97,7 @@ import { type SchemaManifestV1, assertActivationRollbackManifestTransition, assertSchemaManifestTransition, - parseSchemaManifest, + parseSchemaManifestV1 as parseSchemaManifest, } from './manifest.js'; import type { ManagedAdapter } from './manifest.js'; import { diff --git a/src/context/plan-revision.ts b/src/context/plan-revision.ts index 0ca64a5..ab54e14 100644 --- a/src/context/plan-revision.ts +++ b/src/context/plan-revision.ts @@ -3,6 +3,10 @@ import { createTaskAuthorityFileRecoveryAction, createTaskHeadFenceRecoveryAction, } from '../runtime/operation-recovery-payload.js'; +import { + enqueueSessionPointerProjection, + reconcileProjectionIntents, +} from '../runtime/projection-outbox.js'; import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; import { @@ -69,8 +73,8 @@ export interface RevisedV3Plan { } /** - * Writes a new plan version and makes every prior review and verification - * result explicitly stale before the task becomes stable again. + * Writes a new plan version when content changes, or records a plan decision + * against the existing content without invalidating its evidence. */ export async function reviseV3Plan( input: ReviseV3PlanInput, @@ -106,26 +110,33 @@ export async function reviseV3Plan( if (context.task.plan?.content === plan && planDecision === null) { throw new Error('MANCODE_PLAN_CONTENT_UNCHANGED'); } + const planChanged = context.task.plan?.content !== plan; const timestamp = context.now.toISOString(); - const planDigest = digestCanonicalJson({ - artifactRef: { taskRef, kind: 'plan' }, - content: plan, - }); - const review = markTaskReviewStale( - context.task.review, - context.operationId, - timestamp, - ); - const verification = markTaskVerificationStale( - context.task.verification, - context.operationId, - timestamp, - ); + const planDigest = planChanged + ? digestCanonicalJson({ + artifactRef: { taskRef, kind: 'plan' }, + content: plan, + }) + : context.task.plan?.digest; + if (planDigest === undefined) { + throw new Error('MANCODE_PLAN_FILE_REQUIRED'); + } + const review = planChanged + ? markTaskReviewStale(context.task.review, context.operationId, timestamp) + : context.task.review; + const verification = planChanged + ? markTaskVerificationStale( + context.task.verification, + context.operationId, + timestamp, + ) + : context.task.verification; const metadata = updateMetadata( context.task.metadata, context.task.requirements, review, verification, + planChanged, planDecision, context.operationId, timestamp, @@ -140,6 +151,19 @@ export async function reviseV3Plan( }); const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + if (planDecision === 'plan_only') { + await enqueueSessionPointerProjection(context.projectRoot, { + operationId: context.operationId, + action: 'clear', + sessionId: context.session.sessionId, + expectedPreviousTaskRef: context.session.activeTaskRef, + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now: context.now, + }); + } + journal = await createTaskOperationJournal(context, { type: 'plan_revision', action: @@ -153,13 +177,17 @@ export async function reviseV3Plan( ]), recovery: { actions: [ - createTaskAuthorityFileRecoveryAction({ - stepId: 'write-plan', - taskRef, - fileName: 'plan.md', - beforeContent: context.task.plan?.content ?? null, - targetContent: plan, - }), + ...(planChanged + ? [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-plan', + taskRef, + fileName: 'plan.md', + beforeContent: context.task.plan?.content ?? null, + targetContent: plan, + }), + ] + : []), createTaskAuthorityFileRecoveryAction({ stepId: 'update-metadata', taskRef, @@ -167,20 +195,26 @@ export async function reviseV3Plan( beforeContent: serializeTaskAuthority(context.task.metadata), targetContent: serializeTaskAuthority(metadata), }), - createTaskAuthorityFileRecoveryAction({ - stepId: 'mark-review-verification-stale', - taskRef, - fileName: 'review-ledger.json', - beforeContent: serializeTaskAuthority(context.task.review), - targetContent: serializeTaskAuthority(review), - }), - createTaskAuthorityFileRecoveryAction({ - stepId: 'mark-review-verification-stale', - taskRef, - fileName: 'verification-ledger.json', - beforeContent: serializeTaskAuthority(context.task.verification), - targetContent: serializeTaskAuthority(verification), - }), + ...(planChanged + ? [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority( + context.task.verification, + ), + targetContent: serializeTaskAuthority(verification), + }), + ] + : []), ...(taskHeadFence === null ? [] : [ @@ -191,13 +225,20 @@ export async function reviseV3Plan( }), ]), ], - noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + noOpStepIds: [ + ...(planChanged + ? [] + : ['write-plan', 'mark-review-verification-stale']), + ...(taskHeadFence === null ? ['update-task-head-fence'] : []), + ], }, }); journal = await advanceTaskOperation(context, journal, 'validate', true); journal = await advanceTaskOperation(context, journal, 'write-plan', false); - await writeTaskAuthorityFile(context, 'plan.md', plan); + if (planChanged) { + await writeTaskAuthorityFile(context, 'plan.md', plan); + } journal = await advanceTaskOperation( context, @@ -217,16 +258,18 @@ export async function reviseV3Plan( 'mark-review-verification-stale', false, ); - await writeTaskAuthorityFile( - context, - 'review-ledger.json', - `${JSON.stringify(review, null, 2)}\n`, - ); - await writeTaskAuthorityFile( - context, - 'verification-ledger.json', - `${JSON.stringify(verification, null, 2)}\n`, - ); + if (planChanged) { + await writeTaskAuthorityFile( + context, + 'review-ledger.json', + `${JSON.stringify(review, null, 2)}\n`, + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + `${JSON.stringify(verification, null, 2)}\n`, + ); + } journal = await advanceTaskOperation( context, @@ -239,6 +282,17 @@ export async function reviseV3Plan( await replaceTaskHeadFence(context.homeStore, taskHeadFence); } const operation = await commitTaskOperation(context, journal); + if (planDecision === 'plan_only') { + try { + await reconcileProjectionIntents( + context.projectRoot, + context.operationId, + context.now, + ); + } catch { + // Planned authority is committed; doctor can finish the projection. + } + } return { metadata, review, @@ -306,6 +360,7 @@ function updateMetadata( requirements: RequirementsLedgerV1, review: ReviewLedgerV1, verification: VerificationLedgerV1, + planChanged: boolean, planDecision: V3PlanDecision | null, operationId: Ulid, updatedAt: string, @@ -323,11 +378,13 @@ function updateMetadata( ...previous.governance, requirementsStatus: 'ready', requirementsDigest: requirements.contentDigest, - planVersion: previous.governance.planVersion + 1, + planVersion: previous.governance.planVersion + (planChanged ? 1 : 0), planDecision, - reviewStatus: 'stale', + reviewStatus: planChanged ? 'stale' : previous.governance.reviewStatus, reviewLedgerDigest: review.contentDigest, - verificationStatus: 'stale', + verificationStatus: planChanged + ? 'stale' + : previous.governance.verificationStatus, verificationLedgerDigest: verification.contentDigest, }, updatedAt, diff --git a/src/context/project-policy-upgrade.ts b/src/context/project-policy-upgrade.ts new file mode 100644 index 0000000..4651d40 --- /dev/null +++ b/src/context/project-policy-upgrade.ts @@ -0,0 +1,709 @@ +import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { inspectV3AdapterVersions } from '../installers/v3-adapter.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { + type EntityHomeStore, + resolveLocalEntityHomeStore, +} from '../runtime/entity-home-store.js'; +import { + type LocalLockHandle, + acquireOperationEntityLocks, +} from '../runtime/local-lock.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../runtime/operation-definition.js'; +import type { + OperationJournalV1, + OperationStep, +} from '../runtime/operation-journal.js'; +import { + executeOperationRecovery, + listUnfinishedOperationRecoveries, +} from '../runtime/operation-recovery-executor.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createProjectAuthorityFileRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../runtime/operation-recovery-store.js'; +import { + createPreparedOperationJournal, + readOperationJournal, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { PROJECT_SCHEMA_LOCK } from '../runtime/project-write-barrier.js'; +import { type SessionStateV1, readSession } from '../runtime/session.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { VERSION } from '../version.js'; +import { digestCanonicalJson } from './canonical.js'; +import { + CURRENT_WRITER_CAPABILITIES, + assertCompatibilityGate, + compareSemver, +} from './compatibility.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { scanLegacyAuthority } from './layout.js'; +import { managedAdapterNames } from './manifest.js'; +import { + type SchemaManifestV2, + assertSchemaManifestPolicyUpgrade, + parseSchemaManifest, + serializeSchemaManifest, +} from './manifest.js'; +import { V3ContextStore } from './store.js'; + +export interface ProjectPolicyUpgradeInput { + projectRoot: string; + policyVersion?: 2; + sessionId: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface ProjectPolicyUpgradePreviewInput { + projectRoot: string; + policyVersion?: 2; + operationId?: Ulid; + now?: Date; +} + +export interface ProjectPolicyUpgradePreview { + schemaVersion: 1; + policy: 2; + currentManifestVersion: 1 | 2; + willUpgrade: boolean; + beforeDigest: string; + afterDigest: string | null; + minReaderVersion: string; + minWriterVersion: string; + blockers: string[]; + operationId: Ulid; +} + +interface ProjectPolicyUpgradePreviewReceiptV1 { + schemaVersion: 1; + operationId: Ulid; + projectFingerprint: string; + beforeDigest: string; + afterDigest: string; + createdAt: string; +} + +export interface ProjectPolicyUpgradeResult { + schemaVersion: 1; + policy: 2; + state: 'committed' | 'already_upgraded'; + operation: OperationJournalV1 | null; + manifest: SchemaManifestV2; +} + +const UPGRADE_OPERATION = 'project_policy_upgrade' as const; + +export async function dryRunProjectPolicyUpgrade( + input: ProjectPolicyUpgradePreviewInput, +): Promise { + assertPolicyVersion(input.policyVersion); + const root = path.resolve(input.projectRoot); + const project = await new V3ContextStore(root).readProjectSnapshot(); + const beforeDigest = digestManifest(project.manifest); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'project policy upgrade preview operationId'); + const blockers = await collectUpgradeBlockers(root, project); + let afterDigest: string | null = null; + let minReaderVersion = project.manifest.minReaderVersion; + let minWriterVersion = project.manifest.minWriterVersion; + if (project.manifest.manifestVersion === 1 && blockers.length === 0) { + const candidate = buildV2Manifest(project.manifest, operationId); + afterDigest = digestManifest(candidate); + minReaderVersion = candidate.minReaderVersion; + minWriterVersion = candidate.minWriterVersion; + await stageProjectPolicyUpgrade( + root, + { + schemaVersion: 1, + operationId, + projectFingerprint: project.fingerprint, + beforeDigest, + afterDigest, + createdAt: now.toISOString(), + }, + serializeSchemaManifest(candidate), + ); + } + return { + schemaVersion: 1, + policy: 2, + currentManifestVersion: project.manifest.manifestVersion, + willUpgrade: + project.manifest.manifestVersion === 1 && blockers.length === 0, + beforeDigest, + afterDigest, + minReaderVersion, + minWriterVersion, + blockers, + operationId, + }; +} + +export async function upgradeProjectPolicy( + input: ProjectPolicyUpgradeInput, +): Promise { + assertPolicyVersion(input.policyVersion); + assertUlid(input.sessionId, 'project policy upgrade sessionId'); + const root = path.resolve(input.projectRoot); + const now = input.now ?? new Date(); + const operationId = input.operationId; + if (operationId === undefined) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_REQUIRED'); + } + assertUlid(operationId, 'project policy upgrade operationId'); + const runtime = await readProjectRuntimeContext(root); + const store = new V3ContextStore(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const session = await readSession(root, input.sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const initial = await store.readProjectSnapshot(); + if (initial.manifest.manifestVersion === 2) { + const recovered = await recoverInterruptedProjectPolicyUpgrade({ + root, + store, + localStore, + session, + operationId, + now, + }); + if (recovered !== null) return recovered; + return { + schemaVersion: 1, + policy: 2, + state: 'already_upgraded', + operation: null, + manifest: initial.manifest, + }; + } + await assertUpgradePreflight(root, initial); + await assertProjectPolicyUpgradePreview(root, operationId, initial); + const locks = await acquireOperationEntityLocks( + operationId, + [{ store: localStore, entityLockKeys: [PROJECT_SCHEMA_LOCK] }], + { now }, + ); + let journal: OperationJournalV1 | null = null; + try { + const project = await store.readProjectSnapshot(); + if (project.manifest.manifestVersion !== 1) { + if (project.manifest.manifestVersion === 2) { + return { + schemaVersion: 1, + policy: 2, + state: 'already_upgraded', + operation: null, + manifest: project.manifest, + }; + } + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + await assertUpgradePreflight(root, project); + await assertProjectPolicyUpgradePreview(root, operationId, project); + const target = buildV2Manifest(project.manifest, operationId); + const beforeContent = await readSchemaContent(root); + const targetContent = serializeSchemaManifest(target); + const payload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId, + type: UPGRADE_OPERATION, + primaryStoreId: localStore.storeId, + actions: [ + createProjectAuthorityFileRecoveryAction({ + stepId: 'write-manifest', + fileName: 'schema.json', + beforeContent, + targetContent, + }), + ], + noOpStepIds: ['verify-manifest'], + }); + const authorizationBasis = createAuthorizationBasis( + { + action: 'project_maintenance', + actorId: session.actorId, + session: { + sessionId: session.sessionId, + actorId: session.actorId, + status: session.status, + }, + joined: false, + sharedWriteGuard: 'enforced', + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + explicitConfirmation: true, + }, + }, + now, + ); + journal = { + schemaVersion: 1, + operationId, + type: UPGRADE_OPERATION, + state: 'prepared', + primaryStoreId: localStore.storeId, + checkoutId: runtime.checkoutId, + secondaryReservations: [], + actorId: session.actorId, + sessionId: session.sessionId, + authorizationBasis, + recoveryPayloadDigest: operationRecoveryPayloadDigest(payload), + entityLocks: [PROJECT_SCHEMA_LOCK], + expectedRevisions: { [PROJECT_SCHEMA_LOCK]: 1 }, + steps: getOperationDefinition(UPGRADE_OPERATION).steps.map((step) => ({ + id: step.id, + state: 'pending', + })), + startedAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + assertOperationJournalMatchesDefinition(journal); + assertOperationRecoveryPayloadCoversJournal(journal, payload); + await writeOperationRecoveryPayload(localStore, payload); + journal = await createPreparedOperationJournal(localStore, journal); + throwIfOperationCrashInjected(UPGRADE_OPERATION, 'prepared'); + + journal = await advance(localStore, journal, 'validate', now, true); + journal = await advance(localStore, journal, 'write-manifest', now, false); + await writeSchemaContent(root, operationId, targetContent); + armOperationCrashAfterVisibleWrite(UPGRADE_OPERATION, 'write-manifest'); + journal = await advance(localStore, journal, 'verify-manifest', now, false); + const verified = parseSchemaManifest( + JSON.parse(await readSchemaContent(root)), + ); + assertSchemaManifestPolicyUpgrade(project.manifest, verified); + if (digestManifest(verified) !== digestManifest(target)) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + journal = await commit(localStore, journal, now); + return { + schemaVersion: 1, + policy: 2, + state: 'committed', + operation: journal, + manifest: verified, + }; + } catch (error) { + if (journal !== null && journal.state !== 'committed') { + try { + const current = + (await readOperationJournal(localStore, operationId)) ?? journal; + const hasWrite = current.steps.some( + (step, index) => + step.state === 'completed' && + getOperationDefinition(UPGRADE_OPERATION).steps[index] + ?.visibility === 'business_write', + ); + await updateOperationJournal( + localStore, + { + ...current, + state: hasWrite ? 'repair_required' : 'aborted', + updatedAt: now.toISOString(), + }, + { canAbort: !hasWrite }, + ); + } catch { + // The durable journal remains the source of truth for repair. + } + } + throw error; + } finally { + await releaseLocks(locks); + } +} + +async function recoverInterruptedProjectPolicyUpgrade(input: { + root: string; + store: V3ContextStore; + localStore: EntityHomeStore; + session: SessionStateV1; + operationId: Ulid; + now: Date; +}): Promise { + const journal = await readOperationJournal( + input.localStore, + input.operationId, + ); + if ( + journal === null || + journal.state === 'committed' || + journal.state === 'aborted' + ) { + return null; + } + if (journal.type !== UPGRADE_OPERATION) { + throw new Error('MANCODE_OPERATION_JOURNAL_CONFLICT'); + } + const recovered = await executeOperationRecovery({ + projectRoot: input.root, + operationId: input.operationId, + actorId: input.session.actorId, + sessionId: input.session.sessionId, + mode: 'repair', + now: input.now, + }); + if (recovered.journal.state !== 'committed') { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + const project = await input.store.readProjectSnapshot(); + if (project.manifest.manifestVersion !== 2) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + return { + schemaVersion: 1, + policy: 2, + state: 'committed', + operation: recovered.journal, + manifest: project.manifest, + }; +} + +async function collectUpgradeBlockers( + root: string, + project: Awaited>, +): Promise { + const blockers: string[] = []; + if (project.manifest.manifestVersion !== 1) return blockers; + try { + await assertUpgradePreflight(root, project); + } catch (error) { + blockers.push(errorCode(error)); + } + return [...new Set(blockers)]; +} + +async function assertUpgradePreflight( + root: string, + project: Awaited>, +): Promise { + if (project.manifest.manifestVersion !== 1) { + throw new Error('MANCODE_PROJECT_POLICY_ALREADY_UPGRADED'); + } + if (project.manifest.activationState !== 'v3_active') { + throw new Error('MANCODE_V3_WRITE_REQUIRES_ACTIVATION'); + } + const unfinished = await listUnfinishedOperationRecoveries(root); + if (unfinished.length > 0) + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + const legacy = await scanLegacyAuthority(root); + const adapterVersions = await inspectV3AdapterVersions( + root, + managedAdapterNames(project.manifest.managedAdapters), + ); + assertCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'project_policy_upgrade', + }); +} + +function buildV2Manifest( + manifest: Extract< + Awaited>['manifest'], + { manifestVersion: 1 } + >, + operationId: Ulid, +): SchemaManifestV2 { + const candidate = parseSchemaManifest({ + ...manifest, + manifestVersion: 2, + minReaderVersion: maxVersion(manifest.minReaderVersion, '0.4.0'), + minWriterVersion: maxVersion(manifest.minWriterVersion, '0.4.0'), + workflowPolicyDefaults: { planning: 2 }, + lastOperationId: operationId, + }); + assertSchemaManifestPolicyUpgrade(manifest, candidate); + return candidate; +} + +async function readSchemaContent(root: string): Promise { + return readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'); +} + +async function writeSchemaContent( + root: string, + operationId: Ulid, + content: string, +): Promise { + const directory = path.join(root, '.mancode'); + const target = path.join(directory, 'schema.json'); + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const temporary = path.join( + directory, + `.schema.json.${operationId}.${process.pid}.tmp`, + ); + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + try { + await replaceFileAtomically(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +async function advance( + store: EntityHomeStore, + previous: OperationJournalV1, + stepId: string, + now: Date, + canAbort: boolean, +): Promise { + throwIfDeferredOperationCrashInjected(UPGRADE_OPERATION); + const result = await updateOperationJournal( + store, + { + ...previous, + state: 'applying', + steps: completeStep(previous.steps, stepId), + updatedAt: now.toISOString(), + }, + { canAbort }, + ); + if (stepId !== 'write-manifest') { + throwIfOperationCrashInjected(UPGRADE_OPERATION, stepId); + } + return result; +} + +async function commit( + store: EntityHomeStore, + previous: OperationJournalV1, + now: Date, +): Promise { + throwIfDeferredOperationCrashInjected(UPGRADE_OPERATION); + const result = await updateOperationJournal( + store, + { + ...previous, + state: 'committed', + steps: completeStep(previous.steps, 'commit'), + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ); + throwIfOperationCrashInjected(UPGRADE_OPERATION, 'commit'); + return result; +} + +function completeStep( + steps: OperationStep[], + requested: string, +): OperationStep[] { + const index = steps.findIndex((step) => step.id === requested); + if (index < 0 || steps[index]?.state === 'completed') { + throw new Error('MANCODE_OPERATION_STEP_INVALID'); + } + if (steps.slice(0, index).some((step) => step.state !== 'completed')) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + return steps.map((step, stepIndex) => + stepIndex === index ? { ...step, state: 'completed' } : step, + ); +} + +function digestManifest(manifest: unknown): string { + return digestCanonicalJson(parseSchemaManifest(manifest)); +} + +function maxVersion(left: string, right: string): string { + return compareSemver(left, right) >= 0 ? left : right; +} + +function assertPolicyVersion(value: unknown): void { + if (value !== undefined && value !== 2) { + throw new Error( + `MANCODE_POLICY_VERSION_UNSUPPORTED: component=planning observed=${String(value)} supported=2 requiredWriter=0.4.0`, + ); + } +} + +function errorCode(error: unknown): string { + return error instanceof Error && error.message.startsWith('MANCODE_') + ? (error.message.split(':', 1)[0] ?? 'MANCODE_PROJECT_UPGRADE_BLOCKED') + : 'MANCODE_PROJECT_UPGRADE_BLOCKED'; +} + +async function releaseLocks(locks: LocalLockHandle[]): Promise { + await Promise.allSettled([...locks].reverse().map((lock) => lock.release())); +} + +function projectUpgradePreviewRoot(root: string, operationId: Ulid): string { + return path.join(root, '.mancode', 'staging', 'project-upgrade', operationId); +} + +async function stageProjectPolicyUpgrade( + root: string, + receipt: ProjectPolicyUpgradePreviewReceiptV1, + manifestContent: string, +): Promise { + const directory = projectUpgradePreviewRoot(root, receipt.operationId); + const receiptPath = path.join(directory, 'preview.json'); + const manifestPath = path.join(directory, 'schema.json'); + await mkdir(path.dirname(directory), { recursive: true }); + try { + await mkdir(directory); + } catch (error) { + if (isAlreadyExists(error)) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_EXISTS'); + } + throw error; + } + try { + await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await writeFile(manifestPath, manifestContent, { + encoding: 'utf8', + flag: 'wx', + }); + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + +async function assertProjectPolicyUpgradePreview( + root: string, + operationId: Ulid, + project: Awaited>, +): Promise { + const directory = projectUpgradePreviewRoot(root, operationId); + const [receiptContent, manifestContent] = await Promise.all([ + readSafePreviewFile(path.join(directory, 'preview.json')), + readSafePreviewFile(path.join(directory, 'schema.json')), + ]); + let receipt: ProjectPolicyUpgradePreviewReceiptV1; + try { + receipt = parseProjectPolicyUpgradePreviewReceipt( + JSON.parse(receiptContent), + ); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_INVALID'); + } + throw error; + } + if (project.manifest.manifestVersion !== 1) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_STALE'); + } + const target = buildV2Manifest(project.manifest, operationId); + if ( + receipt.operationId !== operationId || + receipt.projectFingerprint !== project.fingerprint || + receipt.beforeDigest !== digestManifest(project.manifest) || + receipt.afterDigest !== digestManifest(target) || + manifestContent !== serializeSchemaManifest(target) + ) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_STALE'); + } +} + +async function readSafePreviewFile(target: string): Promise { + try { + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_INVALID'); + } + return readFile(target, 'utf8'); + } catch (error) { + if (isNotFound(error)) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_REQUIRED'); + } + throw error; + } +} + +function parseProjectPolicyUpgradePreviewReceipt( + value: unknown, +): ProjectPolicyUpgradePreviewReceiptV1 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_INVALID'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const expected = [ + 'afterDigest', + 'beforeDigest', + 'createdAt', + 'operationId', + 'projectFingerprint', + 'schemaVersion', + ]; + if ( + keys.length !== expected.length || + keys.some((key, index) => key !== expected[index]) || + record.schemaVersion !== 1 || + typeof record.projectFingerprint !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(record.projectFingerprint) || + typeof record.beforeDigest !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(record.beforeDigest) || + typeof record.afterDigest !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(record.afterDigest) || + typeof record.createdAt !== 'string' || + !Number.isFinite(Date.parse(record.createdAt)) + ) { + throw new Error('MANCODE_PROJECT_UPGRADE_PREVIEW_INVALID'); + } + assertUlid(record.operationId, 'project upgrade preview operationId'); + return { + schemaVersion: 1, + operationId: record.operationId, + projectFingerprint: record.projectFingerprint, + beforeDigest: record.beforeDigest, + afterDigest: record.afterDigest, + createdAt: record.createdAt, + }; +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EEXIST' + ); +} diff --git a/src/context/reframe.ts b/src/context/reframe.ts new file mode 100644 index 0000000..b3f7e35 --- /dev/null +++ b/src/context/reframe.ts @@ -0,0 +1,783 @@ +import { updateClaim } from '../runtime/claim-store.js'; +import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + type TaskArchiveManifestV1, + type TaskArchiveRecoveryAction, + createCheckpointRecoveryAction, + createClaimRecoveryAction, + createTaskArchiveRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, + taskArchiveManifest, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { + enqueueSessionPointerProjection, + reconcileProjectionIntents, +} from '../runtime/projection-outbox.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + readTaskAuthorityFileAtRoot, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskArchive, + writeTaskAuthorityFile, + writeTaskCheckpoint, +} from '../runtime/task-operation.js'; +import { type CheckpointV1, parseCheckpoint } from '../team/checkpoints.js'; +import { + type ClaimV1, + assertClaimTransition, + parseClaim, +} from '../team/claims.js'; +import { deriveClaimValidity } from '../team/conflicts.js'; +import type { HandoffV1 } from '../team/handoff.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { + type RequirementsLedgerV1, + assertRequirementsLedgerTransition, + parseRequirementsLedger, + requirementsLedgerDigest, +} from './requirements-ledger.js'; +import type { ReviewLedgerV1 } from './review-ledger.js'; +import { V3ContextStore } from './store.js'; +import { + assertTaskCodeHeadUnchanged, + markTaskReviewStale, + markTaskVerificationStale, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import type { VerificationLedgerV1 } from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface ReframeV3WorkflowInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + checkpointId: Ulid; + summary?: string; + nextAction?: string; + operationId?: Ulid; + now?: Date; +} + +export type ReframeArchiveSummary = Pick< + TaskArchiveManifestV1, + | 'archiveId' + | 'taskRef' + | 'sourceTaskRevision' + | 'sourceRequirementsRevision' + | 'sourceRequirementsDigest' + | 'sourcePlanVersion' + | 'sourcePlanDigest' + | 'createdAt' +>; + +export interface ReframedV3Workflow { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + checkpoint: CheckpointV1; + releasedClaims: ClaimV1[]; + archive: ReframeArchiveSummary; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** Reopens requirements clarification without losing prior authority evidence. */ +export async function reframeV3Workflow( + input: ReframeV3WorkflowInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + assertUlid(input.checkpointId, 'reframe checkpointId'); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'reframe operationId'); + + const opened = await openReframeContext({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + checkpointId: input.checkpointId, + operationId, + now, + }); + const { context, activeClaims, openHandoffs, activeChildren } = opened; + let journal: OperationJournalV1 | null = null; + try { + assertReframeEligible(context, activeClaims, openHandoffs, activeChildren); + assertReframeClaimsFresh(context, activeClaims); + + const timestamp = context.now.toISOString(); + const archivedRequirementsContent = await readTaskAuthorityFileAtRoot( + context.task.location.taskRoot, + 'requirements.json', + ); + if (archivedRequirementsContent === null) { + throw new Error('MANCODE_REFRAME_REQUIREMENTS_MISSING'); + } + const pendingMetadata = markReframeOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const requirements = createReframedRequirements( + context.task.requirements, + context.operationId, + timestamp, + ); + const review = markTaskReviewStale( + context.task.review, + context.operationId, + timestamp, + ); + const verification = markTaskVerificationStale( + context.task.verification, + context.operationId, + timestamp, + ); + const releasedClaims = activeClaims.map((claim) => + releaseClaimForReframe(claim, context.operationId, timestamp), + ); + const archiveAction = createTaskArchiveRecoveryAction({ + stepId: 'archive-requirements-plan', + taskRef, + archiveId: context.operationId, + sourceTaskRevision: context.task.metadata.revision, + sourcePlanVersion: context.task.metadata.governance.planVersion, + createdAt: timestamp, + requirementsContent: archivedRequirementsContent, + planContent: context.task.plan?.content ?? null, + }); + const branch = await readCheckoutBranch(context.projectRoot); + const checkpoint = buildReframeCheckpoint({ + context, + checkpointId: input.checkpointId, + taskRevision: pendingMetadata.revision + 1, + requirements, + review, + verification, + summary: input.summary, + nextAction: input.nextAction, + branch, + timestamp, + }); + const metadata = completeReframeMetadata( + pendingMetadata, + requirements, + review, + verification, + checkpoint, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: checkpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + const refreshesSessionPointer = + context.session.activeTaskRef !== null && + sameTaskRef(context.session.activeTaskRef, taskRef); + if (refreshesSessionPointer) { + await enqueueSessionPointerProjection(context.projectRoot, { + operationId: context.operationId, + action: 'resume', + sessionId: context.session.sessionId, + expectedPreviousTaskRef: context.session.activeTaskRef, + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now: context.now, + }); + } + + journal = await createTaskOperationJournal(context, { + type: 'reframe', + action: + taskRef.namespace === 'shared' + ? 'shared_metadata_plan_mutation' + : 'local_workflow_mutation', + expectedRevisions: reframeExpectedRevisions( + context, + input.checkpointId, + activeClaims, + ), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-task-operation-pending', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + archiveAction, + ...releasedClaims.map((claim, index) => + createClaimRecoveryAction({ + stepId: 'release-active-claims', + before: activeClaims[index] ?? null, + claim, + }), + ), + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-requirements-draft', + taskRef, + fileName: 'requirements.json', + beforeContent: serializeTaskAuthority(context.task.requirements), + targetContent: serializeTaskAuthority(requirements), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(verification), + }), + createCheckpointRecoveryAction({ + stepId: 'write-reframe-checkpoint', + before: null, + checkpoint, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'commit-reframed-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: [ + ...(releasedClaims.length === 0 ? ['release-active-claims'] : []), + ...(taskHeadFence === null ? ['update-task-head-fence'] : []), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-task-operation-pending', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(pendingMetadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'archive-requirements-plan', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskArchive(context, archiveAction); + + journal = await advanceTaskOperation( + context, + journal, + 'release-active-claims', + false, + ); + await assertReframeCodeHeadUnchanged(context); + for (const [index, claim] of releasedClaims.entries()) { + const previous = activeClaims[index]; + if (previous === undefined) throw new Error('MANCODE_CLAIM_SET_CHANGED'); + await updateClaim(context.homeStore, claim, previous.revision); + } + + journal = await advanceTaskOperation( + context, + journal, + 'write-requirements-draft', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskAuthorityFile( + context, + 'requirements.json', + serializeTaskAuthority(requirements), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-review-verification-stale', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskAuthorityFile( + context, + 'review-ledger.json', + serializeTaskAuthority(review), + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + serializeTaskAuthority(verification), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'write-reframe-checkpoint', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskCheckpoint(context, checkpoint); + + journal = await advanceTaskOperation( + context, + journal, + 'commit-reframed-metadata', + false, + ); + await assertReframeCodeHeadUnchanged(context); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + if (refreshesSessionPointer) { + try { + await reconcileProjectionIntents( + context.projectRoot, + context.operationId, + context.now, + ); + } catch { + // Reframed authority is committed; doctor can finish the projection. + } + } + const archive = archiveSummary(archiveAction); + return { + metadata, + requirements, + review, + verification, + checkpoint, + releasedClaims, + archive, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // The journal and recovery payload still block ordinary mutation. + } + } + throw error; + } finally { + await context.release(); + } +} + +interface OpenReframeContextInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + checkpointId: Ulid; + operationId: Ulid; + now: Date; +} + +interface OpenedReframeContext { + context: OpenedV3TaskOperation; + activeClaims: ClaimV1[]; + openHandoffs: HandoffV1[]; + activeChildren: TaskRef[]; +} + +async function openReframeContext( + input: OpenReframeContextInput, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const runtime = await readProjectRuntimeContext(input.projectRoot); + const store = new V3ContextStore(input.projectRoot); + const project = await store.readProjectSnapshot(); + if (project.config.transport.mode !== 'local') { + throw new Error('MANCODE_REFRAME_GIT_REF_UNSUPPORTED'); + } + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + input.taskRef, + ); + const preflight = await store.readCoordinationSnapshot( + input.taskRef, + homeStore, + ); + const preflightClaims = activeTaskClaims(preflight.claims); + const preflightHandoffs = openTaskHandoffs(preflight.handoffs); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: input.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + compatibilityOperation: 'reframe', + extraEntityLocks: [ + `archive:${input.operationId}`, + `checkpoint:${input.checkpointId}`, + ...(input.taskRef.namespace === 'shared' + ? [taskHeadEntityKey(input.taskRef)] + : []), + ...preflightClaims.map((claim) => `claim:${claim.claimId}`), + ...preflightHandoffs.map((handoff) => `handoff:${handoff.handoffId}`), + ], + now: input.now, + }); + const lockedClaims = activeTaskClaims(context.coordination.claims); + const lockedHandoffs = openTaskHandoffs(context.coordination.handoffs); + if ( + sameIds( + preflightClaims.map((claim) => claim.claimId), + lockedClaims.map((claim) => claim.claimId), + ) && + sameIds( + preflightHandoffs.map((handoff) => handoff.handoffId), + lockedHandoffs.map((handoff) => handoff.handoffId), + ) + ) { + try { + const activeChildren = await context.store.listActiveChildTaskRefs( + input.taskRef, + ); + return { + context, + activeClaims: lockedClaims, + openHandoffs: lockedHandoffs, + activeChildren, + }; + } catch (error) { + await context.release(); + throw error; + } + } + await context.release(); + } + throw new Error('MANCODE_REFRAME_COORDINATION_SET_CHANGED'); +} + +function assertReframeEligible( + context: OpenedV3TaskOperation, + _claims: ClaimV1[], + openHandoffs: HandoffV1[], + activeChildren: TaskRef[], +): void { + const metadata = context.task.metadata; + if (context.project.config.transport.mode !== 'local') { + throw new Error('MANCODE_REFRAME_GIT_REF_UNSUPPORTED'); + } + if (metadata.workflowMode !== 'man' && metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_REFRAME_WORKFLOW_MODE_INVALID'); + } + if (!['in_progress', 'planned', 'blocked'].includes(metadata.status)) { + throw new Error('MANCODE_REFRAME_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if (activeChildren.length > 0) { + throw new Error('MANCODE_REFRAME_ACTIVE_CHILD'); + } + if (openHandoffs.length > 0) { + throw new Error('MANCODE_REFRAME_OPEN_HANDOFF'); + } + if (metadata.soloExecution !== null) { + throw new Error('MANCODE_REFRAME_ACTIVE_SOLO'); + } + if (context.task.requirements.status !== 'confirmed') { + throw new Error('MANCODE_REFRAME_REQUIREMENTS_NOT_CONFIRMED'); + } +} + +function assertReframeClaimsFresh( + context: OpenedV3TaskOperation, + claims: ClaimV1[], +): void { + if (claims.length === 0) return; + const codeHead = context.codeHead; + if (codeHead === null) + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + for (const claim of claims) { + const validity = deriveClaimValidity(claim, { + taskRef: context.taskRef, + taskRevision: context.task.metadata.revision, + implementationScopeDigest: + context.task.metadata.implementationScope.digest, + ownershipEpoch: context.task.metadata.ownershipEpoch, + codeRefHead: codeHead, + now: context.now, + transportFreshness: 'fresh', + }); + if (validity !== 'fresh') { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + } +} + +function activeTaskClaims(claims: ClaimV1[]): ClaimV1[] { + return claims + .filter((claim) => claim.state === 'active') + .sort((left, right) => left.claimId.localeCompare(right.claimId)); +} + +function openTaskHandoffs(handoffs: HandoffV1[]): HandoffV1[] { + return handoffs + .filter( + (handoff) => handoff.state === 'draft' || handoff.state === 'offered', + ) + .sort((left, right) => left.handoffId.localeCompare(right.handoffId)); +} + +function sameIds(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function markReframeOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function createReframedRequirements( + previous: RequirementsLedgerV1, + operationId: Ulid, + updatedAt: string, +): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + ...previous, + revision: previous.revision + 1, + status: 'draft', + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); + assertRequirementsLedgerTransition(previous, next); + return next; +} + +function releaseClaimForReframe( + previous: ClaimV1, + operationId: Ulid, + updatedAt: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: 'released', + revision: previous.revision + 1, + lastOperationId: operationId, + updatedAt, + }); + assertClaimTransition(previous, next); + return next; +} + +function buildReframeCheckpoint(input: { + context: OpenedV3TaskOperation; + checkpointId: Ulid; + taskRevision: number; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + summary?: string; + nextAction?: string; + branch: string | null; + timestamp: string; +}): CheckpointV1 { + return parseCheckpoint({ + schemaVersion: 1, + checkpointId: input.checkpointId, + operationId: input.context.operationId, + taskRef: input.context.taskRef, + taskRevision: input.taskRevision, + ownershipEpochAtOffer: input.context.task.metadata.ownershipEpoch, + kind: 'requirements_reframed', + git: { + branch: input.branch, + head: input.context.codeHead, + base: input.context.task.metadata.base?.head ?? null, + }, + summary: + input.summary ?? + 'Archived the previous requirements and plan before reopening clarification.', + governance: { + requirementsDigest: input.requirements.contentDigest, + planVersion: input.context.task.metadata.governance.planVersion, + reviewLedgerDigest: input.review.contentDigest, + verificationLedgerDigest: input.verification.contentDigest, + }, + nextAction: + input.nextAction ?? + 'Clarify the reframed requirements before revising the plan.', + createdBy: { + actorId: input.context.session.actorId, + client: input.context.session.client, + }, + createdAt: input.timestamp, + }); +} + +function completeReframeMetadata( + previous: WorkflowMetadataV3, + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, + checkpoint: CheckpointV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + status: 'in_progress', + currentStep: 2, + blockingReason: null, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + requirementsStatus: 'needs_clarification', + requirementsDigest: requirements.contentDigest, + planDecision: null, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + latestCheckpointRef: { + taskRef: checkpoint.taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function reframeExpectedRevisions( + context: OpenedV3TaskOperation, + checkpointId: Ulid, + claims: ClaimV1[], +): Record { + const expected = taskMutationExpectedRevisions(context, [ + 'requirements', + 'plan', + 'review', + 'verification', + ]); + expected[`archive:${context.operationId}`] = 0; + expected[`checkpoint:${checkpointId}`] = 0; + for (const claim of claims) { + expected[`claim:${claim.claimId}`] = claim.revision; + } + return expected; +} + +async function assertReframeCodeHeadUnchanged( + context: OpenedV3TaskOperation, +): Promise { + if (context.taskRef.namespace === 'shared') { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + } +} + +function archiveSummary( + action: TaskArchiveRecoveryAction, +): ReframeArchiveSummary { + const manifest = taskArchiveManifest(action); + return { + archiveId: manifest.archiveId, + taskRef: manifest.taskRef, + sourceTaskRevision: manifest.sourceTaskRevision, + sourceRequirementsRevision: manifest.sourceRequirementsRevision, + sourceRequirementsDigest: manifest.sourceRequirementsDigest, + sourcePlanVersion: manifest.sourcePlanVersion, + sourcePlanDigest: manifest.sourcePlanDigest, + createdAt: manifest.createdAt, + }; +} diff --git a/src/context/requirements-finalize.ts b/src/context/requirements-finalize.ts index da50564..d698e57 100644 --- a/src/context/requirements-finalize.ts +++ b/src/context/requirements-finalize.ts @@ -53,6 +53,8 @@ export interface FinalizeV3RequirementsInput { now?: Date; } +export type SaveV3RequirementsDraftInput = FinalizeV3RequirementsInput; + export interface FinalizedV3Requirements { metadata: WorkflowMetadataV3; requirements: RequirementsLedgerV1; @@ -71,15 +73,42 @@ export interface FinalizedV3Requirements { */ export async function finalizeV3Requirements( input: FinalizeV3RequirementsInput, +): Promise { + return writeV3Requirements(input, 'finalize'); +} + +/** + * Persists an incomplete clarification ledger without claiming that its scope + * or coverage is confirmed. A later session can resume the TaskRef and read the + * open questions from the normal plan Context Pack. + */ +export async function saveV3RequirementsDraft( + input: SaveV3RequirementsDraftInput, +): Promise { + return writeV3Requirements(input, 'draft'); +} + +async function writeV3Requirements( + input: FinalizeV3RequirementsInput, + action: 'draft' | 'finalize', ): Promise { const taskRef = parseTaskRefValue(input.taskRef); const submitted = parseRequirementsLedger(input.requirements); if (!sameTaskRef(submitted.taskRef, taskRef)) { throw new Error('MANCODE_REQUIREMENTS_TASK_REF_MISMATCH'); } - if (submitted.status !== 'confirmed') { + if (action === 'finalize' && submitted.status !== 'confirmed') { throw new Error('MANCODE_REQUIREMENTS_CONFIRMATION_REQUIRED'); } + if (action === 'draft' && submitted.status !== 'draft') { + throw new Error('MANCODE_REQUIREMENTS_DRAFT_REQUIRED'); + } + if ( + action === 'draft' && + !submitted.blockingUnknowns.some((unknown) => unknown.status === 'open') + ) { + throw new Error('MANCODE_REQUIREMENTS_DRAFT_BLOCKER_REQUIRED'); + } const context = await openV3TaskOperation({ projectRoot: input.projectRoot, @@ -95,11 +124,12 @@ export async function finalizeV3Requirements( try { assertRequirementsFinalizeEligible(context.task.metadata); const timestamp = context.now.toISOString(); - const requirements = createFinalRequirements( + const requirements = createReplacementRequirements( context.task.requirements, submitted, context.operationId, timestamp, + action === 'draft' ? 'draft' : 'confirmed', ); const review = markTaskReviewStale( context.task.review, @@ -130,7 +160,7 @@ export async function finalizeV3Requirements( const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); journal = await createTaskOperationJournal(context, { - type: 'requirements_finalize', + type: action === 'draft' ? 'requirements_draft' : 'requirements_finalize', action: taskRef.namespace === 'shared' ? 'shared_metadata_plan_mutation' @@ -271,17 +301,18 @@ function assertRequirementsFinalizeEligible( } } -function createFinalRequirements( +function createReplacementRequirements( previous: RequirementsLedgerV1, submitted: RequirementsLedgerV1, operationId: Ulid, updatedAt: string, + status: 'draft' | 'confirmed', ): RequirementsLedgerV1 { const draft: RequirementsLedgerV1 = { ...submitted, taskRef: previous.taskRef, revision: previous.revision + 1, - status: 'confirmed', + status, contentDigest: '', lastOperationId: operationId, updatedAt, @@ -310,7 +341,8 @@ function updateMetadata( lastOperationId: operationId, governance: { ...previous.governance, - requirementsStatus: 'ready', + requirementsStatus: + requirements.status === 'confirmed' ? 'ready' : 'needs_clarification', requirementsDigest: requirements.contentDigest, reviewStatus: review.status, reviewLedgerDigest: review.contentDigest, diff --git a/src/context/resolver.ts b/src/context/resolver.ts index d1431ce..45baa70 100644 --- a/src/context/resolver.ts +++ b/src/context/resolver.ts @@ -15,6 +15,7 @@ import type { TaskAggregateManifestV1 } from './aggregate.js'; import { digestCanonicalJson } from './canonical.js'; import { type CompatibilityFailureCode, + type WriterCapability, evaluateCompatibilityGate, } from './compatibility.js'; import { @@ -69,7 +70,8 @@ export interface ContextResolverCompatibility { expectedSchemaEpoch: string; readerVersion: string; writerVersion: string; - adapterVersions: Record; + writerCapabilities: readonly WriterCapability[]; + adapterVersions: Partial>; } export interface ContextResolveRequest { @@ -192,24 +194,27 @@ export class ContextResolver { throw new Error('MANCODE_SESSION_NOT_FOUND'); } const requestedTask = resolveRequestedTask(request.taskRef, session); - const taskRef = (await this.store.locateTask(requestedTask)).taskRef; const intent = request.intent ?? 'read'; const maxReadAttempts = parseMaxReadAttempts(request.maxReadAttempts); + const preflight = await this.readCompatibilityPreflight(request, intent); + if (!preflight.readAllowed) { + throw new Error(preflight.failures[0] ?? 'MANCODE_CONTEXT_READ_BLOCKED'); + } + if (intent === 'mutate' && !preflight.writeAllowed) { + throw new Error(preflight.failures[0] ?? 'MANCODE_CONTEXT_WRITE_BLOCKED'); + } + const taskRef = (await this.store.locateTask(requestedTask)).taskRef; const snapshot = await this.readStableSnapshot( taskRef, request, maxReadAttempts, ); - const compatibility = evaluateCompatibilityGate({ - manifest: snapshot.project.manifest, - expectedSchemaEpoch: request.compatibility.expectedSchemaEpoch, - readerVersion: request.compatibility.readerVersion, - writerVersion: request.compatibility.writerVersion, - adapterVersions: request.compatibility.adapterVersions, - currentLegacyBaseline: snapshot.legacy.baseline, - legacyAuthorityPresent: snapshot.legacy.authorityPresent, - operation: intent === 'read' ? 'read' : 'v3_business_write', - }); + const compatibility = this.evaluateCompatibility( + request, + intent, + snapshot.project, + snapshot.legacy, + ); if (!compatibility.readAllowed) { throw new Error( compatibility.failures[0] ?? 'MANCODE_CONTEXT_READ_BLOCKED', @@ -252,6 +257,36 @@ export class ContextResolver { return resolution; } + private async readCompatibilityPreflight( + request: ContextResolveRequest, + intent: ContextResolutionIntent, + ) { + const [project, legacy] = await Promise.all([ + this.store.readProjectSnapshot(), + scanLegacyAuthority(this.store.projectRoot), + ]); + return this.evaluateCompatibility(request, intent, project, legacy); + } + + private evaluateCompatibility( + request: ContextResolveRequest, + intent: ContextResolutionIntent, + project: StoredProjectSnapshot, + legacy: LegacyAuthorityScan, + ) { + return evaluateCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: request.compatibility.expectedSchemaEpoch, + readerVersion: request.compatibility.readerVersion, + writerVersion: request.compatibility.writerVersion, + writerCapabilities: request.compatibility.writerCapabilities, + adapterVersions: request.compatibility.adapterVersions, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: intent === 'read' ? 'read' : 'v3_business_write', + }); + } + private async readStableSnapshot( taskRef: TaskRef, request: ContextResolveRequest, diff --git a/src/context/store.ts b/src/context/store.ts index b4a1531..88bc920 100644 --- a/src/context/store.ts +++ b/src/context/store.ts @@ -40,7 +40,7 @@ import { listConfirmedDecisions, } from './confirmed-decision.js'; import { assertUlid } from './ids.js'; -import { type SchemaManifestV1, parseSchemaManifest } from './manifest.js'; +import { type SchemaManifest, parseSchemaManifest } from './manifest.js'; import { type ParentSnapshotSource, parentSnapshotStaleReasons, @@ -88,7 +88,7 @@ export interface StoredTaskSnapshot { } export interface StoredProjectSnapshot { - manifest: SchemaManifestV1; + manifest: SchemaManifest; config: ProjectConfigV1; policy: TeamPolicyV1; projectFacts: ProjectFactsV1 | null; diff --git a/src/context/task-head-reconcile.ts b/src/context/task-head-reconcile.ts index 28b1a92..46ffbb8 100644 --- a/src/context/task-head-reconcile.ts +++ b/src/context/task-head-reconcile.ts @@ -39,7 +39,7 @@ import { taskAggregateDigest, } from './aggregate.js'; import { type Ulid, assertUlid, createUlid } from './ids.js'; -import type { SchemaManifestV1 } from './manifest.js'; +import type { SchemaManifest } from './manifest.js'; import { type StoredTaskSnapshot, V3ContextStore } from './store.js'; import { assertTaskCodeHeadUnchanged } from './task-mutation.js'; import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; @@ -434,7 +434,7 @@ async function assertRemoteTaskUnpublished(input: { projectRoot: string; taskRef: TaskRef; config: ProjectConfigV1; - manifest: SchemaManifestV1; + manifest: SchemaManifest; }): Promise { if (input.config.transport.mode !== 'git-ref') return; const snapshot = await createGitRefTeamManifestStore( diff --git a/src/context/workflow-create.ts b/src/context/workflow-create.ts index 558d440..80d7ad3 100644 --- a/src/context/workflow-create.ts +++ b/src/context/workflow-create.ts @@ -7,13 +7,18 @@ import { writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { inspectV3AdapterVersions } from '../installers/v3-adapter.js'; import { type EntityHomeStore, resolveTaskEntityHomeStore, } from '../runtime/entity-home-store.js'; -import { acquireEntityLocks } from '../runtime/local-lock.js'; +import { + type LocalLockHandle, + acquireEntityLocks, +} from '../runtime/local-lock.js'; import { armOperationCrashAfterVisibleWrite, + pauseIfOperationLockInjectedForTesting, throwIfDeferredOperationCrashInjected, throwIfOperationCrashInjected, } from '../runtime/operation-crash-injection.js'; @@ -41,6 +46,7 @@ import { readCheckoutCodeHead, readProjectRuntimeContext, } from '../runtime/project-runtime.js'; +import { acquireProjectWriteBarrier } from '../runtime/project-write-barrier.js'; import { completeProjectionIntent, enqueueSessionPointerProjection, @@ -65,13 +71,17 @@ import { taskAggregateDigest, } from './aggregate.js'; import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; -import { assertCompatibilityGate } from './compatibility.js'; +import { + CURRENT_WRITER_CAPABILITIES, + assertCompatibilityGate, +} from './compatibility.js'; import { type WorkflowCreationResolution, resolveWorkflowCreation, } from './creation-resolution.js'; import { type Ulid, assertUlid, createUlid } from './ids.js'; import { scanLegacyAuthority } from './layout.js'; +import { managedAdapterNames } from './manifest.js'; import type { ParentSnapshot } from './parent-snapshot.js'; import { type RequirementsLedgerV1, @@ -178,16 +188,21 @@ export async function createV3Workflow( const runtime = await readProjectRuntimeContext(projectRoot); const contextStore = new V3ContextStore(projectRoot); - const [project, legacy] = await Promise.all([ - contextStore.readProjectSnapshot(), + const project = await contextStore.readProjectSnapshot(); + const [legacy, adapterVersions] = await Promise.all([ scanLegacyAuthority(projectRoot), + inspectV3AdapterVersions( + projectRoot, + managedAdapterNames(project.manifest.managedAdapters), + ), ]); assertCompatibilityGate({ manifest: project.manifest, expectedSchemaEpoch: project.manifest.epoch, readerVersion: VERSION, writerVersion: VERSION, - adapterVersions: project.manifest.managedAdapters, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions, currentLegacyBaseline: legacy.baseline, legacyAuthorityPresent: legacy.authorityPresent, operation: 'v3_business_write', @@ -259,6 +274,12 @@ export async function createV3Workflow( ? 0 : 1, timestamp, + planningPolicyVersion: + parent === null + ? project.manifest.manifestVersion === 2 && workflowMode === 'man' + ? project.manifest.workflowPolicyDefaults.planning + : 1 + : parent.metadata.governance.policyVersions.planning, }); const homeStore = resolveTaskEntityHomeStore( runtime.entityHomeStoreContext, @@ -341,22 +362,34 @@ export async function createV3Workflow( assertOperationRecoveryPayloadCoversJournal(operation, recoveryPayload); assertOperationJournalMatchesDefinition(operation); - const taskParent = await ensureSafeTaskParent(projectRoot, taskRef); - const targetDirectory = path.join(taskParent, taskRef.taskId); - const stagingDirectory = path.join( - taskParent, - `.${taskRef.taskId}.${operationId}.staging`, - ); - const locks = await acquireEntityLocks( - homeStore, + const projectBarrier = await acquireProjectWriteBarrier( + runtime, operationId, - operation.entityLocks, - { now }, + now, ); + let projectBarrierReleased = false; + let locks: LocalLockHandle[] = []; + let stagingDirectory: string | null = null; let journal = operation; let journalCreated = false; let sessionProjectionId: string | null = null; try { + const lockedProject = await contextStore.readProjectSnapshot(); + if (lockedProject.fingerprint !== project.fingerprint) { + throw new Error('MANCODE_REVISION_CONFLICT'); + } + const taskParent = await ensureSafeTaskParent(projectRoot, taskRef); + const targetDirectory = path.join(taskParent, taskRef.taskId); + stagingDirectory = path.join( + taskParent, + `.${taskRef.taskId}.${operationId}.staging`, + ); + locks = await acquireEntityLocks( + homeStore, + operationId, + operation.entityLocks, + { now }, + ); if (taskRef.namespace === 'shared') { await assertTransportCoordinationWriteAllowed(homeStore, project.config); } @@ -396,7 +429,14 @@ export async function createV3Workflow( await writeOperationRecoveryPayload(homeStore, recoveryPayload); journal = await createPreparedOperationJournal(homeStore, journal); journalCreated = true; + await projectBarrier.release(); + projectBarrierReleased = true; throwIfOperationCrashInjected('workflow_create', 'prepared'); + const lockPause = pauseIfOperationLockInjectedForTesting( + operationId, + 'entity_locks_held', + ); + if (lockPause !== null) await lockPause; journal = await advanceJournal(homeStore, journal, 'validate', now, true); // Mark write intent before the visible effect. This makes a crash in the @@ -441,7 +481,7 @@ export async function createV3Workflow( try { if (hasBusinessWriteIntent(journal)) { await markRepairRequired(homeStore, journal, now); - } else { + } else if (stagingDirectory !== null) { await abortPreparedCreate(homeStore, journal, stagingDirectory, now); } } catch { @@ -451,9 +491,10 @@ export async function createV3Workflow( } throw error; } finally { - await Promise.allSettled( - [...locks].reverse().map((lock) => lock.release()), - ); + await Promise.allSettled([ + ...[...locks].reverse().map((lock) => lock.release()), + ...(projectBarrierReleased ? [] : [projectBarrier.release()]), + ]); } let sessionResumed = true; @@ -630,6 +671,7 @@ function buildInitialEntities(input: { explicitScope: WorkflowCreateScope | undefined; ownershipEpoch: number; timestamp: string; + planningPolicyVersion: number | null; }): InitialEntities { const scope = initialScope(input.parent, input.explicitScope); const requirementsDraft: RequirementsLedgerV1 = { @@ -739,7 +781,11 @@ function buildInitialEntities(input: { requirementsDigest: requirements.contentDigest, planVersion: 1, planDecision: null, - policyVersions: { planning: 1, review: 1, verification: 1 }, + policyVersions: { + planning: input.planningPolicyVersion, + review: 1, + verification: 1, + }, reviewStatus: review.status, reviewLedgerDigest: review.contentDigest, verificationStatus: verification.status, diff --git a/src/context/workflow-metadata.ts b/src/context/workflow-metadata.ts index 9368bea..609d096 100644 --- a/src/context/workflow-metadata.ts +++ b/src/context/workflow-metadata.ts @@ -49,6 +49,30 @@ export type PlanDecision = | 'governed_execution' | null; +export type WorkflowPolicyComponent = 'planning' | 'review' | 'verification'; + +export const SUPPORTED_WORKFLOW_POLICY_VERSIONS = { + planning: [1, 2], + review: [1, 2], + verification: [1], +} as const; + +export class WorkflowPolicyVersionUnsupportedError extends Error { + readonly code = 'MANCODE_POLICY_VERSION_UNSUPPORTED' as const; + + constructor( + readonly component: WorkflowPolicyComponent, + readonly observedVersion: unknown, + readonly supportedVersions: readonly number[], + readonly requiredWriter: string, + ) { + super( + `MANCODE_POLICY_VERSION_UNSUPPORTED: component=${component} observed=${String(observedVersion)} supported=${supportedVersions.join(',')} requiredWriter=${requiredWriter}`, + ); + this.name = 'WorkflowPolicyVersionUnsupportedError'; + } +} + export interface WorkflowMetadataV3 { schemaVersion: 3; taskRef: TaskRef; @@ -588,21 +612,33 @@ function parsePolicyVersions( 'workflow metadata policyVersions', ); return { - planning: parsePositiveIntegerOrNull( - value.planning, - 'workflow metadata planning policy version', - ), - review: parsePositiveIntegerOrNull( - value.review, - 'workflow metadata review policy version', - ), - verification: parsePositiveIntegerOrNull( - value.verification, - 'workflow metadata verification policy version', - ), + planning: parsePolicyVersionOrNull(value.planning, 'planning'), + review: parsePolicyVersionOrNull(value.review, 'review'), + verification: parsePolicyVersionOrNull(value.verification, 'verification'), }; } +function parsePolicyVersionOrNull( + value: unknown, + component: WorkflowPolicyComponent, +): number | null { + if (value === null) return null; + const supported = SUPPORTED_WORKFLOW_POLICY_VERSIONS[component]; + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + !supported.some((version) => version === value) + ) { + throw new WorkflowPolicyVersionUnsupportedError( + component, + value, + supported, + component === 'planning' && value === 2 ? '0.4.0' : '>0.4.0', + ); + } + return value; +} + function parsePlanDecision(value: unknown): PlanDecision { if (value === null) return null; if ( @@ -861,13 +897,6 @@ function parsePositiveInteger(value: unknown, label: string): number { return value; } -function parsePositiveIntegerOrNull( - value: unknown, - label: string, -): number | null { - return value === null ? null : parsePositiveInteger(value, label); -} - function parseNonNegativeInteger(value: unknown, label: string): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { throw new Error(`${label} must be a non-negative integer`); diff --git a/src/installers/adapter-upgrade.ts b/src/installers/adapter-upgrade.ts new file mode 100644 index 0000000..89b2aa3 --- /dev/null +++ b/src/installers/adapter-upgrade.ts @@ -0,0 +1,835 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { assertCompatibilityGate } from '../context/compatibility.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { scanLegacyAuthority } from '../context/layout.js'; +import { + type SchemaManifest, + assertSchemaManifestTransition, + managedAdapterNames, + parseSchemaManifest, + serializeSchemaManifest, +} from '../context/manifest.js'; +import { V3ContextStore } from '../context/store.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { resolveLocalEntityHomeStore } from '../runtime/entity-home-store.js'; +import { + type LocalLockHandle, + acquireOperationEntityLocks, +} from '../runtime/local-lock.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { assertOperationJournalMatchesDefinition } from '../runtime/operation-definition.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { listUnfinishedOperationRecoveries } from '../runtime/operation-recovery-executor.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createProjectAuthorityFileRecoveryAction, + createV3AdapterFileRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../runtime/operation-recovery-store.js'; +import { + createPreparedOperationJournal, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { type SessionStateV1, readSession } from '../runtime/session.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { VERSION } from '../version.js'; +import type { PlatformName } from './registry.js'; +import { + type V3AdapterContentStatus, + type V3AdapterFilePlan, + type V3AdapterManagedTargetStatus, + type V3PlatformAdapterStatus, + V3_ADAPTER_PLATFORMS, + V3_ADAPTER_VERSION, + adapterManagedContentDigest, + applyV3AdapterFilePlan, + inspectV3Adapter, + planV3AdapterUpgradeFiles, + stageV3AdapterUpgradeFiles, +} from './v3-adapter.js'; + +export interface AdapterUpgradeInput { + projectRoot: string; + platforms: readonly PlatformName[]; + dryRun?: boolean; + explicitConfirmation?: boolean; + sessionId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface AdapterUpgradeChange { + platform: PlatformName; + identity: string; + target: string; + status: V3AdapterContentStatus; + actualDigest: string | null; + expectedDigest: string; + rendererVersion: string; + repair: string; +} + +export interface AdapterUpgradeResult { + schemaVersion: 1; + operation: 'adapter_upgrade'; + operationId: Ulid; + platforms: PlatformName[]; + dryRun: boolean; + state: 'preview' | 'already_ready' | 'committed'; + changes: AdapterUpgradeChange[]; + filePlans: Array<{ + target: string; + beforeDigest: string | null; + targetDigest: string; + }>; + stagedTargets: string[]; + manifest: { + changed: boolean; + beforeVersion: 1 | 2; + targetVersion: 1 | 2; + }; + status: Record; + journal: OperationJournalV1 | null; +} + +interface AdapterUpgradePreviewV1 { + schemaVersion: 1; + operationId: Ulid; + platforms: PlatformName[]; + projectFingerprint: string; + plansDigest: string; + manifestTargetDigest: string; + stagedTargets: string[]; + createdAt: string; +} + +/** Performs a read-only preview or journaled local adapter repair. */ +export async function upgradeV3Adapters( + input: AdapterUpgradeInput, +): Promise { + const root = path.resolve(input.projectRoot); + const platforms = normalizePlatforms(input.platforms); + const operationIdProvided = input.operationId !== undefined; + const operationId = + input.operationId ?? createUlid((input.now ?? new Date()).getTime()); + assertUlid(operationId, 'adapter upgrade operationId'); + const now = input.now ?? new Date(); + const dryRun = input.dryRun === true; + const projectRuntime = await readProjectRuntimeContext(root); + const store = new V3ContextStore(root); + const [project, legacy, unfinished] = await Promise.all([ + store.readProjectSnapshot(), + scanLegacyAuthority(root), + listUnfinishedOperationRecoveries(root), + ]); + if (unfinished.length > 0) { + throw new Error('MANCODE_ADAPTER_UPGRADE_OPERATION_PENDING'); + } + const actualVersions = await import('./v3-adapter.js').then( + ({ inspectV3AdapterVersions }) => + inspectV3AdapterVersions( + root, + managedAdapterNames(project.manifest.managedAdapters), + ), + ); + assertCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + writerCapabilities: [ + 'planning-policy:1', + 'planning-policy:2', + 'adapter-digest:1', + 'reframe-local:1', + ], + adapterVersions: actualVersions, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'adapter_upgrade', + }); + const statusEntries = await Promise.all( + platforms.map( + async (platform) => + [platform, await inspectV3Adapter(root, platform)] as const, + ), + ); + const status = Object.fromEntries(statusEntries) as Record< + PlatformName, + V3PlatformAdapterStatus + >; + const changes = statusEntries.flatMap(([platform, adapter]) => + adapter.targets + .filter((target) => target.status !== 'ready') + .map((target) => changeFor(platform, target)), + ); + const plans = await planV3AdapterUpgradeFiles(root, platforms); + const manifestTarget = targetManifest( + project.manifest, + platforms, + operationId, + ); + const manifestChanged = manifestTarget !== null; + const previewCandidate = adapterUpgradePreview({ + operationId, + platforms, + projectFingerprint: project.fingerprint, + plans, + manifestTarget: manifestTarget ?? project.manifest, + stagedTargets: [], + now, + }); + let stagedTargets: string[] = []; + if (dryRun) { + const existingPreview = await readAdapterUpgradePreview(root, operationId); + if (existingPreview === null) { + const staged = await stageV3AdapterUpgradeFiles(root, operationId, plans); + stagedTargets = staged.map((item) => item.stagingTarget); + await writeAdapterUpgradePreview(root, { + ...previewCandidate, + stagedTargets, + }); + } else { + assertAdapterUpgradePreviewMatches(existingPreview, previewCandidate); + await assertStagedAdapterFiles(root, existingPreview, plans); + stagedTargets = existingPreview.stagedTargets; + } + } + const resultBase = { + schemaVersion: 1 as const, + operation: 'adapter_upgrade' as const, + operationId, + platforms, + dryRun, + changes, + filePlans: plans.map((plan) => ({ + target: plan.target, + beforeDigest: + plan.beforeContent === null + ? null + : adapterManagedContentDigest(plan.target, plan.beforeContent), + targetDigest: adapterManagedContentDigest( + plan.target, + plan.targetContent, + ), + })), + stagedTargets, + manifest: { + changed: manifestChanged, + beforeVersion: project.manifest.manifestVersion, + targetVersion: + manifestTarget?.manifestVersion ?? project.manifest.manifestVersion, + }, + status, + }; + if (dryRun) { + return { ...resultBase, state: 'preview', journal: null }; + } + if (plans.length === 0 && !manifestChanged) { + return { ...resultBase, state: 'already_ready', journal: null }; + } + if (input.explicitConfirmation !== true) { + throw new Error('MANCODE_EXPLICIT_CONFIRMATION_REQUIRED'); + } + if (!operationIdProvided) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_REQUIRED'); + } + if (input.sessionId === undefined) { + throw new Error('MANCODE_SESSION_REQUIRED'); + } + assertUlid(input.sessionId, 'adapter upgrade sessionId'); + const session = await readSession(root, input.sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const preview = await readAdapterUpgradePreview(root, operationId); + if (preview === null) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_REQUIRED'); + } + assertAdapterUpgradePreviewMatches(preview, previewCandidate); + await assertStagedAdapterFiles(root, preview, plans); + resultBase.stagedTargets = preview.stagedTargets; + const journalResult = await commitAdapterUpgrade({ + root, + runtime: projectRuntime, + project, + platforms, + plans, + manifestTarget, + session, + operationId, + now, + }); + const finalEntries = await Promise.all( + platforms.map( + async (platform) => + [platform, await inspectV3Adapter(root, platform)] as const, + ), + ); + return { + ...resultBase, + state: 'committed', + status: Object.fromEntries(finalEntries) as Record< + PlatformName, + V3PlatformAdapterStatus + >, + journal: journalResult, + }; +} + +async function commitAdapterUpgrade(input: { + root: string; + runtime: Awaited>; + project: Awaited>; + platforms: PlatformName[]; + plans: V3AdapterFilePlan[]; + manifestTarget: SchemaManifest | null; + session: SessionStateV1; + operationId: Ulid; + now: Date; +}): Promise { + const localStore = resolveLocalEntityHomeStore( + input.runtime.entityHomeStoreContext, + ); + const adapterTargets = new Set(); + for (const platform of input.platforms) { + adapterTargets.add(`adapter:${primaryTargetForPlatform(platform)}`); + for (const mode of ['manba', 'man', 'manteam', 'manps', 'mansolo']) { + adapterTargets.add(`adapter:${modeTargetForPlatform(platform, mode)}`); + } + } + for (const plan of input.plans) adapterTargets.add(`adapter:${plan.target}`); + const entityLocks = [...adapterTargets].sort(compareUtf8); + const expectedRevisions: Record = Object.fromEntries( + entityLocks.map((key) => [key, 0]), + ); + if (input.manifestTarget !== null) { + entityLocks.push('schema:project'); + entityLocks.sort(compareUtf8); + expectedRevisions['schema:project'] = + input.project.manifest.manifestVersion; + } + const authorizationBasis = createAuthorizationBasis( + { + action: 'project_maintenance', + actorId: input.session.actorId, + session: { + sessionId: input.session.sessionId, + actorId: input.session.actorId, + status: input.session.status, + }, + joined: false, + sharedWriteGuard: capabilitiesFromProjectConfig(input.project.config) + .writeGuard, + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + explicitConfirmation: true, + }, + }, + input.now, + ); + const manifestAction = + input.manifestTarget === null + ? null + : createProjectAuthorityFileRecoveryAction({ + stepId: 'update-adapter-inventory', + fileName: 'schema.json', + beforeContent: serializeSchemaManifest(input.project.manifest), + targetContent: serializeSchemaManifest(input.manifestTarget), + }); + const payload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId: input.operationId, + type: 'adapter_upgrade', + primaryStoreId: localStore.storeId, + actions: [ + ...input.plans.map((plan) => + createV3AdapterFileRecoveryAction({ + stepId: 'replace-managed-adapters', + target: plan.target, + beforeContent: plan.beforeContent, + targetContent: plan.targetContent, + }), + ), + ...(manifestAction === null ? [] : [manifestAction]), + ], + noOpStepIds: [ + ...(input.plans.length === 0 ? ['replace-managed-adapters'] : []), + ...(manifestAction === null ? ['update-adapter-inventory'] : []), + ], + }); + let journal: OperationJournalV1 = { + schemaVersion: 1, + operationId: input.operationId, + type: 'adapter_upgrade', + state: 'prepared', + primaryStoreId: localStore.storeId, + checkoutId: input.runtime.checkoutId, + secondaryReservations: [], + actorId: input.session.actorId, + sessionId: input.session.sessionId, + authorizationBasis, + recoveryPayloadDigest: operationRecoveryPayloadDigest(payload), + entityLocks, + expectedRevisions, + steps: [ + 'validate', + 'replace-managed-adapters', + 'update-adapter-inventory', + 'verify', + 'commit', + ].map((id) => ({ id, state: 'pending' as const })), + startedAt: input.now.toISOString(), + updatedAt: input.now.toISOString(), + }; + assertOperationJournalMatchesDefinition(journal); + assertOperationRecoveryPayloadCoversJournal(journal, payload); + const locks = await acquireOperationEntityLocks( + input.operationId, + [{ store: localStore, entityLockKeys: entityLocks }], + { now: input.now }, + ); + let durable = false; + try { + const lockedProject = await new V3ContextStore( + input.root, + ).readProjectSnapshot(); + const lockedPlans = await planV3AdapterUpgradeFiles( + input.root, + input.platforms, + ); + if ( + lockedProject.fingerprint !== input.project.fingerprint || + JSON.stringify(lockedPlans) !== JSON.stringify(input.plans) + ) { + throw new Error('MANCODE_REVISION_CONFLICT'); + } + await writeOperationRecoveryPayload(localStore, payload); + await createPreparedOperationJournal(localStore, journal); + durable = true; + throwIfOperationCrashInjected('adapter_upgrade', 'prepared'); + journal = await completeAdapterStep( + localStore, + journal, + 'validate', + input.now, + ); + journal = await completeAdapterStep( + localStore, + journal, + 'replace-managed-adapters', + input.now, + ); + for (const plan of input.plans) { + await renewLocks(locks); + throwIfOperationCrashInjected( + 'adapter_upgrade', + `replace-managed-adapters:before:${plan.target}`, + ); + await applyV3AdapterFilePlan(input.root, plan); + throwIfOperationCrashInjected( + 'adapter_upgrade', + `replace-managed-adapters:${plan.target}`, + ); + } + journal = await completeAdapterStep( + localStore, + journal, + 'update-adapter-inventory', + input.now, + ); + if (input.manifestTarget !== null) { + await writeSchemaManifest(input.root, input.manifestTarget); + } + journal = await completeAdapterStep( + localStore, + journal, + 'verify', + input.now, + ); + const verified = await Promise.all( + input.platforms.map((platform) => inspectV3Adapter(input.root, platform)), + ); + if (verified.some((status) => !status.ready)) { + throw new Error('MANCODE_ADAPTER_CONTENT_STALE'); + } + if (input.manifestTarget !== null) { + const after = await new V3ContextStore(input.root).readProjectSnapshot(); + if (after.manifest.lastOperationId !== input.operationId) { + throw new Error('MANCODE_ADAPTER_MANIFEST_VERIFY_FAILED'); + } + } + journal = await completeAdapterStep( + localStore, + journal, + 'commit', + input.now, + true, + ); + return journal; + } catch (error) { + if ( + durable && + journal.state !== 'committed' && + journal.state !== 'aborted' + ) { + const hasWriteIntent = journal.steps.some((step) => + step.id === 'replace-managed-adapters' || + step.id === 'update-adapter-inventory' + ? step.state === 'completed' + : false, + ); + await updateOperationJournal( + localStore, + { + ...journal, + state: hasWriteIntent ? 'repair_required' : 'aborted', + updatedAt: input.now.toISOString(), + }, + { canAbort: !hasWriteIntent }, + ).catch(() => undefined); + } + throw error; + } finally { + await Promise.allSettled(locks.map((lock) => lock.release())); + } +} + +async function completeAdapterStep( + store: ReturnType, + previous: OperationJournalV1, + stepId: string, + now: Date, + commit = false, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + const index = previous.steps.findIndex((step) => step.id === stepId); + if (index < 0 || previous.steps[index]?.state === 'completed') { + throw new Error('MANCODE_OPERATION_STEP_INVALID'); + } + if ( + previous.steps.slice(0, index).some((step) => step.state !== 'completed') + ) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + const next: OperationJournalV1 = { + ...previous, + state: commit ? 'committed' : 'applying', + steps: previous.steps.map((step, current) => + current === index ? { ...step, state: 'completed' as const } : step, + ), + updatedAt: now.toISOString(), + }; + const updated = await updateOperationJournal(store, next, { + canAbort: !commit && index === 0, + }); + if (commit) { + throwIfOperationCrashInjected(previous.type, 'commit'); + } else { + const definitionWrite = + stepId === 'replace-managed-adapters' || + stepId === 'update-adapter-inventory'; + if (definitionWrite) { + armOperationCrashAfterVisibleWrite(previous.type, stepId); + } else { + throwIfOperationCrashInjected(previous.type, stepId); + } + } + return updated; +} + +function targetManifest( + manifest: SchemaManifest, + platforms: readonly PlatformName[], + operationId: Ulid, +): SchemaManifest | null { + const managedAdapters = { ...manifest.managedAdapters }; + let changed = false; + for (const platform of platforms) { + if (managedAdapters[platform] !== V3_ADAPTER_VERSION) { + managedAdapters[platform] = V3_ADAPTER_VERSION; + changed = true; + } + } + if (!changed) return null; + const next = parseSchemaManifest({ + ...manifest, + managedAdapters, + lastOperationId: operationId, + }); + assertSchemaManifestTransition(manifest, next); + return next; +} + +function changeFor( + platform: PlatformName, + target: V3AdapterManagedTargetStatus, +): AdapterUpgradeChange { + return { + platform, + identity: target.identity, + target: target.target, + status: target.status, + actualDigest: target.actualDigest, + expectedDigest: target.expectedDigest, + rendererVersion: target.rendererVersion, + repair: target.repair, + }; +} + +function normalizePlatforms( + platforms: readonly PlatformName[], +): PlatformName[] { + if (!Array.isArray(platforms) || platforms.length === 0) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PLATFORM_REQUIRED'); + } + const set = new Set(platforms); + if ( + [...set].some( + (platform) => !V3_ADAPTER_PLATFORMS.includes(platform as PlatformName), + ) + ) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PLATFORM_INVALID'); + } + return V3_ADAPTER_PLATFORMS.filter((platform) => set.has(platform)); +} + +function primaryTargetForPlatform(platform: PlatformName): string { + return platform === 'claude-code' + ? 'claude-skill' + : platform === 'cursor' + ? 'cursor-rule' + : platform === 'copilot' + ? 'copilot-instructions' + : 'agents'; +} + +function modeTargetForPlatform(platform: PlatformName, mode: string): string { + const family = + platform === 'claude-code' + ? 'claude' + : platform === 'codex' || platform === 'zcode' + ? 'agents' + : platform; + return `${family}-mode-${mode}`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +async function renewLocks(locks: LocalLockHandle[]): Promise { + await Promise.all(locks.map((lock) => lock.renew())); +} + +async function writeSchemaManifest( + projectRoot: string, + manifest: SchemaManifest, +): Promise { + const target = path.join(projectRoot, '.mancode', 'schema.json'); + const temporary = path.join( + path.dirname(target), + `.schema.adapter-upgrade.${process.pid}.${Date.now()}.tmp`, + ); + try { + await writeFile(temporary, serializeSchemaManifest(manifest), { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +function adapterUpgradePreview(input: { + operationId: Ulid; + platforms: PlatformName[]; + projectFingerprint: string; + plans: V3AdapterFilePlan[]; + manifestTarget: SchemaManifest; + stagedTargets: string[]; + now: Date; +}): AdapterUpgradePreviewV1 { + return { + schemaVersion: 1, + operationId: input.operationId, + platforms: [...input.platforms], + projectFingerprint: input.projectFingerprint, + plansDigest: digestCanonicalJson(input.plans), + manifestTargetDigest: digestCanonicalJson(input.manifestTarget), + stagedTargets: [...input.stagedTargets], + createdAt: input.now.toISOString(), + }; +} + +function adapterUpgradePreviewPath(root: string, operationId: Ulid): string { + return path.join( + root, + '.mancode', + 'staging', + 'adapters', + 'upgrade', + operationId, + 'preview.json', + ); +} + +async function readAdapterUpgradePreview( + root: string, + operationId: Ulid, +): Promise { + const target = adapterUpgradePreviewPath(root, operationId); + try { + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + return parseAdapterUpgradePreview( + JSON.parse(await readFile(target, 'utf8')), + ); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + throw error; + } +} + +async function writeAdapterUpgradePreview( + root: string, + preview: AdapterUpgradePreviewV1, +): Promise { + const target = adapterUpgradePreviewPath(root, preview.operationId); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, `${JSON.stringify(preview, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); +} + +function parseAdapterUpgradePreview(value: unknown): AdapterUpgradePreviewV1 { + assertRecord(value, 'adapter upgrade preview'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'platforms', + 'projectFingerprint', + 'plansDigest', + 'manifestTargetDigest', + 'stagedTargets', + 'createdAt', + ], + 'adapter upgrade preview', + ); + if (value.schemaVersion !== 1) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + assertUlid(value.operationId, 'adapter upgrade preview operationId'); + const platforms = normalizePlatforms( + Array.isArray(value.platforms) ? (value.platforms as PlatformName[]) : [], + ); + if ( + !isDigest(value.projectFingerprint) || + !isDigest(value.plansDigest) || + !isDigest(value.manifestTargetDigest) || + !Array.isArray(value.stagedTargets) || + value.stagedTargets.some( + (target) => typeof target !== 'string' || !target.trim(), + ) || + typeof value.createdAt !== 'string' || + !Number.isFinite(Date.parse(value.createdAt)) + ) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + return { + schemaVersion: 1, + operationId: value.operationId, + platforms, + projectFingerprint: value.projectFingerprint, + plansDigest: value.plansDigest, + manifestTargetDigest: value.manifestTargetDigest, + stagedTargets: [...value.stagedTargets] as string[], + createdAt: value.createdAt, + }; +} + +function assertAdapterUpgradePreviewMatches( + preview: AdapterUpgradePreviewV1, + candidate: AdapterUpgradePreviewV1, +): void { + if ( + preview.operationId !== candidate.operationId || + JSON.stringify(preview.platforms) !== JSON.stringify(candidate.platforms) || + preview.projectFingerprint !== candidate.projectFingerprint || + preview.plansDigest !== candidate.plansDigest || + preview.manifestTargetDigest !== candidate.manifestTargetDigest + ) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_STALE'); + } +} + +async function assertStagedAdapterFiles( + root: string, + preview: AdapterUpgradePreviewV1, + plans: readonly V3AdapterFilePlan[], +): Promise { + if (preview.stagedTargets.length !== plans.length) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + for (const [index, plan] of plans.entries()) { + const relative = preview.stagedTargets[index]; + if (relative === undefined) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + const target = path.resolve(root, relative); + if (!target.startsWith(`${root}${path.sep}`)) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_INVALID'); + } + if ((await readFile(target, 'utf8')) !== plan.targetContent) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PREVIEW_STALE'); + } + } +} + +function isDigest(value: unknown): value is string { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/src/installers/claude-code.ts b/src/installers/claude-code.ts index d09b0f9..ba98f21 100644 --- a/src/installers/claude-code.ts +++ b/src/installers/claude-code.ts @@ -46,7 +46,7 @@ const LEGACY_MVP2_SKILL_NAMES = ['mamba', 'man8'] as const; /** * Claude Code 平台安装器。 * - * 职责(docs/08-cli-spec.md §2.4): + * 职责见 docs/platform-adapters.md: * 1. 创建/修复平台无关的 .mancode/ 文件(通过 installMancodeCore) * 2. 创建 .claude/settings.json(幂等合并,不覆盖用户已有配置) * 3. 创建 .claude/skills//SKILL.md @@ -163,7 +163,7 @@ async function installSoloSkill( } /** - * 写入 MVP-2 skill 目录(/man /manba /mansolo,docs/03)。 + * 写入 mode skill 目录(/man /manba /mansolo,见 docs/workflows.md)。 * * 目录:.claude/skills//SKILL.md(Claude Code 通过目录名识别命令)。 * `--force` 重装时只覆盖可识别的 mancode 生成文件。 diff --git a/src/installers/cursor.ts b/src/installers/cursor.ts index b2324d6..197ae06 100644 --- a/src/installers/cursor.ts +++ b/src/installers/cursor.ts @@ -262,7 +262,8 @@ function renderSoloRule(): string { '- Read `.mancode/project-profile.json` before choosing tools or validation. For detected UI assets and UI work, read `.mancode/aesthetics/style-tokens.json` and inspect existing components before inventing new styles.', '- Verify with the narrowest meaningful test, lint, build, or smoke check.', '- Perform one bounded self-check limited to the current diff. Do not start another reviewer, create a review artifact, or repeat the review.', - '- Recommend /man only for auth, payment, sensitive data, migrations/deletion, public APIs, untrusted input, concurrency, or infrastructure risk.', + '- Recommend /man and explain why when platform entry/flow differs, the semantic owner or source of truth is unclear, status/contract/policy semantics change, scope/architecture/cost/acceptance crosses files or modules, or historical compatibility, migration, cross-platform, or team evidence is required. Auth, payment, sensitive data, deletion, public APIs, untrusted input, concurrency, and infrastructure remain hard-risk signals. Advice alone never changes mode, step, policy, or authority.', + '- While executing confirmed requirements/plan, new evidence that invalidates its goal, owner, source of truth, acceptance, or scope, or a stale adapter/incompatible writer/unfinished operation/active child/open handoff/active solo assignment, requires the read-only diagnostic `NEEDS_REALIGNMENT` with reason `MANCODE_REFRAME_REQUIRED`. Preserve authority and do not call generic workflow update or modify metadata, requirements, plan, ledgers, claims, or handoffs.', ].join('\n'); } diff --git a/src/installers/managed-block.ts b/src/installers/managed-block.ts index 09894f3..3e7f4ed 100644 --- a/src/installers/managed-block.ts +++ b/src/installers/managed-block.ts @@ -29,6 +29,26 @@ export function hasManagedBlock( return start !== null && end !== null && end.start > start.start; } +/** Returns the exact managed block bytes-as-text, including both markers. */ +export function extractManagedBlock( + existing: string, + startMarker = DEFAULT_MANCODE_START_MARKER, + endMarker = DEFAULT_MANCODE_END_MARKER, +): string | null { + const starts = findMarkerLines(existing, startMarker); + const ends = findMarkerLines(existing, endMarker); + if (starts.length === 0 && ends.length === 0) return null; + if (starts.length !== 1 || ends.length !== 1) { + throw new Error('managed block is malformed: marker count is invalid'); + } + const start = starts[0]; + const end = ends[0]; + if (!start || !end || end.start < start.start) { + throw new Error('managed block is malformed: marker order is invalid'); + } + return existing.slice(start.start, end.end); +} + function cleanUpOrphanedNewlines(content: string): string { const trimmed = content.replace(/\n{3,}/gu, '\n\n').replace(/\n+$/u, '\n'); return trimmed || ''; @@ -92,8 +112,16 @@ function findMarkerLine( content: string, marker: string, ): { start: number; end: number } | null { + return findMarkerLines(content, marker)[0] ?? null; +} + +function findMarkerLines( + content: string, + marker: string, +): Array<{ start: number; end: number }> { let offset = 0; let inFence: { char: '`' | '~'; length: number } | null = null; + const matches: Array<{ start: number; end: number }> = []; for (const lineWithBreak of content.matchAll(/[^\n]*(?:\n|$)/gu)) { const rawLine = lineWithBreak[0]; @@ -109,14 +137,13 @@ function findMarkerLine( inFence = null; } } else if (!inFence && line === marker) { - return { + matches.push({ start: offset, end: offset + marker.length, - }; + }); } offset += rawLine.length; } - - return null; + return matches; } diff --git a/src/installers/mode-skills.ts b/src/installers/mode-skills.ts index 870f971..fb8847d 100644 --- a/src/installers/mode-skills.ts +++ b/src/installers/mode-skills.ts @@ -508,9 +508,9 @@ const MODE_META: Record = { 'After each completed phase, advance through the CLI to the next numbered step;', 'never advance state merely because a report was requested or an agent was called.', '', - 'Step 1 — Scout: inspect the profile, existing implementation, dependencies, risks, and unknowns; write `scout-report.md`.', + 'Step 1 — Scout: inspect the profile, existing implementation, dependencies, risks, and unknowns; write `scout-report.md`. When relevant and verifiable, include Current Behavior Evidence, Candidate Semantic Owner with confidence/conflict, Source of Truth split into authority/readers/writer/derived copies, and Historical / Compatibility Impact for existing workflows, legacy fixtures, transport, migration, and rollback. Never invent fields to fill the template.', 'Step 2 — Clarify by readiness, not a fixed round or question count. Ask every decision-changing question that cannot be resolved from project facts; use as many batches as needed without repeating confirmed answers. Classify unknowns as blocking, recommendable, or defaultable. When a good approach exists, present 2–3 options and make a clear recommendation. Create structured input with non-empty scope, exclusions, technical decisions, defaults, blocking unknowns, stable acceptance IDs, and explicit confirmed/defaulted/not-applicable coverage for platform, core scope, stack, data, performance, compatibility, and security. Run `mancode workflow requirements finalize --file `; only advance to Step 3 when the CLI returns ready.', - 'Step 3 — Plan Coach first returns READY_FOR_PLAN or NEEDS_CLARIFICATION. Never hide blocking gaps inside assumptions. When ready, write `plan.md` with confirmed scope, technical choices and reasons, reuse, exclusions, behavior, risks/rollback, completion criteria, defaults, and real validation. First plan is v1; revisions use `--plan-version `.', + 'Step 3 — Plan Coach first returns READY_FOR_PLAN or NEEDS_CLARIFICATION. Never hide blocking gaps inside assumptions. Every option must solve the same goal, acceptance boundary, and scope; name its complexity bearer and observable cost; return exactly one recommendation with rejection reasons and stop conditions. A simple task may have one real direction and must not manufacture alternatives. When ready, write `plan.md` with confirmed scope, technical choices and reasons, reuse, exclusions, behavior, risks/rollback, completion criteria, defaults, and real validation. For cross-platform entry differences, unclear owner/source of truth, contract/state changes, cross-workflow/team/transport work, or multi-version compatibility, add an optional Domain Matrix inside plan.md; it is not authority. First plan is v1; revisions use `--plan-version `.', 'Step 4 — Plan gate: recommend based on risk and let the user choose (1) hand the confirmed plan to lightweight solo via `mancode workflow handoff --to solo`, (2) continue full governed execution via `mancode workflow decide --plan-decision governed_execution` before Step 5, (3) keep the plan only via `mancode workflow decide --plan-decision plan_only`, or (4) revise. Do not implement before this choice. Before either execution path, echo the plan version, delivery, stack, inclusions, exclusions, validation, and residual assumptions.', 'Step 5 — Implement the confirmed minimum change.', 'Step 6 — Run `workflow verify init`, execute detected build/lint/typecheck/test and smoke checks, and record every required acceptance ID with reproducible evidence; automated passed/failed records include the command and exit code. Use require-manual when a foreground browser, device, or human judgment is necessary; stop for explicit user confirmation before confirm-manual. The CLI blocks Step 7 and review until all checks pass. Then write `review-scope.md` and initialize targeted or full review. An explicit user review skip uses `workflow review skip --reason ` at Step 6, never generic skipped metadata.', @@ -518,6 +518,8 @@ const MODE_META: Record = { 'Step 8 — Only full review runs the security/boundary reviewer. It must read Film #1, mark the same root cause duplicate, and stay within security, permissions, recovery, resources, and boundaries. A targeted review treats the second domain as not applicable; it is never recorded as a skipped step.', 'Step 9 — If open blockers exist, fix them in one remediation round and record resolved IDs through `workflow review ... remediate`; remediation invalidates all earlier acceptance evidence, so re-run and re-record every required check at Step 9 without re-running completed reviewers. Write `summary.md` and set completed only when verification and required review domains are complete and blockers are zero.', '', + 'Stop and return the read-only diagnostic `NEEDS_REALIGNMENT` with reason `MANCODE_REFRAME_REQUIRED` when new evidence invalidates the confirmed goal, semantic owner, source of truth, or acceptance; platform entries would produce different semantics; status/contract/policy/transition meaning must change; an adapter is stale, the writer is incompatible, an operation is unfinished, an active child/open handoff/active solo assignment exists; or the change exceeds the current requirements/plan scope. Preserve metadata, requirements, plan, ledgers, claims, and handoffs. Do not call generic workflow update, write blocked/currentStep/planning, archive files, release claims, or claim to be back at Step 2.', + '', 'A solo handoff keeps the workflow planned and stores activeSoloPlan in state; solo reads requirements.md and plan.md, implements the confirmed scope with narrow validation and one bounded self-check, and does not run the /man reviewers. After successful solo verification, run `mancode workflow handoff --complete` to atomically clear the pointer, complete the workflow at Step 4, and remove it from Active Plans. After plan-only/completed/abandoned, return state to solo. On blocked, preserve enough state to resume. A CLI rejection is a hard stop, never a reason to bypass metadata validation.', ].join('\n'), }, @@ -578,6 +580,8 @@ const MODE_META: Record = { '- Reuse existing functions, components, styles, and dependencies.', '- Read project-profile first; for detected UI assets and UI work, read `.mancode/aesthetics/style-tokens.json`.', '- Verify with the narrowest meaningful test, lint, build, or smoke check.', + '- Recommend man and explain why when platform entry/flow differs, the semantic owner or source of truth is unclear, status/contract/policy semantics change, scope/architecture/cost/acceptance crosses files or modules, or historical compatibility, migration, cross-platform, or team evidence is required. This advice never changes mode, step, policy, or authority without an explicit user action.', + '- While executing an existing requirements/plan, if new evidence invalidates its goal, owner, source of truth, acceptance, or scope, or a stale adapter/incompatible writer/unfinished operation/active child/open handoff/active solo assignment is found, stop with `NEEDS_REALIGNMENT` and reason `MANCODE_REFRAME_REQUIRED`. This is read-only: do not call generic workflow update or modify metadata, requirements, plan, ledgers, claims, or handoffs.', ].join('\n'), }, }; diff --git a/src/installers/shared-content.ts b/src/installers/shared-content.ts index 5ac2b16..44e69d1 100644 --- a/src/installers/shared-content.ts +++ b/src/installers/shared-content.ts @@ -116,7 +116,9 @@ function renderPracticeRules(): string { '', 'For every task, consider: why this change, what already exists, and what is the smallest useful diff?', '', - 'In solo mode, use the narrowest meaningful validation and one bounded self-check limited to the current diff. Do not start another reviewer or repeat the review. Only recommend man for auth, payment, sensitive data, migrations/deletion, public APIs, untrusted input, concurrency, or infrastructure risk.', + 'In solo mode, use the narrowest meaningful validation and one bounded self-check limited to the current diff. Do not start another reviewer or repeat the review.', + 'Recommend man and explain the trigger when platform entry/flow differs, the semantic owner or source of truth is unclear, status/contract/policy semantics change, scope/architecture/cost/acceptance crosses files or modules, or historical compatibility, migration, cross-platform, or team evidence is required. Auth, payment, sensitive data, deletion, public APIs, untrusted input, concurrency, and infrastructure remain hard-risk signals. Advice alone never changes mode, step, policy, or authority.', + 'While executing confirmed requirements/plan, new evidence that invalidates its goal, owner, source of truth, acceptance, or scope, or a stale adapter/incompatible writer/unfinished operation/active child/open handoff/active solo assignment, requires the read-only diagnostic `NEEDS_REALIGNMENT` with reason `MANCODE_REFRAME_REQUIRED`. Preserve metadata, requirements, plan, ledgers, claims, and handoffs; do not call generic workflow update, write blocked/currentStep/planning, archive files, release claims, or claim to be back at Step 2.', ].join('\n'); } diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts index c9b2cce..f44f285 100644 --- a/src/installers/v3-adapter.ts +++ b/src/installers/v3-adapter.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { lstat, mkdir, @@ -8,7 +9,9 @@ import { writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { TextDecoder } from 'node:util'; import { + extractManagedBlock, hasManagedBlock, removeManagedBlock, replaceManagedBlock, @@ -23,11 +26,18 @@ import type { PlatformName } from './registry.js'; export const V3_ADAPTER_VERSION = '3'; export const V3_ADAPTER_MANAGED_MARKER = - ''; + ''; export const V3_MODE_ENTRY_MANAGED_MARKER = + ''; + +const LEGACY_V3_ADAPTER_MANAGED_MARKER = + ''; +const LEGACY_V3_MODE_ENTRY_MANAGED_MARKER = ''; +export const V3_ADAPTER_DIGEST_DOMAIN = 'mancode-adapter-digest-v1'; + export const V3_MODE_NAMES = [ 'manba', 'man', @@ -39,6 +49,7 @@ export const V3_MODE_NAMES = [ export type V3ModeName = (typeof V3_MODE_NAMES)[number]; const LEGACY_MODE_ENTRY_MANAGED_MARKERS = [ + LEGACY_V3_MODE_ENTRY_MANAGED_MARKER, '', '', '', @@ -65,12 +76,27 @@ const LEGACY_CLAUDE_SETTINGS_RAW_HINTS = [ ...LEGACY_CLAUDE_SKILL_PATHS, ] as const; -const V3_CODEX_START_MARKER = ''; -const V3_CODEX_END_MARKER = ''; -const V3_ZCODE_START_MARKER = ''; -const V3_ZCODE_END_MARKER = ''; -const V3_COPILOT_START_MARKER = ''; -const V3_COPILOT_END_MARKER = ''; +const CONTINUITY_CLAUDE_START_MARKER = + ''; +const CONTINUITY_CLAUDE_END_MARKER = ''; +const V3_CODEX_START_MARKER = ''; +const V3_CODEX_END_MARKER = ''; +const V3_ZCODE_START_MARKER = ''; +const V3_ZCODE_END_MARKER = ''; +const V3_COPILOT_START_MARKER = ''; +const V3_COPILOT_END_MARKER = ''; +const LEGACY_V3_CODEX_MARKERS = [ + '', + '', +] as const; +const LEGACY_V3_ZCODE_MARKERS = [ + '', + '', +] as const; +const LEGACY_V3_COPILOT_MARKERS = [ + '', + '', +] as const; const LEGACY_CODEX_START_MARKER = ''; const LEGACY_CODEX_END_MARKER = ''; const LEGACY_ZCODE_START_MARKER = ''; @@ -90,11 +116,38 @@ export interface V3PlatformAdapterStatus { version: string; installed: boolean; ready: boolean; + status: V3AdapterContentStatus; target: string; detail: string; + targets: V3AdapterManagedTargetStatus[]; capabilities: V3AdapterCapabilities; } +export type V3AdapterContentStatus = + | 'ready' + | 'missing' + | 'stale' + | 'unreadable'; + +export interface V3AdapterManagedTargetStatus { + identity: string; + target: string; + status: V3AdapterContentStatus; + actualDigest: string | null; + expectedDigest: string; + rendererVersion: string; + repair: string; + detail: string; +} + +interface V3AdapterManagedTargetSpec { + identity: string; + target: string; + fileTarget: V3AdapterFileTarget; + expectedManagedContent: string; + blockMarkers: readonly [string, string] | null; +} + /** * A physical file touched by the V3 bootstrap renderer. Codex and ZCode * deliberately share the AGENTS target, so activation journals these files @@ -136,12 +189,25 @@ export type V3AdapterFileTarget = | V3ModeEntryFileTarget | V3LegacyAdapterFileTarget; +export const V3_ADAPTER_PLATFORMS: readonly PlatformName[] = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', +]; + export interface V3AdapterFilePlan { target: V3AdapterFileTarget; beforeContent: string | null; targetContent: string; } +export interface V3StagedAdapterFilePlan { + target: V3AdapterFileTarget; + stagingTarget: string; +} + export interface V3StagedAdapter { platform: PlatformName; /** The corresponding live target, relative to the project root. */ @@ -192,6 +258,28 @@ export const V3_ADAPTER_FILE_TARGETS: readonly V3AdapterFileTarget[] = [ ...V3_LEGACY_ADAPTER_FILE_TARGETS, ]; +/** Computes the content digest for one logical managed target. */ +export function adapterManagedContentDigest( + targetIdentity: string, + managedContent: string | Uint8Array, +): string { + if (!targetIdentity.trim() || targetIdentity.includes('\0')) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_IDENTITY_INVALID'); + } + const content = + typeof managedContent === 'string' + ? Buffer.from(managedContent, 'utf8') + : Buffer.from(managedContent); + const digest = createHash('sha256') + .update(Buffer.from(V3_ADAPTER_DIGEST_DOMAIN, 'utf8')) + .update(Buffer.from([0])) + .update(Buffer.from(targetIdentity, 'utf8')) + .update(Buffer.from([0])) + .update(content) + .digest('hex'); + return `sha256:${digest}`; +} + /** * Calculates exact file replacements without publishing them. This lets a * migration journal bind the combined AGENTS.md result before its first @@ -205,7 +293,13 @@ export async function planV3AdapterFiles( for (const target of V3_ADAPTER_FILE_TARGETS) { existing.set(target, await readAdapterTarget(root, target)); } - const agents = removeLegacyAgentsBlocks(existing.get('agents') ?? ''); + const agents = removeLegacyV3Block( + removeLegacyV3Block( + removeLegacyAgentsBlocks(existing.get('agents') ?? ''), + LEGACY_V3_CODEX_MARKERS, + ), + LEGACY_V3_ZCODE_MARKERS, + ); const nextAgents = replaceManagedV3BlockText( replaceManagedV3BlockText( agents, @@ -219,11 +313,16 @@ export async function planV3AdapterFiles( ); const legacyAdapterPlans = planLegacyAdapterRetirement(existing); const plans: V3AdapterFilePlan[] = [ - managedFilePlan( - 'claude-skill', - existing.get('claude-skill') ?? null, - renderClaudeSkill(renderV3Bootstrap('claude-code')), - ), + { + target: 'claude-skill', + beforeContent: existing.get('claude-skill') ?? null, + targetContent: replaceManagedV3BlockText( + existing.get('claude-skill') ?? '', + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, + renderV3Bootstrap('claude-code'), + ), + }, managedFilePlan( 'cursor-rule', existing.get('cursor-rule') ?? null, @@ -238,7 +337,10 @@ export async function planV3AdapterFiles( target: 'copilot-instructions', beforeContent: existing.get('copilot-instructions') ?? null, targetContent: replaceManagedV3BlockText( - removeManagedBlock(existing.get('copilot-instructions') ?? ''), + removeLegacyV3Block( + removeManagedBlock(existing.get('copilot-instructions') ?? ''), + LEGACY_V3_COPILOT_MARKERS, + ), V3_COPILOT_START_MARKER, V3_COPILOT_END_MARKER, renderV3Bootstrap('copilot'), @@ -256,6 +358,88 @@ export async function planV3AdapterFiles( return plans; } +/** Plans a selected-platform repair while composing shared AGENTS targets once. */ +export async function planV3AdapterUpgradeFiles( + projectRoot: string, + platforms: readonly PlatformName[], +): Promise { + const root = path.resolve(projectRoot); + const selected = normalizeUpgradePlatforms(platforms); + const targetSet = new Set(); + for (const platform of selected) { + targetSet.add(primaryFileTarget(platform)); + for (const mode of V3_MODE_NAMES) { + targetSet.add(modeEntryFileTarget(platform, mode)); + } + for (const target of legacyAdapterTargetsForPlatform(platform, true)) { + targetSet.add(target); + } + } + const existing = new Map(); + for (const target of targetSet) { + existing.set(target, await readAdapterTarget(root, target)); + } + const desired = new Map(existing); + for (const platform of selected) { + planPlatformBootstrapUpgrade(desired, platform); + for (const mode of V3_MODE_NAMES) { + const target = modeEntryFileTarget(platform, mode); + const current = desired.get(target) ?? null; + desired.set( + target, + managedModeEntryPlan(target, current, renderV3ModeEntry(mode, platform)) + .targetContent, + ); + } + } + const plans = [...desired.entries()] + .filter(([target, targetContent]) => existing.get(target) !== targetContent) + .map(([target, targetContent]) => ({ + target, + beforeContent: existing.get(target) ?? null, + targetContent: targetContent ?? '', + })); + const legacyPlans = planLegacyAdapterRetirement(existing).filter( + (legacyPlan) => + !plans.some((candidate) => candidate.target === legacyPlan.target), + ); + return [...plans, ...legacyPlans]; +} + +/** Writes immutable upgrade candidates below .mancode staging, never live targets. */ +export async function stageV3AdapterUpgradeFiles( + projectRoot: string, + operationId: string, + plans: readonly V3AdapterFilePlan[], +): Promise { + if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(operationId)) { + throw new Error('MANCODE_ADAPTER_UPGRADE_OPERATION_ID_INVALID'); + } + const root = path.resolve(projectRoot); + const staged: V3StagedAdapterFilePlan[] = []; + for (const plan of plans) { + const liveTarget = v3AdapterTargetPath(root, plan.target); + const relative = relativeAdapterPath(root, liveTarget); + const stagingTarget = path.join( + '.mancode', + 'staging', + 'adapters', + 'upgrade', + operationId, + relative, + ); + const destination = path.join(root, stagingTarget); + await assertAdapterPathSafe(root, destination); + await mkdir(path.dirname(destination), { recursive: true }); + await atomicWrite(destination, plan.targetContent); + staged.push({ + target: plan.target, + stagingTarget: relativeAdapterPath(root, destination), + }); + } + return staged; +} + /** Publishes one precomputed fixed-target replacement with atomic rename. */ export async function applyV3AdapterFilePlan( projectRoot: string, @@ -270,12 +454,36 @@ export async function applyV3AdapterFilePlan( } const target = v3AdapterTargetPath(root, plan.target); await assertAdapterPathSafe(root, target); + const retiredBootstrapPlatform = retiredBootstrapPlatformFor(plan.target); + if (retiredBootstrapPlatform !== null) { + for (const retired of retiredBootstrapSpecs( + root, + retiredBootstrapPlatform, + )) { + await assertAdapterPathSafe(root, retired.filePath); + } + } + const current = await readAdapterTarget(root, plan.target); + if (current === plan.targetContent) { + if (retiredBootstrapPlatform !== null) { + await removeRetiredBootstrapFiles(root, retiredBootstrapPlatform); + } + return; + } + if (current !== plan.beforeContent) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_CONFLICT'); + } await mkdir(path.dirname(target), { recursive: true }); await atomicWrite(target, plan.targetContent); + if (retiredBootstrapPlatform !== null) { + // The old bootstrap is non-authoritative and is retired only after its + // Continuity replacement is durable. A later repair safely retries this. + await removeRetiredBootstrapFiles(root, retiredBootstrapPlatform); + } } /** - * Renders a complete adapter candidate under V3 staging. Shadow integration + * Renders a complete adapter candidate under Continuity staging. Shadow integration * may inspect this exact replacement without changing a live managed file. */ export async function stageV3Adapter( @@ -290,7 +498,7 @@ export async function stageV3Adapter( '.mancode', 'staging', 'adapters', - 'v3', + 'continuity', platform, target, ); @@ -308,7 +516,7 @@ export async function stageV3Adapter( '.mancode', 'staging', 'adapters', - 'v3', + 'continuity', platform, modeTarget, ); @@ -338,9 +546,9 @@ export function v3AdapterTargetPath( if (legacyTarget !== null) return path.join(root, legacyTarget); switch (target) { case 'claude-skill': - return path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'); + return path.join(root, 'CLAUDE.md'); case 'cursor-rule': - return path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'); + return path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'); case 'agents': return path.join(root, 'AGENTS.md'); case 'copilot-instructions': @@ -391,16 +599,20 @@ export async function installV3Adapter( const content = renderV3Bootstrap(platform); switch (platform) { case 'claude-code': - await writeManagedFile( - path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), - renderClaudeSkill(content), + await replaceManagedV3Block( + path.join(root, 'CLAUDE.md'), + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, + content, ); + await removeRetiredBootstrapFiles(root, platform); break; case 'cursor': await writeManagedFile( - path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), renderCursorRule(content), ); + await removeRetiredBootstrapFiles(root, platform); break; case 'codex': await replaceManagedV3Block( @@ -409,6 +621,7 @@ export async function installV3Adapter( V3_CODEX_END_MARKER, content, [ + LEGACY_V3_CODEX_MARKERS, [LEGACY_CODEX_START_MARKER, LEGACY_CODEX_END_MARKER], [LEGACY_ZCODE_START_MARKER, LEGACY_ZCODE_END_MARKER], ], @@ -420,7 +633,10 @@ export async function installV3Adapter( V3_COPILOT_START_MARKER, V3_COPILOT_END_MARKER, content, - [[LEGACY_CODEX_START_MARKER, LEGACY_CODEX_END_MARKER]], + [ + LEGACY_V3_COPILOT_MARKERS, + [LEGACY_CODEX_START_MARKER, LEGACY_CODEX_END_MARKER], + ], ); break; case 'zcode': @@ -430,6 +646,7 @@ export async function installV3Adapter( V3_ZCODE_END_MARKER, content, [ + LEGACY_V3_ZCODE_MARKERS, [LEGACY_CODEX_START_MARKER, LEGACY_CODEX_END_MARKER], [LEGACY_ZCODE_START_MARKER, LEGACY_ZCODE_END_MARKER], ], @@ -474,25 +691,29 @@ export async function inspectV3Adapter( platform: PlatformName, ): Promise { const root = path.resolve(projectRoot); - await assertPlatformAdapterPathsSafe(root, platform); const target = targetFor(platform); - const bootstrapInstalled = await adapterTargetPresent(root, platform); - const modeEntriesInstalled = ( - await Promise.all( - V3_MODE_NAMES.map((mode) => - v3ModeEntryPresent(v3ModeEntryPath(root, platform, mode)), - ), - ) - ).every(Boolean); - const installed = bootstrapInstalled && modeEntriesInstalled; + const targets: V3AdapterManagedTargetStatus[] = []; + for (const spec of managedTargetSpecs(root, platform)) { + targets.push(await inspectManagedTarget(root, platform, spec)); + } + const ready = targets.every((item) => item.status === 'ready'); + const installed = targets.every((item) => item.status !== 'missing'); + const status = aggregateAdapterStatus(targets); return { version: V3_ADAPTER_VERSION, installed, - ready: installed, + ready, + status, target, - detail: installed - ? 'mancode bootstrap and original mode entries are present; session identity is explicit-required.' - : 'mancode bootstrap or one of its original mode entries is not installed.', + detail: + status === 'ready' + ? 'Managed adapter content matches the current renderer.' + : status === 'missing' + ? 'One or more managed adapter targets are missing.' + : status === 'stale' + ? 'Managed adapter content differs from the current renderer.' + : 'One or more managed adapter targets cannot be read safely.', + targets, capabilities: capabilitiesFor(platform), }; } @@ -500,7 +721,8 @@ export async function inspectV3Adapter( /** Actual on-disk inventory for compatibility gates; never trust manifest echo. */ export async function inspectV3AdapterVersions( projectRoot: string, -): Promise> { + requiredPlatforms: readonly PlatformName[] = [], +): Promise>> { const platforms: PlatformName[] = [ 'claude-code', 'codex', @@ -508,13 +730,36 @@ export async function inspectV3AdapterVersions( 'copilot', 'zcode', ]; + const required = new Set(requiredPlatforms); const entries = await Promise.all( - platforms.map(async (platform) => { - const status = await inspectV3Adapter(projectRoot, platform); - return [platform, status.ready ? status.version : 'missing'] as const; - }), + platforms.map( + async (platform) => + [platform, await inspectV3Adapter(projectRoot, platform)] as const, + ), ); - return Object.fromEntries(entries) as Record; + return v3AdapterVersionsFromStatuses(entries, required); +} + +/** Builds the same physical inventory used by compatibility from prior status reads. */ +export function v3AdapterVersionsFromStatuses( + entries: ReadonlyArray, + requiredPlatforms: ReadonlySet | readonly PlatformName[] = [], +): Partial> { + const required = + requiredPlatforms instanceof Set + ? requiredPlatforms + : new Set(requiredPlatforms); + const versions = entries.map(([platform, status]) => { + const primaryPresent = status.targets[0]?.status !== 'missing'; + return required.has(platform) || primaryPresent + ? ([platform, status.ready ? status.version : status.status] as const) + : null; + }); + return Object.fromEntries( + versions.filter( + (entry): entry is NonNullable => entry !== null, + ), + ) as Partial>; } /** Removes only the V3 bootstrap owned by this renderer, never V3 authority. */ @@ -527,14 +772,18 @@ export async function removeV3Adapter( let preserveSharedModeEntries = false; switch (platform) { case 'claude-code': - await removeManagedFile( - path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), + await removeManagedV3Block( + path.join(root, 'CLAUDE.md'), + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, ); + await removeRetiredBootstrapFiles(root, platform); break; case 'cursor': await removeManagedFile( - path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), ); + await removeRetiredBootstrapFiles(root, platform); break; case 'codex': await removeManagedV3Block( @@ -542,10 +791,13 @@ export async function removeV3Adapter( V3_CODEX_START_MARKER, V3_CODEX_END_MARKER, ); - preserveSharedModeEntries = await managedBlockPresent( + await removeManagedV3Block( path.join(root, 'AGENTS.md'), - V3_ZCODE_START_MARKER, - V3_ZCODE_END_MARKER, + ...LEGACY_V3_CODEX_MARKERS, + ); + preserveSharedModeEntries = await anyManagedBlockPresent( + path.join(root, 'AGENTS.md'), + [[V3_ZCODE_START_MARKER, V3_ZCODE_END_MARKER], LEGACY_V3_ZCODE_MARKERS], ); break; case 'copilot': @@ -554,6 +806,10 @@ export async function removeV3Adapter( V3_COPILOT_START_MARKER, V3_COPILOT_END_MARKER, ); + await removeManagedV3Block( + path.join(root, '.github', 'copilot-instructions.md'), + ...LEGACY_V3_COPILOT_MARKERS, + ); break; case 'zcode': await removeManagedV3Block( @@ -561,10 +817,13 @@ export async function removeV3Adapter( V3_ZCODE_START_MARKER, V3_ZCODE_END_MARKER, ); - preserveSharedModeEntries = await managedBlockPresent( + await removeManagedV3Block( path.join(root, 'AGENTS.md'), - V3_CODEX_START_MARKER, - V3_CODEX_END_MARKER, + ...LEGACY_V3_ZCODE_MARKERS, + ); + preserveSharedModeEntries = await anyManagedBlockPresent( + path.join(root, 'AGENTS.md'), + [[V3_CODEX_START_MARKER, V3_CODEX_END_MARKER], LEGACY_V3_CODEX_MARKERS], ); break; } @@ -597,16 +856,25 @@ export function renderV3Bootstrap(platform: PlatformName): string { '', `- Platform: ${platformLabel}. This file is a non-authoritative bootstrap.`, '- Locate the project root before running mancode commands.', + '- Before the first command, choose one CLI binary for the entire task: use `./node_modules/.bin/mancode` when it exists, otherwise use `mancode`. Run that selected binary with `--version` once and never mix binaries or versions.', + '- In every command below, `mancode` means that selected binary; when the local binary exists, invoke the command as `./node_modules/.bin/mancode ...` rather than falling back to a global executable.', '- Reuse a `mancode status --brief --json` snapshot already obtained in this conversation. Only when no such snapshot exists, run it once from the project root.', + '- Inspect a session read-only with `mancode context session show --session --client --json`; do not invent other session subcommands.', '- The compact status is the public mancode Continuity runtime view. In operator-facing narration, say `mancode` or `mancode Continuity`; never prefix a mode or action with a version label.', '- An explicitly invoked original `man`, `manba`, `manteam`, `manps`, or `mansolo` entry supplies its authorized action. Its mode-specific steps override conflicting generic no-task or mutation guidance below.', '- In particular, `manps` may run local health scans without an actor, session, or TaskRef. `mansolo` needs them only for an explicit governed handoff.', - '- If status has no `identity.actorId`, ask for a display name and run `mancode team identity create --name ""` before creating a session.', + '- Outside an explicitly invoked mode entry, treat an ordinary requested coding task as default Solo work. Ordinary Solo work requires no actor identity, session, TaskRef, or workflow; do not ask for a display name or create Continuity authority for it.', + "- Before editing in default Solo, inspect only the relevant project facts, implementation, tests, and contracts. A supplied instruction is not automatically sound: verify its factual assumptions and proposed solution against the repository and the operator's goal.", + '- If the goal and decision-changing requirements are clear, consistent with project evidence, and low risk, proceed with the narrowest useful change without ceremonial questions. Resolve repository-answerable unknowns yourself.', + '- When the goal is clear but requirements are incomplete, classify each remaining unknown as blocking, recommendable, or defaultable. Ask and wait only for blocking decisions that can materially change behavior, scope, acceptance, architecture, data, security, compatibility, or semantic ownership. For recommendable decisions, give bounded options and a clear recommendation. Use a default only when it is low-impact, reversible, consistent with repository conventions, and stated explicitly.', + "- If an explicit request conflicts with repository evidence or introduces a hard-risk change involving authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure, or another irreversible effect, stop before editing. Show the concrete conflict or impact, recommend the safer path, ask a focused confirmation or choice, and wait. Clarity never overrides safety or the operator's actual goal.", + '- A natural-language request explicitly asking for research, a plan, architecture, migration design, or formal acceptance authorizes the `man` planning path without a separate mode-confirmation question. For an ordinary implementation request whose blocking decision crosses modules or requires architecture, migration, semantic owner/source-of-truth, team coordination, or formal acceptance, recommend `/man`, explain why, and wait; never switch authority silently.', + '- For governed task work only, if status has no `identity.actorId`, ask for a display name and run `mancode team identity create --name ""` before creating a session.', '- If status reports `session`, reuse it. `task: null` and `MANCODE_TASK_REQUIRED` do not make a session stale.', `- ${sessionCreationGuidance} Pass its returned \`sessionId\` and matching client as \`${sessionArguments}\` to every later session command; an \`export\` inside one command tool does not persist to later command tools.`, - '- Outside an invoked original mode entry, if no current task and no task is explicitly supplied, report "no task bound" and stop. Do not probe workflow subcommands to work around `MANCODE_TASK_REQUIRED`.', + '- Outside an invoked original mode entry, if no coding, planning, diagnostic, or review task was requested and no TaskRef is explicitly supplied, report "no task bound" and stop. Do not probe workflow subcommands to work around `MANCODE_TASK_REQUIRED`.', '- Bootstrap discovery is read-only: before the operator explicitly requests task work, do not run `mancode init`, `mancode migrate`, `mancode workflow`, or inspect mancode installed package/source.', - `- With an existing or supplied task, read its Context Pack with \`mancode context show --purpose orient ${sessionArguments}\`; for anonymous diagnosis, include an explicit \`--task \`.`, + `- With an existing or explicitly supplied TaskRef, read its Context Pack with \`mancode context show --purpose orient ${sessionArguments}\`; for anonymous diagnosis, include an explicit \`--task \`. A plain-language Solo request is not a TaskRef and needs no Context Pack.`, '- After an operator explicitly requests task work, perform mutations only through `mancode workflow`, `mancode team`, and `mancode context` commands with their required revision and session arguments.', '- For a mode entry, request the matching Context Pack purpose: `plan`, `implement`, `review`, `verify`, or `handoff`.', '- Do not persist task, mode, or session state in this adapter file or any legacy state file.', @@ -629,6 +897,8 @@ export function renderV3ModeEntry( const sessionClientGuidance = sessionClientGuidanceFor(platform); const statusGuidance = 'Reuse a `mancode status --brief --json` snapshot already obtained in this conversation. Only when none exists, run it once from the project root.'; + const cliSelectionGuidance = + 'Before the first command, use `./node_modules/.bin/mancode` when it exists, otherwise `mancode`; check that selected binary with `--version` once and never mix binaries or versions. In every command below, replace the literal `mancode` with that selected binary path when the local binary exists.'; const sessionCreationGuidance = platform === 'codex' || platform === 'zcode' ? 'If status has no current session, reuse an explicit session ID already retained in this conversation. Only if neither exists, run `mancode context session new --client codex` in Codex or `mancode context session new --client zcode` in ZCode exactly once, then retain the returned session ID.' @@ -696,6 +966,7 @@ export function renderV3ModeEntry( '', '## Enter through mancode', '', + cliSelectionGuidance, ...authoritySteps, '', '## Mode action', @@ -743,9 +1014,22 @@ const V3_MODE_DEFINITIONS: Record< actions: [ '- For a read-only project orientation, inspect and answer directly; do not create governance records.', '- For a new task, run `mancode workflow create man "" --session `.', - '- Write requirements as semantic JSON with `version: 1`, `goal`, `confirmedScope`, `excludedScope`, `technicalDecisions`, `defaults`, `blockingUnknowns`, all seven `coverage` dimensions, and `acceptanceCriteria`; mancode generates canonical internal IDs and digests.', + '- Before writing requirements, inspect the relevant project facts and implementation, then run a decision-readiness gate covering both clarity and soundness. Treat the request as ready only when the goal, in-scope/out-of-scope behavior, acceptance boundary, semantic owner/source of truth, and decision-changing constraints are supplied and consistent with evidence, verifiable from the repository, or explicitly recorded as safe defaults. A supplied instruction is not automatically correct. Do not ask ceremonial questions or manufacture alternatives when the request is already clear and sound.', + '- Classify unresolved decisions as blocking, recommendable, or defaultable. Ask and wait for blocking decisions; for recommendable decisions, present 2–3 bounded options with tradeoffs and one clear recommendation; use a default only when it is low-impact, reversible, consistent with repository conventions, and recorded with its reason.', + '- If any unresolved ambiguity could change the goal, scope, user-visible behavior, acceptance, architecture, data, security, compatibility, owner, or source of truth, stop before requirements finalization, explain the missing decision, ask focused questions, and wait for the user answer. Ask in as many batches as needed, do not repeat answered questions, and never turn an unverified assumption into confirmed scope or confirmed coverage.', + '- Before waiting on a blocking answer, persist the known facts, partial decisions, and each open question with `mancode workflow requirements draft --file --expected-revision --session `. A draft may leave scope, coverage, technical decisions, or acceptance incomplete only while `blockingUnknowns` names the open decisions. After every answer, update the draft or finalize it so another session can resume the exact clarification state.', + '- If an explicit direction conflicts with repository evidence or creates a hard-risk authentication, payment, sensitive-data, deletion, migration, public-API, untrusted-input, concurrency, infrastructure, or irreversible change, stop before requirements finalization. Show the evidence and impact, recommend a safer path, ask for a focused confirmation or choice, and wait; clarity does not waive risk.', + '- After the user answers, summarize the resolved requirements and any remaining defaults. Continue only when no decision-changing blocking unknown remains; otherwise keep the task in clarification and ask again.', + '- Write requirements as semantic JSON with `version: 1`, a non-empty `goal`, non-empty `confirmedScope`, and the arrays `excludedScope`, `technicalDecisions`, `defaults`, and `blockingUnknowns`. Every array item must be a non-empty string; an array may be empty except `confirmedScope`, and `technicalDecisions` must be non-empty whenever `technical_stack` applies.', + '- `coverage` must contain exactly one item for each dimension: `platform`, `core_scope`, `technical_stack`, `data_and_persistence`, `performance`, `compatibility`, and `security`. Each item has the shape `{ "dimension": "platform", "status": "confirmed", "rationale": "..." }`; `status` is exactly `confirmed`, `defaulted`, or `not_applicable`, and `rationale` is non-empty.', + '- `acceptanceCriteria` must contain at least one required item shaped as `{ "id": "AC-1", "description": "...", "required": true, "method": "automated" }`; `method` is exactly `automated`, `manual`, or `hybrid`.', '- Finalize requirements with `mancode workflow requirements finalize --file --expected-revision --session `.', - '- Revise or confirm the plan with `mancode workflow plan revise|confirm --expected-revision ... --session `.', + '- Let mancode assign internal IDs and digests; do not invent canonical IDs or digests in the semantic input.', + '- Revise the plan with `mancode workflow plan revise --expected-revision --file --session `.', + '- Confirm the current plan with `mancode workflow plan confirm --expected-revision --plan-decision --session `.', + "- Confirming with `--plan-decision plan_only` keeps the plan as planned authority and clears this session's active workflow pointer. Resume the TaskRef explicitly before any later governed mutation.", + '- When new evidence materially invalidates confirmed requirements and the operator explicitly chooses to realign the same local task, resume its TaskRef if needed, generate a fresh canonical checkpoint ULID, and run `mancode workflow reframe --expected-revision --checkpoint-id --summary "" --next-action "" --session `. Reframe archives the confirmed requirements and plan, clears the plan decision, and stops at Step 2 with draft requirements. Do not substitute plan revise, scope-change, or workflow update for reframe.', + '- Read reframe evidence without opening private authority files: `mancode workflow archive show --json` and `mancode workflow checkpoint show --json`.', '- Apply verification and review ledgers with their mancode `apply --file` commands, then use `mancode workflow complete --expected-revision --session `.', ], }, @@ -755,6 +1039,8 @@ const V3_MODE_DEFINITIONS: Record< contextPurpose: 'implement', actions: [ '- For a new diagnostic task, run `mancode workflow create manba "" --session `.', + '- Before changing code, establish the expected behavior from reproducible evidence, tests, documentation, history, or the current semantic owner. If the bug goal is clear but the correct behavior cannot be established, ask one focused question and wait instead of inventing product behavior.', + '- If the requested fix conflicts with repository evidence or crosses a hard-risk boundary, show the conflict and obtain a focused confirmation or route the decision through `/man`; do not treat an explicit but unsound fix instruction as sufficient evidence.', '- When this is a child investigation, add `--parent `; report and merge the typed outcome through the mancode child commands.', '- Change lifecycle only with `mancode workflow update --status --expected-revision --session ` and finish with `workflow complete` plus the typed `--outcome`.', ], @@ -766,6 +1052,8 @@ const V3_MODE_DEFINITIONS: Record< actions: [ '- Confirm team membership with `mancode team status`; join invited participants before assigning shared work.', '- For a new shared task, run `mancode workflow create manteam "" --visibility shared --coordination team --confirm-shared --session `.', + '- Apply the same decision-readiness gate as `man` before finalizing requirements: validate both clarity and soundness against project facts and team authority. If the goal, scope, acceptance, owner/source of truth, and constraints are clear and consistent, continue without ceremonial questions; if a decision-changing ambiguity, ownership conflict, or hard-risk direction remains, give evidence and a recommendation, ask focused questions, and wait before writing confirmed requirements.', + '- Persist unresolved team clarification through the same `workflow requirements draft --file ` command as `man`; do not leave ownership questions or partial answers only in chat history.', '- Use claims, checkpoints, sync, and handoffs through `mancode team`; never infer ownership from an adapter prompt.', '- Use the same mancode requirements, plan, verification, review, and completion commands as `man`, adding `--sync` whenever the active transport requires it.', ], @@ -785,6 +1073,9 @@ const V3_MODE_DEFINITIONS: Record< contextPurpose: 'implement', actions: [ '- Do not create or persist a legacy solo mode. Ordinary focused work needs no TaskRef; if the operator expects a governed task, use its bound TaskRef or report that none is bound.', + '- Before editing, assess both clarity and soundness using the project facts. If the request is clear, consistent, and low risk, proceed without ceremonial questions. Resolve repository-answerable unknowns yourself; classify the rest as blocking, recommendable, or defaultable. Ask and wait only when a blocking unknown could materially change behavior, scope, acceptance, data, security, compatibility, or ownership.', + '- A supplied implementation direction is not automatically safe. If it conflicts with repository evidence or involves authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure, or another irreversible effect, show the evidence and impact, recommend the safer path, ask for focused confirmation, and wait before editing.', + '- If resolving the ambiguity requires architecture, semantic owner/source-of-truth, cross-module scope, migration, team coordination, or formal acceptance decisions, recommend `/man`, explain the trigger, and wait for the operator to choose; advice alone never changes mode or authority.', '- For a governed-to-solo transition, use `mancode workflow handoff --to solo --expected-revision --session `.', ], }, @@ -797,11 +1088,12 @@ async function renderV3AdapterCandidate( switch (platform) { case 'claude-code': { const existing = await readAdapterTarget(root, 'claude-skill'); - return managedFilePlan( - 'claude-skill', - existing, - renderClaudeSkill(renderV3Bootstrap(platform)), - ).targetContent; + return replaceManagedV3BlockText( + existing ?? '', + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, + renderV3Bootstrap(platform), + ); } case 'cursor': { const existing = await readAdapterTarget(root, 'cursor-rule'); @@ -814,7 +1106,10 @@ async function renderV3AdapterCandidate( case 'codex': { const existing = (await readAdapterTarget(root, 'agents')) ?? ''; return replaceManagedV3BlockText( - removeLegacyAgentsBlocks(existing), + removeLegacyV3Block( + removeLegacyAgentsBlocks(existing), + LEGACY_V3_CODEX_MARKERS, + ), V3_CODEX_START_MARKER, V3_CODEX_END_MARKER, renderV3Bootstrap(platform), @@ -824,7 +1119,10 @@ async function renderV3AdapterCandidate( const existing = (await readAdapterTarget(root, 'copilot-instructions')) ?? ''; return replaceManagedV3BlockText( - removeManagedBlock(existing), + removeLegacyV3Block( + removeManagedBlock(existing), + LEGACY_V3_COPILOT_MARKERS, + ), V3_COPILOT_START_MARKER, V3_COPILOT_END_MARKER, renderV3Bootstrap(platform), @@ -833,7 +1131,10 @@ async function renderV3AdapterCandidate( case 'zcode': { const existing = (await readAdapterTarget(root, 'agents')) ?? ''; return replaceManagedV3BlockText( - removeLegacyAgentsBlocks(existing), + removeLegacyV3Block( + removeLegacyAgentsBlocks(existing), + LEGACY_V3_ZCODE_MARKERS, + ), V3_ZCODE_START_MARKER, V3_ZCODE_END_MARKER, renderV3Bootstrap(platform), @@ -842,19 +1143,6 @@ async function renderV3AdapterCandidate( } } -function renderClaudeSkill(content: string): string { - return [ - '---', - 'name: mancode-v3', - 'description: "Internal bootstrap for the original mancode mode entries."', - 'user-invocable: false', - '---', - '', - content, - '', - ].join('\n'); -} - function renderCursorRule(content: string): string { return [ '---', @@ -868,46 +1156,343 @@ function renderCursorRule(content: string): string { ].join('\n'); } -async function adapterTargetPresent( +function managedTargetSpecs( root: string, platform: PlatformName, -): Promise { +): V3AdapterManagedTargetSpec[] { + const primary = primaryManagedTargetSpec(platform); + const modes = V3_MODE_NAMES.map((mode) => { + const fileTarget = modeEntryFileTarget(platform, mode); + return { + identity: fileTarget, + target: relativeAdapterPath(root, v3ModeEntryPath(root, platform, mode)), + fileTarget, + expectedManagedContent: renderV3ModeEntry(mode, platform), + blockMarkers: null, + } satisfies V3AdapterManagedTargetSpec; + }); + return [primary, ...modes]; +} + +function normalizeUpgradePlatforms( + platforms: readonly PlatformName[], +): PlatformName[] { + if (!Array.isArray(platforms) || platforms.length === 0) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PLATFORM_REQUIRED'); + } + const selected = new Set(); + for (const platform of platforms) { + if (!V3_ADAPTER_PLATFORMS.includes(platform)) { + throw new Error('MANCODE_ADAPTER_UPGRADE_PLATFORM_INVALID'); + } + selected.add(platform); + } + return V3_ADAPTER_PLATFORMS.filter((platform) => selected.has(platform)); +} + +function primaryFileTarget(platform: PlatformName): V3AdapterFileTarget { switch (platform) { case 'claude-code': - return managedFilePresent( - path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), + return 'claude-skill'; + case 'cursor': + return 'cursor-rule'; + case 'codex': + case 'zcode': + return 'agents'; + case 'copilot': + return 'copilot-instructions'; + } +} + +function planPlatformBootstrapUpgrade( + desired: Map, + platform: PlatformName, +): void { + const target = primaryFileTarget(platform); + const current = desired.get(target) ?? null; + switch (platform) { + case 'claude-code': + desired.set( + target, + replaceManagedV3BlockText( + current ?? '', + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, + renderV3Bootstrap(platform), + ), ); + return; case 'cursor': - return managedFilePresent( - path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + desired.set( + target, + managedFilePlan( + target, + current, + renderCursorRule(renderV3Bootstrap(platform)), + ).targetContent, ); + return; case 'codex': - return managedBlockPresent( - path.join(root, 'AGENTS.md'), + desired.set( + target, + replaceManagedV3BlockText( + removeLegacyV3Block( + removeLegacyAgentsBlocks(current ?? ''), + LEGACY_V3_CODEX_MARKERS, + ), + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + renderV3Bootstrap(platform), + ), + ); + return; + case 'zcode': + desired.set( + target, + replaceManagedV3BlockText( + removeLegacyV3Block( + removeLegacyAgentsBlocks(current ?? ''), + LEGACY_V3_ZCODE_MARKERS, + ), + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + renderV3Bootstrap(platform), + ), + ); + return; + case 'copilot': + desired.set( + target, + replaceManagedV3BlockText( + removeLegacyV3Block( + removeManagedBlock(current ?? ''), + LEGACY_V3_COPILOT_MARKERS, + ), + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + renderV3Bootstrap(platform), + ), + ); + } +} + +function primaryManagedTargetSpec( + platform: PlatformName, +): V3AdapterManagedTargetSpec { + const target = targetFor(platform); + switch (platform) { + case 'claude-code': + return embeddedManagedTargetSpec( + target, + 'claude-instructions#continuity', + 'claude-skill', + CONTINUITY_CLAUDE_START_MARKER, + CONTINUITY_CLAUDE_END_MARKER, + renderV3Bootstrap(platform), + ); + case 'cursor': + return { + identity: 'cursor-rule', + target, + fileTarget: 'cursor-rule', + expectedManagedContent: renderCursorRule(renderV3Bootstrap(platform)), + blockMarkers: null, + }; + case 'codex': + return embeddedManagedTargetSpec( + target, + 'agents#codex', + 'agents', V3_CODEX_START_MARKER, V3_CODEX_END_MARKER, + renderV3Bootstrap(platform), ); case 'copilot': - return managedBlockPresent( - path.join(root, '.github', 'copilot-instructions.md'), + return embeddedManagedTargetSpec( + target, + 'copilot-instructions#copilot', + 'copilot-instructions', V3_COPILOT_START_MARKER, V3_COPILOT_END_MARKER, + renderV3Bootstrap(platform), ); case 'zcode': - return managedBlockPresent( - path.join(root, 'AGENTS.md'), + return embeddedManagedTargetSpec( + target, + 'agents#zcode', + 'agents', V3_ZCODE_START_MARKER, V3_ZCODE_END_MARKER, + renderV3Bootstrap(platform), ); } } +function embeddedManagedTargetSpec( + target: string, + identity: string, + fileTarget: V3AdapterFileTarget, + startMarker: string, + endMarker: string, + content: string, +): V3AdapterManagedTargetSpec { + return { + identity, + target, + fileTarget, + expectedManagedContent: [startMarker, content, endMarker].join('\n'), + blockMarkers: [startMarker, endMarker], + }; +} + +function modeEntryFileTarget( + platform: PlatformName, + mode: V3ModeName, +): V3ModeEntryFileTarget { + const family = + platform === 'claude-code' + ? 'claude' + : platform === 'codex' || platform === 'zcode' + ? 'agents' + : platform; + return `${family}-mode-${mode}` as V3ModeEntryFileTarget; +} + +async function inspectManagedTarget( + root: string, + platform: PlatformName, + spec: V3AdapterManagedTargetSpec, +): Promise { + const expectedDigest = adapterManagedContentDigest( + spec.identity, + spec.expectedManagedContent, + ); + const repair = [ + `Preview with \`mancode adapter upgrade --platform ${platform} --dry-run\`.`, + `Confirm that exact preview with \`mancode adapter upgrade --platform ${platform} --confirm --operation-id --session \`.`, + ].join(' '); + try { + const bytes = await readAdapterBytesIfExists( + root, + v3AdapterTargetPath(root, spec.fileTarget), + ); + if (bytes === null) { + return managedTargetStatus( + spec, + 'missing', + null, + expectedDigest, + repair, + 'Managed target does not exist.', + ); + } + const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + let managedContent: string; + if (spec.blockMarkers === null) { + managedContent = content; + } else { + try { + const extracted = extractManagedBlock( + content, + spec.blockMarkers[0], + spec.blockMarkers[1], + ); + if (extracted === null) { + return managedTargetStatus( + spec, + 'missing', + null, + expectedDigest, + repair, + 'Managed block is absent from the target file.', + ); + } + managedContent = extracted; + } catch (error) { + return managedTargetStatus( + spec, + 'stale', + null, + expectedDigest, + repair, + error instanceof Error + ? error.message + : 'Managed block is malformed.', + ); + } + } + const actualDigest = adapterManagedContentDigest( + spec.identity, + managedContent, + ); + const status = actualDigest === expectedDigest ? 'ready' : 'stale'; + return managedTargetStatus( + spec, + status, + actualDigest, + expectedDigest, + status === 'ready' ? 'none' : repair, + status === 'ready' + ? 'Managed content matches the current renderer.' + : 'Managed content differs from the current renderer.', + ); + } catch (error) { + return managedTargetStatus( + spec, + 'unreadable', + null, + expectedDigest, + `Resolve the filesystem or UTF-8 error, then ${repair}`, + error instanceof Error ? error.message : 'Managed target is unreadable.', + ); + } +} + +function managedTargetStatus( + spec: V3AdapterManagedTargetSpec, + status: V3AdapterContentStatus, + actualDigest: string | null, + expectedDigest: string, + repair: string, + detail: string, +): V3AdapterManagedTargetStatus { + return { + identity: spec.identity, + target: spec.target, + status, + actualDigest, + expectedDigest, + rendererVersion: V3_ADAPTER_VERSION, + repair, + detail, + }; +} + +function aggregateAdapterStatus( + targets: V3AdapterManagedTargetStatus[], +): V3AdapterContentStatus { + if (targets.every((target) => target.status === 'ready')) return 'ready'; + if (targets.some((target) => target.status === 'unreadable')) { + return 'unreadable'; + } + if (targets.some((target) => target.status === 'stale')) return 'stale'; + return 'missing'; +} + +function relativeAdapterPath(root: string, target: string): string { + return path.relative(root, target).split(path.sep).join('/'); +} + async function writeManagedFile( filePath: string, content: string, ): Promise { const existing = await readTextIfExists(filePath); - if (existing !== null && !existing.includes(V3_ADAPTER_MANAGED_MARKER)) { + if ( + existing !== null && + !existing.includes(V3_ADAPTER_MANAGED_MARKER) && + !existing.includes(LEGACY_V3_ADAPTER_MANAGED_MARKER) + ) { throw new Error('MANCODE_V3_ADAPTER_TARGET_USER_AUTHORED'); } await mkdir(path.dirname(filePath), { recursive: true }); @@ -954,17 +1539,17 @@ async function assertV3ModeEntriesWritable( async function removeV3ModeEntry(filePath: string): Promise { const existing = await readTextIfExists(filePath); - if (existing?.includes(V3_MODE_ENTRY_MANAGED_MARKER)) { + if ( + existing?.includes(V3_MODE_ENTRY_MANAGED_MARKER) || + LEGACY_MODE_ENTRY_MANAGED_MARKERS.some((marker) => + existing?.includes(marker), + ) + ) { await rm(filePath, { force: true }); await removeDirectoryIfEmpty(path.dirname(filePath)); } } -async function v3ModeEntryPresent(filePath: string): Promise { - const existing = await readTextIfExists(filePath); - return existing?.includes(V3_MODE_ENTRY_MANAGED_MARKER) ?? false; -} - async function removeDirectoryIfEmpty(directory: string): Promise { try { await rmdir(directory); @@ -988,6 +1573,22 @@ async function removeManagedFile(filePath: string): Promise { } } +async function removeRetiredBootstrapFiles( + root: string, + platform: PlatformName, +): Promise { + if (platform !== 'claude-code' && platform !== 'cursor') return; + for (const retired of retiredBootstrapSpecs(root, platform)) { + await assertAdapterPathSafe(root, retired.filePath); + const existing = await readTextIfExists(retired.filePath); + if (!retired.managedMarkers.some((marker) => existing?.includes(marker))) { + continue; + } + await rm(retired.filePath, { force: true }); + await removeDirectoryIfEmpty(path.dirname(retired.filePath)); + } +} + async function replaceManagedV3Block( filePath: string, startMarker: string, @@ -1021,6 +1622,13 @@ function removeLegacyAgentsBlocks(existing: string): string { ); } +function removeLegacyV3Block( + existing: string, + markers: readonly [string, string], +): string { + return removeManagedBlock(existing, markers[0], markers[1]); +} + function replaceManagedV3BlockText( existing: string, startMarker: string, @@ -1042,7 +1650,8 @@ function managedFilePlan( ): V3AdapterFilePlan { if ( beforeContent !== null && - !beforeContent.includes(V3_ADAPTER_MANAGED_MARKER) + !beforeContent.includes(V3_ADAPTER_MANAGED_MARKER) && + !beforeContent.includes(LEGACY_V3_ADAPTER_MANAGED_MARKER) ) { throw new Error('MANCODE_V3_ADAPTER_TARGET_USER_AUTHORED'); } @@ -1482,6 +2091,11 @@ async function assertPlatformAdapterPathsSafe( v3AdapterTargetPath(root, target), ), ]); + if (platform === 'claude-code' || platform === 'cursor') { + for (const retired of retiredBootstrapSpecs(root, platform)) { + targets.add(retired.filePath); + } + } for (const target of targets) { await assertAdapterPathSafe(root, target); } @@ -1536,18 +2150,48 @@ async function removeManagedV3Block( } } -async function managedFilePresent(filePath: string): Promise { +async function anyManagedBlockPresent( + filePath: string, + markerPairs: readonly (readonly [string, string])[], +): Promise { const content = await readTextIfExists(filePath); - return content?.includes(V3_ADAPTER_MANAGED_MARKER) ?? false; + return ( + content !== null && + markerPairs.some(([startMarker, endMarker]) => + hasManagedBlock(content, startMarker, endMarker), + ) + ); } -async function managedBlockPresent( +async function readAdapterBytesIfExists( + root: string, filePath: string, - startMarker: string, - endMarker: string, -): Promise { - const content = await readTextIfExists(filePath); - return content !== null && hasManagedBlock(content, startMarker, endMarker); +): Promise { + await assertAdapterPathSafe(root, filePath); + try { + const entry = await lstat(filePath); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } + for (let attempt = 1; attempt <= ADAPTER_READ_MAX_ATTEMPTS; attempt += 1) { + try { + return await readFile(filePath); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + if ( + !isRetriableAdapterReadError(error) || + attempt === ADAPTER_READ_MAX_ATTEMPTS + ) { + throw error; + } + await delay(ADAPTER_READ_RETRY_DELAY_MS * attempt); + } + } + throw new Error('MANCODE_V3_ADAPTER_READ_RETRY_EXHAUSTED'); } async function readTextIfExists(filePath: string): Promise { @@ -1584,9 +2228,9 @@ async function atomicWrite(filePath: string, content: string): Promise { function targetFor(platform: PlatformName): string { switch (platform) { case 'claude-code': - return '.claude/skills/mancode-v3/SKILL.md'; + return 'CLAUDE.md'; case 'cursor': - return '.cursor/rules/mancode-v3.mdc'; + return '.cursor/rules/mancode-continuity.mdc'; case 'codex': case 'zcode': return 'AGENTS.md'; @@ -1595,6 +2239,54 @@ function targetFor(platform: PlatformName): string { } } +function retiredBootstrapPlatformFor( + target: V3AdapterFileTarget, +): 'claude-code' | 'cursor' | null { + if (target === 'claude-skill') return 'claude-code'; + if (target === 'cursor-rule') return 'cursor'; + return null; +} + +function retiredBootstrapSpecs( + root: string, + platform: 'claude-code' | 'cursor', +): Array<{ filePath: string; managedMarkers: readonly string[] }> { + const managedMarkers = [ + LEGACY_V3_ADAPTER_MANAGED_MARKER, + V3_ADAPTER_MANAGED_MARKER, + ] as const; + if (platform === 'claude-code') { + return [ + { + filePath: path.join( + root, + '.claude', + 'skills', + 'mancode-v3', + 'SKILL.md', + ), + managedMarkers, + }, + { + filePath: path.join( + root, + '.claude', + 'skills', + 'mancode-continuity', + 'SKILL.md', + ), + managedMarkers, + }, + ]; + } + return [ + { + filePath: path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + managedMarkers, + }, + ]; +} + function platformLabelFor(platform: PlatformName): string { switch (platform) { case 'claude-code': diff --git a/src/runtime/beta-gate.ts b/src/runtime/beta-gate.ts index 7796acf..1830f73 100644 --- a/src/runtime/beta-gate.ts +++ b/src/runtime/beta-gate.ts @@ -1,5 +1,9 @@ -import { evaluateCompatibilityGate } from '../context/compatibility.js'; +import { + CURRENT_WRITER_CAPABILITIES, + evaluateCompatibilityGate, +} from '../context/compatibility.js'; import { scanLegacyAuthority } from '../context/layout.js'; +import { managedAdapterNames } from '../context/manifest.js'; import { V3ContextStore } from '../context/store.js'; import type { PlatformName } from '../installers/registry.js'; import { @@ -55,8 +59,8 @@ export async function evaluateV3BetaGate( throw new Error('MANCODE_BETA_RELEASE_CANDIDATE_REQUIRED'); } const store = new V3ContextStore(projectRoot); + const snapshot = await store.readProjectSnapshot(); const [ - snapshot, legacy, adapterVersions, adapterEntries, @@ -64,9 +68,11 @@ export async function evaluateV3BetaGate( unfinished, unfinishedGitRefWorkflowRepairs, ] = await Promise.all([ - store.readProjectSnapshot(), scanLegacyAuthority(projectRoot), - inspectV3AdapterVersions(projectRoot), + inspectV3AdapterVersions( + projectRoot, + managedAdapterNames(snapshot.manifest.managedAdapters), + ), Promise.all( BETA_PLATFORMS.map( async (platform) => @@ -82,6 +88,7 @@ export async function evaluateV3BetaGate( expectedSchemaEpoch: snapshot.manifest.epoch, readerVersion: VERSION, writerVersion: VERSION, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, adapterVersions, currentLegacyBaseline: legacy.baseline, legacyAuthorityPresent: legacy.authorityPresent, diff --git a/src/runtime/operation-crash-injection.ts b/src/runtime/operation-crash-injection.ts index bf69b0d..8b67d89 100644 --- a/src/runtime/operation-crash-injection.ts +++ b/src/runtime/operation-crash-injection.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { type Ulid, assertUlid } from '../context/ids.js'; import type { OperationType } from './operation-journal.js'; export interface OperationCrashInjection { @@ -13,6 +14,25 @@ interface ActiveCrashInjection extends OperationCrashInjection { const activeInjection = new AsyncLocalStorage(); +export type OperationLockPausePoint = 'entity_locks_held'; + +export interface OperationLockPauseController { + /** Resolves only after the selected operation holds its canonical locks. */ + readonly reached: Promise; + release(): void; + run(operation: () => Promise): Promise; +} + +interface ActiveLockPauseInjection { + operationId: Ulid; + pauseAfter: OperationLockPausePoint; + triggered: boolean; + signalReached(): void; + readonly resume: Promise; +} + +const activeLockPause = new AsyncLocalStorage(); + /** * Test harness only: simulate process loss immediately after a journal state * becomes durable. The async-local scope prevents one concurrent test from @@ -31,6 +51,83 @@ export async function withOperationCrashInjectionForTesting( ); } +/** + * Test harness only: pauses one explicitly selected operation after its + * prepared journal releases the project barrier while canonical entity locks + * remain held. Async-local scoping keeps unrelated concurrent operations and + * test workers completely unaffected. + */ +export function createOperationLockPauseForTesting(input: { + operationId: Ulid; + pauseAfter: OperationLockPausePoint; +}): OperationLockPauseController { + assertUlid(input.operationId, 'test lock pause operationId'); + if (input.pauseAfter !== 'entity_locks_held') { + throw new Error('MANCODE_TEST_LOCK_PAUSE_POINT_INVALID'); + } + let signalReached!: () => void; + let rejectReached!: (reason?: unknown) => void; + const reached = new Promise((resolve, reject) => { + signalReached = resolve; + rejectReached = reject; + }); + let resume!: () => void; + const resumed = new Promise((resolve) => { + resume = resolve; + }); + let started = false; + let released = false; + const state: ActiveLockPauseInjection = { + ...input, + triggered: false, + signalReached, + resume: resumed, + }; + return { + reached, + release(): void { + if (released) return; + released = true; + resume(); + }, + async run(operation: () => Promise): Promise { + if (started) throw new Error('MANCODE_TEST_LOCK_PAUSE_ALREADY_STARTED'); + started = true; + return activeLockPause.run(state, async () => { + try { + const result = await operation(); + if (!state.triggered) { + rejectReached(new Error('MANCODE_TEST_LOCK_PAUSE_NOT_REACHED')); + } + return result; + } catch (error) { + if (!state.triggered) rejectReached(error); + throw error; + } + }); + }, + }; +} + +/** Called only at the shared post-journal lock-holding boundary. */ +export function pauseIfOperationLockInjectedForTesting( + operationId: Ulid, + pauseAfter: OperationLockPausePoint, +): Promise | null { + const injection = activeLockPause.getStore(); + if ( + injection === undefined || + injection.triggered || + injection.operationId !== operationId || + injection.pauseAfter !== pauseAfter + ) { + return null; + } + injection.triggered = true; + injection.signalReached(); + return injection.resume; +} + export function throwIfOperationCrashInjected( operationType: OperationType, crashAfter: 'prepared' | string, diff --git a/src/runtime/operation-definition.ts b/src/runtime/operation-definition.ts index 6c12483..f37ac68 100644 --- a/src/runtime/operation-definition.ts +++ b/src/runtime/operation-definition.ts @@ -43,6 +43,10 @@ export const OPERATION_AUTHORIZATION_ACTIONS: Record< > = { workflow_create: ['local_workflow_mutation', 'shared_create_publish_promote'], workflow_update: ['local_workflow_mutation', 'shared_metadata_plan_mutation'], + requirements_draft: [ + 'local_workflow_mutation', + 'shared_metadata_plan_mutation', + ], requirements_finalize: [ 'local_workflow_mutation', 'shared_metadata_plan_mutation', @@ -72,6 +76,7 @@ export const OPERATION_AUTHORIZATION_ACTIONS: Record< 'shared_metadata_plan_mutation', ], solo_handoff: ['local_workflow_mutation'], + reframe: ['local_workflow_mutation', 'shared_metadata_plan_mutation'], child_result_merge: [ 'local_workflow_mutation', 'task_complete_scope_change_child_merge', @@ -79,6 +84,8 @@ export const OPERATION_AUTHORIZATION_ACTIONS: Record< task_head_reconcile: ['task_head_reconcile'], transport_migrate: ['team_policy_config_transport'], greenfield_initialize: ['team_policy_config_transport'], + adapter_upgrade: ['project_maintenance'], + project_policy_upgrade: ['project_maintenance'], v3_activate: ['team_policy_config_transport'], }; @@ -175,6 +182,24 @@ export const OPERATION_DEFINITIONS: Record< commit('commit', ['task_head:', 'task:'], ['task:']), ], ), + requirements_draft: definition( + 'requirements_draft', + 'update-metadata', + true, + false, + [ + prepare('validate', ['task:'], ['task:']), + write('write-requirements', ['requirements:', 'task:'], ['task:']), + write( + 'mark-review-verification-stale', + ['review:', 'verification:', 'task:'], + ['task:'], + ), + write('update-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), requirements_finalize: definition( 'requirements_finalize', 'update-metadata', @@ -448,6 +473,49 @@ export const OPERATION_DEFINITIONS: Record< commit('commit', ['task:'], ['task:']), ], ), + reframe: definition('reframe', 'commit-reframed-metadata', true, false, [ + prepare( + 'validate', + [ + 'task:', + 'requirements:', + 'plan:', + 'review:', + 'verification:', + 'archive:', + 'checkpoint:', + ], + ['task:', 'archive:', 'checkpoint:'], + { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }, + ), + write('mark-task-operation-pending', ['task:'], ['task:']), + write( + 'archive-requirements-plan', + ['archive:', 'requirements:', 'plan:', 'task:'], + ['archive:', 'task:'], + ), + write('release-active-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('write-requirements-draft', ['requirements:', 'task:'], ['task:']), + write( + 'mark-review-verification-stale', + ['review:', 'verification:', 'task:'], + ['task:'], + ), + write( + 'write-reframe-checkpoint', + ['checkpoint:', 'task:'], + ['checkpoint:', 'task:'], + ), + write('commit-reframed-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ]), child_result_merge: definition( 'child_result_merge', 'update-parent-metadata', @@ -537,10 +605,48 @@ export const OPERATION_DEFINITIONS: Record< ), write('publish-v3-root', ['schema:', 'config:'], ['schema:']), write('register-workspace-binding', ['binding:'], ['binding:']), + write('publish-managed-adapters', ['adapter:'], ['adapter:']), write('activate-v3-manifest', ['schema:'], ['schema:']), commit('commit', ['schema:'], ['schema:']), ], ), + adapter_upgrade: definition( + 'adapter_upgrade', + 'replace-managed-adapters', + false, + false, + [ + prepare('validate', ['adapter:'], ['adapter:'], { + expectedRevisionPrefixes: ['schema:'], + requiredLockPrefixes: ['schema:'], + }), + write('replace-managed-adapters', ['adapter:'], ['adapter:']), + write('update-adapter-inventory', [], [], { + expectedRevisionPrefixes: ['schema:'], + requiredLockPrefixes: ['schema:'], + }), + commit('verify', ['adapter:'], ['adapter:'], { + expectedRevisionPrefixes: ['schema:'], + requiredLockPrefixes: ['schema:'], + }), + commit('commit', ['adapter:'], ['adapter:'], { + expectedRevisionPrefixes: ['schema:'], + requiredLockPrefixes: ['schema:'], + }), + ], + ), + project_policy_upgrade: definition( + 'project_policy_upgrade', + 'write-manifest', + false, + false, + [ + prepare('validate', ['schema:'], ['schema:']), + write('write-manifest', ['schema:'], ['schema:']), + write('verify-manifest', ['schema:'], ['schema:']), + commit('commit', ['schema:'], ['schema:']), + ], + ), v3_activate: definition('v3_activate', 'activate-manifest', true, true, [ prepare( 'validate-staged-migration', diff --git a/src/runtime/operation-journal.ts b/src/runtime/operation-journal.ts index f7d1c5f..1c7b41a 100644 --- a/src/runtime/operation-journal.ts +++ b/src/runtime/operation-journal.ts @@ -12,6 +12,7 @@ import { export type OperationType = | 'workflow_create' | 'workflow_update' + | 'requirements_draft' | 'requirements_finalize' | 'plan_revision' | 'review_remediation' @@ -28,10 +29,13 @@ export type OperationType = | 'claim_revalidation' | 'checkpoint_create' | 'solo_handoff' + | 'reframe' | 'child_result_merge' | 'task_head_reconcile' | 'transport_migrate' | 'greenfield_initialize' + | 'adapter_upgrade' + | 'project_policy_upgrade' | 'v3_activate'; export type OperationState = @@ -82,6 +86,7 @@ export interface OperationTransitionOptions { const OPERATION_TYPES = new Set([ 'workflow_create', 'workflow_update', + 'requirements_draft', 'requirements_finalize', 'plan_revision', 'review_remediation', @@ -98,10 +103,13 @@ const OPERATION_TYPES = new Set([ 'claim_revalidation', 'checkpoint_create', 'solo_handoff', + 'reframe', 'child_result_merge', 'task_head_reconcile', 'transport_migrate', 'greenfield_initialize', + 'adapter_upgrade', + 'project_policy_upgrade', 'v3_activate', ]); const OPERATION_STATES = new Set([ @@ -499,7 +507,7 @@ function allowedOperationTransitions( case 'applying': return new Set(['committed', 'repair_required', 'aborted']); case 'repair_required': - return new Set(['committed']); + return new Set(['committed', 'aborted']); case 'committed': case 'aborted': return new Set(); diff --git a/src/runtime/operation-recovery-executor.ts b/src/runtime/operation-recovery-executor.ts index a0d76aa..153314a 100644 --- a/src/runtime/operation-recovery-executor.ts +++ b/src/runtime/operation-recovery-executor.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import { digestCanonicalJson } from '../context/canonical.js'; import { type Ulid, assertUlid } from '../context/ids.js'; import { + assertSchemaManifestPolicyUpgrade, assertSchemaManifestTransition, parseSchemaManifest, } from '../context/manifest.js'; @@ -70,12 +71,15 @@ import { updateOperationJournal, } from './operation-store.js'; import { readProjectRuntimeContext } from './project-runtime.js'; +import { inspectOperationProjectionState } from './projection-outbox.js'; import { assertRecoveryActor, planOperationRecovery } from './reconciler.js'; import { readSession } from './session.js'; import { readTaskHeadFence, replaceTaskHeadFence } from './task-head-store.js'; import { + readTaskArchiveDigestAtRoot, readTaskAuthorityFileAtRoot, readTaskCheckpointAtRoot, + writeTaskArchiveAtRoot, writeTaskAuthorityFileAtRoot, writeTaskCheckpointAtRoot, } from './task-operation.js'; @@ -119,6 +123,10 @@ export async function inspectOperationRecovery( const plan = planOperationRecovery({ journal: located.journal, reservations: await readReservations(located.journal, stores), + projections: await inspectOperationProjectionState( + projectRoot, + operationId, + ), }); return { journal: located.journal, @@ -466,6 +474,7 @@ async function applyPayload( stores, journal.operationId, journal.actorId, + journal.type, action, ); } @@ -516,6 +525,7 @@ async function applyAction( stores: EntityHomeStore[], operationId: Ulid, actorId: Ulid, + operationType: OperationJournalV1['type'], action: OperationRecoveryActionV1, ): Promise { switch (action.kind) { @@ -529,6 +539,11 @@ async function applyAction( ); return; } + case 'task_archive': { + const root = await taskRoot(projectRoot, action.taskRef); + await writeTaskArchiveAtRoot(root, operationId, action); + return; + } case 'workflow_task_directory': await publishWorkflowDirectory(projectRoot, operationId, action); return; @@ -536,7 +551,7 @@ async function applyAction( await publishMigrationDirectory(projectRoot, operationId, action); return; case 'project_authority_file': - assertProjectAuthorityTransition(action); + assertProjectAuthorityTransition(action, operationType); await writeProjectAuthorityFile( projectRoot, operationId, @@ -599,17 +614,22 @@ function assertProjectAuthorityTransition( OperationRecoveryActionV1, { kind: 'project_authority_file' } >, + operationType: OperationJournalV1['type'], ): void { if (action.beforeContent === null) { throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); } switch (action.fileName) { - case 'schema.json': - assertSchemaManifestTransition( - parseSchemaManifest(JSON.parse(action.beforeContent)), - parseSchemaManifest(JSON.parse(action.targetContent)), - ); + case 'schema.json': { + const previous = parseSchemaManifest(JSON.parse(action.beforeContent)); + const next = parseSchemaManifest(JSON.parse(action.targetContent)); + if (operationType === 'project_policy_upgrade') { + assertSchemaManifestPolicyUpgrade(previous, next); + } else { + assertSchemaManifestTransition(previous, next); + } return; + } case 'shared/config.json': assertProjectConfigTransition( parseProjectConfig(JSON.parse(action.beforeContent)), @@ -663,6 +683,11 @@ async function currentActionDigest( ? null : taskAuthorityContentDigest(action.fileName, content); } + case 'task_archive': + return readTaskArchiveDigestAtRoot( + await taskRoot(projectRoot, action.taskRef), + action, + ); case 'workflow_task_directory': return currentWorkflowDirectoryDigest(projectRoot, action); case 'migration_task_directory': diff --git a/src/runtime/operation-recovery-payload.ts b/src/runtime/operation-recovery-payload.ts index eaa2dc9..bff383d 100644 --- a/src/runtime/operation-recovery-payload.ts +++ b/src/runtime/operation-recovery-payload.ts @@ -55,6 +55,39 @@ export interface TaskAuthorityFileRecoveryAction { targetContent: string; } +export interface TaskArchiveManifestV1 { + schemaVersion: 1; + archiveId: Ulid; + operationId: Ulid; + taskRef: TaskRef; + sourceTaskRevision: number; + sourceRequirementsRevision: number; + sourceRequirementsDigest: string; + sourcePlanVersion: number; + sourcePlanDigest: string | null; + requirementsFileDigest: string; + planFileDigest: string | null; + archiveDigest: string; + createdAt: string; +} + +/** Immutable requirements/plan snapshot at a fixed operation-owned path. */ +export interface TaskArchiveRecoveryAction { + kind: 'task_archive'; + stepId: string; + taskRef: TaskRef; + archiveId: Ulid; + sourceTaskRevision: number; + sourceRequirementsRevision: number; + sourceRequirementsDigest: string; + sourcePlanVersion: number; + sourcePlanDigest: string | null; + createdAt: string; + requirementsContent: string; + planContent: string | null; + beforeDigest: null; +} + export interface WorkflowTaskDirectoryRecoveryAction { kind: 'workflow_task_directory'; stepId: string; @@ -159,6 +192,7 @@ export interface HandoffRecoveryAction { export type OperationRecoveryActionV1 = | TaskAuthorityFileRecoveryAction + | TaskArchiveRecoveryAction | WorkflowTaskDirectoryRecoveryAction | MigrationTaskDirectoryRecoveryAction | ProjectAuthorityFileRecoveryAction @@ -217,6 +251,15 @@ export function parseOperationRecoveryPayload( throw new Error('operation recovery payload primaryStoreId is invalid'); } const actions = parseActions(value.actions); + if ( + actions.some( + (action) => + action.kind === 'task_archive' && + action.archiveId !== value.operationId, + ) + ) { + throw new Error('operation recovery task archive operationId is invalid'); + } const noOpStepIds = parseStepIds(value.noOpStepIds, 'noOpStepIds'); const actionSteps = new Set(actions.map((action) => action.stepId)); if (noOpStepIds.some((stepId) => actionSteps.has(stepId))) { @@ -353,6 +396,40 @@ export function createTaskAuthorityFileRecoveryAction(input: { return parseTaskAuthorityFileAction(action); } +export function createTaskArchiveRecoveryAction(input: { + stepId: string; + taskRef: TaskRef; + archiveId: Ulid; + sourceTaskRevision: number; + sourcePlanVersion: number; + createdAt: string; + requirementsContent: string; + planContent: string | null; +}): TaskArchiveRecoveryAction { + const taskRef = parseTaskRefValue(input.taskRef); + const requirements = parseRequirementsLedger( + parseJson(input.requirementsContent, 'task archive requirements'), + ); + if (!sameTaskRef(requirements.taskRef, taskRef)) { + throw new Error('operation recovery task archive TaskRef is invalid'); + } + return parseTaskArchiveAction({ + kind: 'task_archive', + stepId: input.stepId, + taskRef, + archiveId: input.archiveId, + sourceTaskRevision: input.sourceTaskRevision, + sourceRequirementsRevision: requirements.revision, + sourceRequirementsDigest: requirements.contentDigest, + sourcePlanVersion: input.sourcePlanVersion, + sourcePlanDigest: taskArchivePlanDigest(taskRef, input.planContent), + createdAt: input.createdAt, + requirementsContent: input.requirementsContent, + planContent: input.planContent, + beforeDigest: null, + }); +} + export function createWorkflowTaskDirectoryRecoveryAction(input: { stepId: string; taskRef: TaskRef; @@ -519,6 +596,14 @@ function assertActionLockCoverage( throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); } return; + case 'task_archive': + if ( + !hasLock(taskKey(action.taskRef)) || + !hasLock(`archive:${action.archiveId}`) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; case 'project_authority_file': if ( !hasLock( @@ -690,6 +775,8 @@ export function recoveryActionResourceKey( switch (action.kind) { case 'task_authority_file': return `task-file:${action.taskRef.namespace}:${action.taskRef.taskId}:${action.fileName}`; + case 'task_archive': + return `task-archive:${action.taskRef.namespace}:${action.taskRef.taskId}:${action.archiveId}`; case 'workflow_task_directory': return `workflow-directory:${action.taskRef.namespace}:${action.taskRef.taskId}`; case 'migration_task_directory': @@ -717,6 +804,8 @@ export function recoveryActionTargetDigest( switch (action.kind) { case 'task_authority_file': return taskAuthorityContentDigest(action.fileName, action.targetContent); + case 'task_archive': + return taskArchiveDigest(action); case 'workflow_task_directory': return workflowTaskDirectoryDigest(action); case 'migration_task_directory': @@ -753,6 +842,8 @@ function parseActions(value: unknown): OperationRecoveryActionV1[] { switch (action.kind) { case 'task_authority_file': return parseTaskAuthorityFileAction(action); + case 'task_archive': + return parseTaskArchiveAction(action); case 'workflow_task_directory': return parseWorkflowTaskDirectoryAction(action); case 'migration_task_directory': @@ -1157,6 +1248,95 @@ function parseTaskAuthorityFileAction( }; } +function parseTaskArchiveAction( + value: Record, +): TaskArchiveRecoveryAction { + assertKnownKeys( + value, + [ + 'kind', + 'stepId', + 'taskRef', + 'archiveId', + 'sourceTaskRevision', + 'sourceRequirementsRevision', + 'sourceRequirementsDigest', + 'sourcePlanVersion', + 'sourcePlanDigest', + 'createdAt', + 'requirementsContent', + 'planContent', + 'beforeDigest', + ], + 'operation recovery task archive action', + ); + if (value.kind !== 'task_archive' || value.beforeDigest !== null) { + throw new Error('operation recovery task archive action is invalid'); + } + const taskRef = parseTaskRefValue(value.taskRef); + assertUlid(value.archiveId, 'operation recovery task archive archiveId'); + const sourceTaskRevision = parsePositiveInteger( + value.sourceTaskRevision, + 'task archive sourceTaskRevision', + ); + const sourceRequirementsRevision = parsePositiveInteger( + value.sourceRequirementsRevision, + 'task archive sourceRequirementsRevision', + ); + const sourceRequirementsDigest = requireDigest( + value.sourceRequirementsDigest, + 'task archive sourceRequirementsDigest', + ); + const sourcePlanVersion = parsePositiveInteger( + value.sourcePlanVersion, + 'task archive sourcePlanVersion', + ); + const sourcePlanDigest = parseDigestOrNull( + value.sourcePlanDigest, + 'task archive sourcePlanDigest', + ); + const createdAt = parseTimestamp(value.createdAt, 'task archive createdAt'); + const requirementsContent = parseText( + value.requirementsContent, + 'task archive requirementsContent', + ); + const requirements = parseRequirementsLedger( + parseJson(requirementsContent, 'task archive requirements'), + ); + if ( + !sameTaskRef(requirements.taskRef, taskRef) || + requirements.revision !== sourceRequirementsRevision || + requirements.contentDigest !== sourceRequirementsDigest + ) { + throw new Error('operation recovery task archive requirements are invalid'); + } + const planContent = + value.planContent === null + ? null + : parseText(value.planContent, 'task archive planContent'); + if (planContent !== null && !planContent.trim()) { + throw new Error('operation recovery task archive plan is invalid'); + } + if (sourcePlanDigest !== taskArchivePlanDigest(taskRef, planContent)) { + throw new Error('operation recovery task archive plan digest is invalid'); + } + return { + kind: 'task_archive', + stepId: parseStepId(value.stepId), + taskRef, + archiveId: value.archiveId, + sourceTaskRevision, + sourceRequirementsRevision, + sourceRequirementsDigest, + sourcePlanVersion, + sourcePlanDigest, + createdAt, + requirementsContent, + planContent, + beforeDigest: null, + }; +} + function parseCheckpointAction( value: Record, ): CheckpointRecoveryAction { @@ -1278,6 +1458,40 @@ function parseText(value: unknown, label: string): string { return value; } +function parseJson(content: string, label: string): unknown { + try { + return JSON.parse(content); + } catch { + throw new Error(`operation recovery ${label} JSON is invalid`); + } +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`operation recovery ${label} is invalid`); + } + return value as number; +} + +function parseTimestamp(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !value.trim() || + !Number.isFinite(Date.parse(value)) + ) { + throw new Error(`operation recovery ${label} is invalid`); + } + return value; +} + +function requireDigest(value: unknown, label: string): string { + const digest = parseDigestOrNull(value, label); + if (digest === null) { + throw new Error(`operation recovery ${label} is invalid`); + } + return digest; +} + function parseDigestOrNull(value: unknown, label: string): string | null { if (value === null) return null; if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { @@ -1304,6 +1518,56 @@ export function workflowTaskDirectoryDigest( }); } +export function taskArchiveDigest(action: TaskArchiveRecoveryAction): string { + return digestCanonicalJson({ + archiveId: action.archiveId, + operationId: action.archiveId, + taskRef: action.taskRef, + sourceTaskRevision: action.sourceTaskRevision, + sourceRequirementsRevision: action.sourceRequirementsRevision, + sourceRequirementsDigest: action.sourceRequirementsDigest, + sourcePlanVersion: action.sourcePlanVersion, + sourcePlanDigest: action.sourcePlanDigest, + createdAt: action.createdAt, + requirementsContent: action.requirementsContent, + planContent: action.planContent, + }); +} + +export function taskArchiveManifest( + action: TaskArchiveRecoveryAction, +): TaskArchiveManifestV1 { + return { + schemaVersion: 1, + archiveId: action.archiveId, + operationId: action.archiveId, + taskRef: action.taskRef, + sourceTaskRevision: action.sourceTaskRevision, + sourceRequirementsRevision: action.sourceRequirementsRevision, + sourceRequirementsDigest: action.sourceRequirementsDigest, + sourcePlanVersion: action.sourcePlanVersion, + sourcePlanDigest: action.sourcePlanDigest, + requirementsFileDigest: digestCanonicalJson({ + content: action.requirementsContent, + }), + planFileDigest: + action.planContent === null + ? null + : digestCanonicalJson({ content: action.planContent }), + archiveDigest: taskArchiveDigest(action), + createdAt: action.createdAt, + }; +} + +function taskArchivePlanDigest( + taskRef: TaskRef, + content: string | null, +): string | null { + return content === null + ? null + : digestCanonicalJson({ artifactRef: { taskRef, kind: 'plan' }, content }); +} + export function migrationTaskDirectoryDigest( action: MigrationTaskDirectoryRecoveryAction, ): string { diff --git a/src/runtime/platform-spike-store.ts b/src/runtime/platform-spike-store.ts index 965a130..1ee9268 100644 --- a/src/runtime/platform-spike-store.ts +++ b/src/runtime/platform-spike-store.ts @@ -73,7 +73,7 @@ export async function writePlatformSessionSpike( spike: PlatformSessionSpike, ): Promise { const parsed = parsePlatformSessionSpike(spike); - if (parsed.schemaVersion !== 2) { + if (parsed.schemaVersion !== 3) { throw new Error('MANCODE_PLATFORM_SPIKE_RECAPTURE_REQUIRED'); } const directory = await safeEvidenceDirectory(projectRoot, true); diff --git a/src/runtime/platform-spike.ts b/src/runtime/platform-spike.ts index 39bb8fa..2e781ca 100644 --- a/src/runtime/platform-spike.ts +++ b/src/runtime/platform-spike.ts @@ -11,6 +11,7 @@ export const SESSION_SPIKE_PLATFORMS = [ export type SessionSpikePlatform = (typeof SESSION_SPIKE_PLATFORMS)[number]; export type HostSessionSource = 'hook_stdin' | 'environment' | 'api' | 'none'; +export type SessionEvidenceMode = 'host' | 'explicit'; export type SpikeEvidenceStatus = | 'proven' | 'not_proven' @@ -65,18 +66,40 @@ export interface PlatformSessionSpikeV2 { evidence: PlatformSessionEvidenceBindingV1; } +/** + * Distinguishes trusted host identity from verified explicit sessions. Both + * can satisfy release evidence, but only host evidence may enable automatic + * host identity at runtime. + */ +export interface PlatformSessionSpikeV3 { + schemaVersion: 3; + platform: SessionSpikePlatform; + observedAt: string; + sessionMode: SessionEvidenceMode; + hostSessionSource: HostSessionSource; + hostSessionObserved: boolean; + distinctClientWindows: SpikeEvidenceStatus; + commandPropagation: SpikeEvidenceStatus; + subagentInheritance: SpikeEvidenceStatus; + subagentInheritanceReason: string | null; + hookApproval: HookApprovalStatus; + evidence: PlatformSessionEvidenceBindingV1; +} + export type PlatformSessionSpike = | PlatformSessionSpikeV1 - | PlatformSessionSpikeV2; + | PlatformSessionSpikeV2 + | PlatformSessionSpikeV3; export interface PlatformSessionSpikeInput { platform: SessionSpikePlatform; observedAt: string; + sessionMode: SessionEvidenceMode; hostSessionSource: HostSessionSource; - /** Ephemeral only; never copied into PlatformSessionSpikeV2. */ - firstWindowHostSessionKey: string | null; + /** Ephemeral host key or explicit session ID; never persisted. */ + firstWindowSessionKey: string | null; /** Ephemeral only; proves two same-client windows do not collide. */ - secondWindowHostSessionKey: string | null; + secondWindowSessionKey: string | null; /** Operator-attested result from a real host child command. */ commandPropagation: SpikeEvidenceStatus; /** Operator-attested result from a real host child agent, if applicable. */ @@ -98,6 +121,11 @@ export type PlatformSessionEvidenceState = | 'release_candidate_mismatch' | 'mancode_version_mismatch'; +export type PlatformReleaseEvidence = + | 'host_session_verified' + | 'explicit_session_verified' + | 'unverified'; + export interface PlatformSessionCapability { platform: SessionSpikePlatform; hostIdentity: HostIdentityCapability; @@ -106,61 +134,62 @@ export interface PlatformSessionCapability { subagentInheritance: SpikeEvidenceStatus; hookApproval: HookApprovalStatus; evidenceState: PlatformSessionEvidenceState; + releaseEvidence: PlatformReleaseEvidence; reason: string; } export interface PlatformSpikeFreezeStatus { missingPlatforms: SessionSpikePlatform[]; + /** Informational runtime policy; verified explicit evidence may still be release-ready. */ explicitRequiredPlatforms: SessionSpikePlatform[]; + unverifiedPlatforms: SessionSpikePlatform[]; evidenceMismatchPlatforms: SessionSpikePlatform[]; ready: boolean; } export function createPlatformSessionSpike( input: PlatformSessionSpikeInput, -): PlatformSessionSpikeV2 { +): PlatformSessionSpikeV3 { assertSessionSpikePlatform(input.platform); assertTimestamp(input.observedAt, 'platform session spike observedAt'); + assertSessionEvidenceMode(input.sessionMode); assertHostSessionSource(input.hostSessionSource); assertSpikeStatus(input.commandPropagation, 'commandPropagation'); assertSpikeStatus(input.subagentInheritance, 'subagentInheritance'); assertHookApproval(input.hookApproval); const evidence = parseEvidenceBinding(input.evidence); - const first = normalizeEphemeralHostKey(input.firstWindowHostSessionKey); - const second = normalizeEphemeralHostKey(input.secondWindowHostSessionKey); + const first = normalizeEphemeralSessionKey(input.firstWindowSessionKey); + const second = normalizeEphemeralSessionKey(input.secondWindowSessionKey); const subagentInheritanceReason = parseSubagentInheritanceReason( input.subagentInheritance, input.subagentInheritanceReason ?? null, ); - if (input.hostSessionSource === 'none' && first !== null) { + if (input.sessionMode === 'host' && input.hostSessionSource === 'none') { throw new Error( - 'platform session spike source none cannot carry a host key', + 'platform host session spike requires a host session source', ); } - if (input.hostSessionSource !== 'none' && first === null) { - throw new Error( - 'platform session spike host source requires an observed key', - ); + if (input.sessionMode === 'explicit' && input.hostSessionSource !== 'none') { + throw new Error('platform explicit session spike host source must be none'); } - if (second !== null && first === null) { - throw new Error( - 'platform session spike second window requires a first window', - ); + if (first === null) { + throw new Error('platform session spike requires a first window key'); } if ( - input.hostSessionSource === 'none' && - input.commandPropagation === 'proven' + input.sessionMode === 'explicit' && + input.hookApproval !== 'not_applicable' ) { throw new Error( - 'platform session spike cannot prove propagation without a host key', + 'platform explicit session spike hook approval must be not_applicable', ); } return { - schemaVersion: 2, + schemaVersion: 3, platform: input.platform, observedAt: input.observedAt, + sessionMode: input.sessionMode, hostSessionSource: input.hostSessionSource, - hostSessionObserved: first !== null, + hostSessionObserved: input.sessionMode === 'host', distinctClientWindows: second === null ? 'not_tested' @@ -181,7 +210,8 @@ export function parsePlatformSessionSpike( assertRecord(value, 'platform session spike'); if (value.schemaVersion === 1) return parsePlatformSessionSpikeV1(value); if (value.schemaVersion === 2) return parsePlatformSessionSpikeV2(value); - throw new Error('platform session spike schemaVersion must be 1 or 2'); + if (value.schemaVersion === 3) return parsePlatformSessionSpikeV3(value); + throw new Error('platform session spike schemaVersion must be 1, 2, or 3'); } /** Missing evidence is deliberately indistinguishable from insufficient evidence. */ @@ -195,18 +225,33 @@ export function evaluatePlatformSessionCapability( parsed.hostSessionSource !== 'hook_stdin' || parsed.hookApproval === 'approved'; const childInheritanceVerified = - parsed.schemaVersion === 2 && + parsed.schemaVersion !== 1 && (parsed.subagentInheritance === 'proven' || (parsed.subagentInheritance === 'not_applicable' && parsed.subagentInheritanceReason !== null)); - const hostVerified = + const commonEvidenceVerified = evidenceState === 'current' && - parsed.hostSessionObserved && - parsed.hostSessionSource !== 'none' && parsed.distinctClientWindows === 'proven' && parsed.commandPropagation === 'proven' && - childInheritanceVerified && + childInheritanceVerified; + const sessionMode = parsed.schemaVersion === 3 ? parsed.sessionMode : 'host'; + const hostVerified = + commonEvidenceVerified && + sessionMode === 'host' && + parsed.hostSessionObserved && + parsed.hostSessionSource !== 'none' && hostSourceApproved; + const explicitVerified = + commonEvidenceVerified && + parsed.schemaVersion === 3 && + sessionMode === 'explicit' && + parsed.hostSessionSource === 'none' && + !parsed.hostSessionObserved; + const releaseEvidence: PlatformReleaseEvidence = hostVerified + ? 'host_session_verified' + : explicitVerified + ? 'explicit_session_verified' + : 'unverified'; return { platform: parsed.platform, hostIdentity: hostVerified ? 'host_verified' : 'explicit_required', @@ -215,9 +260,12 @@ export function evaluatePlatformSessionCapability( subagentInheritance: parsed.subagentInheritance, hookApproval: parsed.hookApproval, evidenceState, + releaseEvidence, reason: hostVerified ? 'host identity, distinct windows, command propagation, and child inheritance are proven' - : capabilityReason(parsed, evidenceState, childInheritanceVerified), + : explicitVerified + ? 'explicit session isolation, command propagation, and child inheritance are proven' + : capabilityReason(parsed, evidenceState, childInheritanceVerified), }; } @@ -255,6 +303,9 @@ export function platformSpikeFreezeStatus( (platform) => capabilities.get(platform)?.hostIdentity === 'explicit_required', ); + const unverifiedPlatforms = SESSION_SPIKE_PLATFORMS.filter( + (platform) => capabilities.get(platform)?.releaseEvidence === 'unverified', + ); const evidenceMismatchPlatforms = SESSION_SPIKE_PLATFORMS.filter( (platform) => { const capability = capabilities.get(platform); @@ -264,9 +315,9 @@ export function platformSpikeFreezeStatus( return { missingPlatforms, explicitRequiredPlatforms, + unverifiedPlatforms, evidenceMismatchPlatforms, - ready: - missingPlatforms.length === 0 && explicitRequiredPlatforms.length === 0, + ready: missingPlatforms.length === 0 && unverifiedPlatforms.length === 0, }; } @@ -324,6 +375,52 @@ function parsePlatformSessionSpikeV2( }; } +function parsePlatformSessionSpikeV3( + value: Record, +): PlatformSessionSpikeV3 { + assertKnownKeys( + value, + [ + 'schemaVersion', + 'platform', + 'observedAt', + 'sessionMode', + 'hostSessionSource', + 'hostSessionObserved', + 'distinctClientWindows', + 'commandPropagation', + 'subagentInheritance', + 'subagentInheritanceReason', + 'hookApproval', + 'evidence', + ], + 'platform session spike', + ); + const common = parseCommonSpikeFields(value, 3); + assertSessionEvidenceMode(value.sessionMode); + if (value.sessionMode === 'host') { + if (common.hostSessionSource === 'none' || !common.hostSessionObserved) { + throw new Error('platform host session spike evidence is inconsistent'); + } + } else if ( + common.hostSessionSource !== 'none' || + common.hostSessionObserved || + common.hookApproval !== 'not_applicable' + ) { + throw new Error('platform explicit session spike evidence is inconsistent'); + } + return { + ...common, + schemaVersion: 3, + sessionMode: value.sessionMode, + subagentInheritanceReason: parseSubagentInheritanceReason( + common.subagentInheritance, + value.subagentInheritanceReason, + ), + evidence: parseEvidenceBinding(value.evidence), + }; +} + function parseCommonSpikeFields( value: Record, schemaVersion: 1, @@ -334,10 +431,21 @@ function parseCommonSpikeFields( ): Omit; function parseCommonSpikeFields( value: Record, - schemaVersion: 1 | 2, + schemaVersion: 3, +): Omit< + PlatformSessionSpikeV3, + 'sessionMode' | 'subagentInheritanceReason' | 'evidence' +>; +function parseCommonSpikeFields( + value: Record, + schemaVersion: 1 | 2 | 3, ): | PlatformSessionSpikeV1 - | Omit { + | Omit + | Omit< + PlatformSessionSpikeV3, + 'sessionMode' | 'subagentInheritanceReason' | 'evidence' + > { if (value.schemaVersion !== schemaVersion) { throw new Error( `platform session spike schemaVersion must be ${schemaVersion}`, @@ -370,7 +478,11 @@ function parseCommonSpikeFields( hookApproval: value.hookApproval, } as | PlatformSessionSpikeV1 - | Omit; + | Omit + | Omit< + PlatformSessionSpikeV3, + 'sessionMode' | 'subagentInheritanceReason' | 'evidence' + >; } function evidenceStateFor( @@ -408,6 +520,13 @@ function capabilityReason( if (spike.commandPropagation !== 'proven') { return 'command propagation is not proven; require --session or MANCODE_SESSION_ID'; } + if ( + spike.schemaVersion === 3 && + spike.sessionMode === 'explicit' && + spike.distinctClientWindows !== 'proven' + ) { + return 'explicit session isolation is not proven; require distinct active sessions'; + } return 'host identity is not fully proven; require --session or MANCODE_SESSION_ID'; } @@ -473,14 +592,22 @@ function parseSubagentInheritanceReason( return parseEvidenceText(value, 'subagentInheritanceReason'); } -function normalizeEphemeralHostKey(value: string | null): string | null { +function normalizeEphemeralSessionKey(value: string | null): string | null { if (value === null) return null; if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { - throw new Error('platform session spike host key is invalid'); + throw new Error('platform session spike window key is invalid'); } return value.trim(); } +function assertSessionEvidenceMode( + value: unknown, +): asserts value is SessionEvidenceMode { + if (value !== 'host' && value !== 'explicit') { + throw new Error('platform session spike sessionMode is invalid'); + } +} + function assertSessionSpikePlatform( value: unknown, ): asserts value is SessionSpikePlatform { diff --git a/src/runtime/project-write-barrier.ts b/src/runtime/project-write-barrier.ts new file mode 100644 index 0000000..325a1a8 --- /dev/null +++ b/src/runtime/project-write-barrier.ts @@ -0,0 +1,45 @@ +import type { Ulid } from '../context/ids.js'; +import { resolveLocalEntityHomeStore } from './entity-home-store.js'; +import { type LocalLockHandle, acquireEntityLocks } from './local-lock.js'; +import { listUnfinishedOperationJournals } from './operation-store.js'; +import type { ProjectRuntimeContext } from './project-runtime.js'; + +export const PROJECT_SCHEMA_LOCK = 'schema:project'; + +/** + * Serializes the start of ordinary writes with project-authority transitions. + * The caller keeps this lock until its prepared journal is durable, allowing a + * project upgrade to observe and reject every in-flight business operation. + */ +export async function acquireProjectWriteBarrier( + runtime: ProjectRuntimeContext, + operationId: Ulid, + now: Date, +): Promise { + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const [lock] = await acquireEntityLocks( + localStore, + operationId, + [PROJECT_SCHEMA_LOCK], + { now }, + ); + if (lock === undefined) { + throw new Error('MANCODE_LOCK_HELD'); + } + try { + const unfinished = await listUnfinishedOperationJournals(localStore); + if ( + unfinished.some((journal) => + journal.entityLocks.includes(PROJECT_SCHEMA_LOCK), + ) + ) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + return lock; + } catch (error) { + await lock.release().catch(() => undefined); + throw error; + } +} diff --git a/src/runtime/projection-outbox.ts b/src/runtime/projection-outbox.ts index 42e2370..09e9378 100644 --- a/src/runtime/projection-outbox.ts +++ b/src/runtime/projection-outbox.ts @@ -27,6 +27,7 @@ import { writeTeamEvent, } from '../team/events.js'; import { replaceFileAtomically } from './atomic-file.js'; +import type { OperationProjectionState } from './reconciler.js'; import { clearSessionTaskPointer, readSession, @@ -267,6 +268,35 @@ export async function listProjectionIntents( ); } +/** + * Inspects the projections actually declared for one operation. An operation + * with no projection intents has no projection repair work. + */ +export async function inspectOperationProjectionState( + projectRoot: string, + operationId: Ulid, +): Promise { + assertUlid(operationId, 'projection operationId'); + const state: OperationProjectionState = { + auditEvent: 'not_applicable', + sessionPointer: 'not_applicable', + cache: 'not_applicable', + }; + const intents = await listProjectionIntents(projectRoot, { + operationId, + includeTerminal: true, + }); + for (const intent of intents) { + const key = projectionStateKey(intent.target.kind); + const availability = + intent.state === 'superseded' + ? 'not_applicable' + : await inspectProjection(projectRoot, intent.target); + state[key] = mergeProjectionAvailability(state[key], availability); + } + return state; +} + /** Applies only durable, fully specified targets and leaves conflicts pending. */ export async function reconcileProjectionIntents( projectRoot: string, @@ -521,6 +551,32 @@ async function inspectProjection( } } +function projectionStateKey( + kind: ProjectionTargetV1['kind'], +): keyof OperationProjectionState { + switch (kind) { + case 'audit_event': + return 'auditEvent'; + case 'session_pointer': + return 'sessionPointer'; + case 'cache_invalidation': + return 'cache'; + } +} + +function mergeProjectionAvailability( + left: ProjectionAvailability, + right: ProjectionAvailability, +): ProjectionAvailability { + const priority: Record = { + not_applicable: 0, + present: 1, + missing: 2, + conflict: 3, + }; + return priority[right] > priority[left] ? right : left; +} + async function applyProjection( projectRoot: string, target: ProjectionTargetV1, @@ -621,15 +677,15 @@ async function inspectSessionProjection( return 'conflict'; } if (task.metadata.revision < target.taskRevision) return 'conflict'; - const terminal = isTerminalWorkflowStatus(task.metadata.status); + const clearsPointer = workflowRequiresClearedSession(task.metadata); if (target.action === 'clear') { - if (!terminal) return 'conflict'; + if (!clearsPointer) return 'conflict'; if (session.activeTaskRef === null) return 'present'; return sameTaskRef(session.activeTaskRef, target.taskRef) ? 'missing' : 'not_applicable'; } - if (terminal) { + if (clearsPointer) { if (session.activeTaskRef === null) return 'not_applicable'; return sameTaskRef(session.activeTaskRef, target.taskRef) ? 'missing' @@ -668,7 +724,7 @@ async function applySessionProjection( if (task.metadata.revision < target.taskRevision) return; if ( target.action === 'clear' || - isTerminalWorkflowStatus(task.metadata.status) + workflowRequiresClearedSession(task.metadata) ) { await clearSessionTaskPointer(projectRoot, target.sessionId, { expectedTaskRef: target.taskRef, @@ -843,6 +899,17 @@ function isTerminalWorkflowStatus(status: string): boolean { ); } +function workflowRequiresClearedSession(metadata: { + status: string; + governance: { planDecision: string | null }; +}): boolean { + return ( + isTerminalWorkflowStatus(metadata.status) || + (metadata.status === 'planned' && + metadata.governance.planDecision === 'plan_only') + ); +} + async function pathExists(target: string): Promise { try { await lstat(target); diff --git a/src/runtime/task-operation.ts b/src/runtime/task-operation.ts index ebe1102..10847e6 100644 --- a/src/runtime/task-operation.ts +++ b/src/runtime/task-operation.ts @@ -1,11 +1,24 @@ import { execFile as execFileCallback } from 'node:child_process'; -import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { taskAggregateDigest } from '../context/aggregate.js'; -import { assertCompatibilityGate } from '../context/compatibility.js'; +import { + CURRENT_WRITER_CAPABILITIES, + type CompatibilityOperation, + assertCompatibilityGate, +} from '../context/compatibility.js'; import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; import { scanLegacyAuthority } from '../context/layout.js'; +import { managedAdapterNames } from '../context/manifest.js'; import { type StoredCoordinationSnapshot, type StoredProjectSnapshot, @@ -18,6 +31,7 @@ import { sameTaskRef, } from '../context/task-ref.js'; import { assertParentWorkflowRelation } from '../context/workflow-metadata.js'; +import { inspectV3AdapterVersions } from '../installers/v3-adapter.js'; import { readSharedActorProfile } from '../team/actor.js'; import { type AuthorizationAction, @@ -49,6 +63,7 @@ import { } from './local-lock.js'; import { armOperationCrashAfterVisibleWrite, + pauseIfOperationLockInjectedForTesting, throwIfDeferredOperationCrashInjected, throwIfOperationCrashInjected, } from './operation-crash-injection.js'; @@ -64,10 +79,13 @@ import type { import { type OperationRecoveryActionV1, TASK_AUTHORITY_FILE_NAMES, + type TaskArchiveRecoveryAction, type TaskAuthorityFileName, assertOperationRecoveryPayloadCoversJournal, operationRecoveryPayloadDigest, parseOperationRecoveryPayload, + taskArchiveDigest, + taskArchiveManifest, } from './operation-recovery-payload.js'; import { writeOperationRecoveryPayload } from './operation-recovery-store.js'; import { @@ -79,6 +97,7 @@ import { readCheckoutCodeHead, readProjectRuntimeContext, } from './project-runtime.js'; +import { acquireProjectWriteBarrier } from './project-write-barrier.js'; import { type SessionStateV1, readSession } from './session.js'; import { assertTaskHeadFenceMatchesAggregate } from './task-head-fence.js'; @@ -90,6 +109,10 @@ export interface OpenV3TaskOperationInput { sessionId: Ulid; expectedTaskRevision: number; operationId?: Ulid; + compatibilityOperation?: Extract< + CompatibilityOperation, + 'v3_business_write' | 'reframe' + >; /** Additional entities use the same task-store lock family. */ extraEntityLocks?: string[]; /** @@ -117,6 +140,7 @@ export interface OpenedV3TaskOperation { codeHead: string | null; entityLocks: string[]; renewLocks(): Promise; + releaseProjectBarrier(): Promise; release(): Promise; } @@ -159,43 +183,60 @@ export async function openV3TaskOperation( const operationId = input.operationId ?? createUlid(now.getTime()); assertUlid(operationId, 'task operation operationId'); const runtime = await readProjectRuntimeContext(projectRoot); - const store = new V3ContextStore(projectRoot); - const [project, legacy, session] = await Promise.all([ - store.readProjectSnapshot(), - scanLegacyAuthority(projectRoot), - readSession(projectRoot, input.sessionId), - ]); - if (session === null || session.status !== 'active') { - throw new Error('MANCODE_SESSION_NOT_FOUND'); - } - assertCompatibilityGate({ - manifest: project.manifest, - expectedSchemaEpoch: project.manifest.epoch, - readerVersion: VERSION, - writerVersion: VERSION, - adapterVersions: project.manifest.managedAdapters, - currentLegacyBaseline: legacy.baseline, - legacyAuthorityPresent: legacy.authorityPresent, - operation: 'v3_business_write', - }); - const homeStore = resolveTaskEntityHomeStore( - runtime.entityHomeStoreContext, - taskRef, - ); - const taskKey = taskEntityKey(taskRef); - const entityLocks = uniqueEntityLocks([ - taskKey, - ...(input.extraEntityLocks ?? []), - ]); - const locks = await acquireOperationEntityLocks( + const projectBarrier = await acquireProjectWriteBarrier( + runtime, operationId, - [ - { store: homeStore, entityLockKeys: entityLocks }, - ...(input.additionalEntityLockTargets ?? []), - ], - { now }, + now, ); + let projectBarrierReleased = false; + const releaseProjectBarrier = async (): Promise => { + if (projectBarrierReleased) return; + await projectBarrier.release(); + projectBarrierReleased = true; + }; + const store = new V3ContextStore(projectRoot); + let locks: LocalLockHandle[] = []; try { + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + const taskKey = taskEntityKey(taskRef); + const entityLocks = uniqueEntityLocks([ + taskKey, + ...(input.extraEntityLocks ?? []), + ]); + const project = await store.readProjectSnapshot(); + const [legacy, session, adapterVersions] = await Promise.all([ + scanLegacyAuthority(projectRoot), + readSession(projectRoot, input.sessionId), + inspectV3AdapterVersions( + projectRoot, + managedAdapterNames(project.manifest.managedAdapters), + ), + ]); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + assertCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: input.compatibilityOperation ?? 'v3_business_write', + }); + locks = await acquireOperationEntityLocks( + operationId, + [ + { store: homeStore, entityLockKeys: entityLocks }, + ...(input.additionalEntityLockTargets ?? []), + ], + { now }, + ); if (taskRef.namespace === 'shared') { await assertTransportCoordinationWriteAllowed(homeStore, project.config); } @@ -254,14 +295,22 @@ export async function openV3TaskOperation( codeHead, entityLocks, async renewLocks(): Promise { - await Promise.all(locks.map((lock) => lock.renew())); + await Promise.all([ + ...locks.map((lock) => lock.renew()), + ...(projectBarrierReleased ? [] : [projectBarrier.renew()]), + ]); + }, + async releaseProjectBarrier(): Promise { + await releaseProjectBarrier(); }, async release(): Promise { await releaseLocks(locks); + await releaseProjectBarrier(); }, }; } catch (error) { await releaseLocks(locks); + await releaseProjectBarrier().catch(() => undefined); await recordV3ErrorDiagnostic(projectRoot, error).catch(() => undefined); throw error; } @@ -418,7 +467,13 @@ export async function createTaskOperationJournal( context.homeStore, journal, ); + await context.releaseProjectBarrier(); throwIfOperationCrashInjected(input.type, 'prepared'); + const lockPause = pauseIfOperationLockInjectedForTesting( + context.operationId, + 'entity_locks_held', + ); + if (lockPause !== null) await lockPause; return created; } @@ -511,6 +566,127 @@ export async function writeTaskAuthorityFile( ); } +export async function writeTaskArchive( + context: OpenedV3TaskOperation, + action: TaskArchiveRecoveryAction, +): Promise { + if ( + !sameTaskRef(action.taskRef, context.taskRef) || + action.archiveId !== context.operationId + ) { + throw new Error('MANCODE_REFRAME_ARCHIVE_OPERATION_MISMATCH'); + } + await writeTaskArchiveAtRoot( + context.task.location.taskRoot, + context.operationId, + action, + ); +} + +/** Atomically publishes one immutable operation-owned archive directory. */ +export async function writeTaskArchiveAtRoot( + taskRoot: string, + operationId: Ulid, + action: TaskArchiveRecoveryAction, +): Promise { + assertUlid(operationId, 'task archive operationId'); + if (action.archiveId !== operationId) { + throw new Error('MANCODE_REFRAME_ARCHIVE_OPERATION_MISMATCH'); + } + await assertSafeTaskDirectory(taskRoot); + const archiveRoot = await ensureSafeTaskChildDirectory(taskRoot, 'archives'); + if ((await readTaskArchiveDigestAtRoot(taskRoot, action)) !== null) return; + + const staging = path.join(archiveRoot, `.${operationId}.staging`); + const target = path.join(archiveRoot, operationId); + await removeSafeArchiveStaging(staging); + await mkdir(staging); + try { + await writeFile( + path.join(staging, 'archive.json'), + serializeTaskAuthority(taskArchiveManifest(action)), + { encoding: 'utf8', flag: 'wx' }, + ); + await writeFile( + path.join(staging, 'requirements.json'), + action.requirementsContent, + { encoding: 'utf8', flag: 'wx' }, + ); + if (action.planContent !== null) { + await writeFile(path.join(staging, 'plan.md'), action.planContent, { + encoding: 'utf8', + flag: 'wx', + }); + } + await rename(staging, target); + } catch (error) { + await removeSafeArchiveStaging(staging); + if (isAlreadyExists(error) || isDirectoryNotEmpty(error)) { + const existing = await readTaskArchiveDigestAtRoot(taskRoot, action); + if (existing === taskArchiveDigest(action)) return; + throw new Error('MANCODE_REFRAME_ARCHIVE_CONFLICT'); + } + throw error; + } +} + +export async function readTaskArchiveDigestAtRoot( + taskRoot: string, + action: TaskArchiveRecoveryAction, +): Promise { + await assertSafeTaskDirectory(taskRoot); + const archiveRoot = path.join(taskRoot, 'archives'); + try { + const rootEntry = await lstat(archiveRoot); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + const target = path.join(archiveRoot, action.archiveId); + try { + const targetEntry = await lstat(target); + if (!targetEntry.isDirectory() || targetEntry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + const expectedNames = [ + 'archive.json', + 'requirements.json', + ...(action.planContent === null ? [] : ['plan.md']), + ].sort(); + const names = (await readdir(target)).sort(); + if ( + names.length !== expectedNames.length || + names.some((name, index) => name !== expectedNames[index]) + ) { + throw new Error('MANCODE_REFRAME_ARCHIVE_CONFLICT'); + } + const archiveContent = await readImmutableArchiveFile( + path.join(target, 'archive.json'), + ); + const requirementsContent = await readImmutableArchiveFile( + path.join(target, 'requirements.json'), + ); + const planContent = + action.planContent === null + ? null + : await readImmutableArchiveFile(path.join(target, 'plan.md')); + if ( + archiveContent !== serializeTaskAuthority(taskArchiveManifest(action)) || + requirementsContent !== action.requirementsContent || + planContent !== action.planContent + ) { + throw new Error('MANCODE_REFRAME_ARCHIVE_CONFLICT'); + } + return taskArchiveDigest(action); +} + /** Fixed-name authority reader used by forward repair; it never follows links. */ export async function readTaskAuthorityFileAtRoot( taskRoot: string, @@ -738,7 +914,7 @@ async function assertSafeTaskDirectory(taskRoot: string): Promise { async function ensureSafeTaskChildDirectory( taskRoot: string, - child: 'checkpoints', + child: 'checkpoints' | 'archives', ): Promise { const directory = path.join(taskRoot, child); try { @@ -753,6 +929,36 @@ async function ensureSafeTaskChildDirectory( return directory; } +async function readImmutableArchiveFile(target: string): Promise { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; +} + +async function removeSafeArchiveStaging(staging: string): Promise { + try { + const entry = await lstat(staging); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + await rm(staging, { recursive: true }); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + async function readImmutableCheckpoint(target: string): Promise { let before: Awaited>; try { @@ -812,6 +1018,15 @@ function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { ); } +function isDirectoryNotEmpty(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' + ); +} + function isNotFound(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === 'object' && diff --git a/src/system/requirements-ledger.ts b/src/system/requirements-ledger.ts index 9561c82..295fe84 100644 --- a/src/system/requirements-ledger.ts +++ b/src/system/requirements-ledger.ts @@ -54,7 +54,10 @@ export const REQUIREMENT_DIMENSIONS: RequirementDimension[] = [ 'security', ]; -export function parseRequirementsLedger(raw: string): RequirementsLedger { +export function parseRequirementsLedger( + raw: string, + options: { allowIncomplete?: boolean } = {}, +): RequirementsLedger { let value: unknown; try { value = JSON.parse(raw); @@ -108,18 +111,22 @@ export function parseRequirementsLedger(raw: string): RequirementsLedger { const missingDimensions = REQUIREMENT_DIMENSIONS.filter( (dimension) => !coverageDimensions.has(dimension), ); - if (missingDimensions.length > 0) { + if (!options.allowIncomplete && missingDimensions.length > 0) { throw new Error( `requirements coverage is missing: ${missingDimensions.join(', ')}`, ); } - if ((value.confirmedScope as string[]).length === 0) { + if ( + !options.allowIncomplete && + (value.confirmedScope as string[]).length === 0 + ) { throw new Error('requirements confirmedScope must not be empty'); } const stackCoverage = coverage.find( (item) => item.dimension === 'technical_stack', ); if ( + !options.allowIncomplete && stackCoverage?.status !== 'not_applicable' && (value.technicalDecisions as string[]).length === 0 ) { @@ -129,7 +136,7 @@ export function parseRequirementsLedger(raw: string): RequirementsLedger { } if ( !Array.isArray(value.acceptanceCriteria) || - value.acceptanceCriteria.length === 0 + (!options.allowIncomplete && value.acceptanceCriteria.length === 0) ) { throw new Error('requirements need at least one acceptance criterion'); } @@ -157,7 +164,10 @@ export function parseRequirementsLedger(raw: string): RequirementsLedger { method: item.method, }; }); - if (!acceptanceCriteria.some((item) => item.required)) { + if ( + !options.allowIncomplete && + !acceptanceCriteria.some((item) => item.required) + ) { throw new Error( 'requirements need at least one required acceptance criterion', ); diff --git a/src/system/scan-aesthetics.ts b/src/system/scan-aesthetics.ts index b767ebc..b3a5689 100644 --- a/src/system/scan-aesthetics.ts +++ b/src/system/scan-aesthetics.ts @@ -5,8 +5,8 @@ import path from 'node:path'; /** * 审美扫描结果。 * - * 结构对齐 docs/06-aesthetics.md §9.3 和 docs/13-scanning.md §8.2。 - * MVP-2 beta 提取 Tailwind colors/fonts、darkMode、uiLibrary、 + * 结构对齐 docs/project-intelligence.md。 + * 当前实现提取 Tailwind colors/fonts、darkMode、uiLibrary、 * 常见组件名和 CSS custom properties。 */ export interface AestheticsTokens { @@ -28,12 +28,12 @@ const MAX_COMPONENT_FILES = 2000; /** * 扫描项目审美 token。 * - * 职责(docs/13-scanning.md §4 + docs/06-aesthetics.md §3): + * 职责见 docs/project-intelligence.md: * 1. 检测 tailwind.config.{js,ts,cjs,mjs},提取 colors 和 fontFamily * 2. 检测 darkMode 策略 * 3. matchLevel: 检测到配置、CSS token 或组件 → high;只有 UI 依赖 → low;否则 none * - * MVP-2 beta 仍不做: + * 当前仍不做: * - theme.json / tokens.json 等非 Tailwind token 文件 * * @param projectRoot 项目根目录 diff --git a/src/system/workflow.ts b/src/system/workflow.ts index caeba7e..6ceb9be 100644 --- a/src/system/workflow.ts +++ b/src/system/workflow.ts @@ -23,7 +23,7 @@ import { * Workflow 元数据。 * * 每个 /man、/manba 或 /manteam 任务在 .mancode/workflows//metadata.json 里 - * 存一份,记录任务进度(docs/14-orchestration.md §2.1)。 + * 存一份,记录任务进度(见 docs/workflows.md)。 */ export interface WorkflowMeta { /** YYYYMMDD-HHMMSS- 格式 */ diff --git a/src/team/authorization.ts b/src/team/authorization.ts index 56d3f78..d084515 100644 --- a/src/team/authorization.ts +++ b/src/team/authorization.ts @@ -22,6 +22,7 @@ export type AuthorizationAction = | 'handoff_offer_cancel' | 'handoff_accept_reject' | 'review_skip_or_waiver' + | 'project_maintenance' | 'team_policy_config_transport' | 'actor_profile_publish' | 'confirmed_decision_publish'; @@ -156,6 +157,7 @@ const AUTHORIZATION_ACTIONS = new Set([ 'handoff_offer_cancel', 'handoff_accept_reject', 'review_skip_or_waiver', + 'project_maintenance', 'team_policy_config_transport', 'actor_profile_publish', 'confirmed_decision_publish', @@ -224,6 +226,11 @@ export const AUTHORIZATION_MATRIX: readonly AuthorizationMatrixEntry[] = [ actorRule: 'current task owner', requiredGuards: ['reason', 'P0/legacy_unknown waiver prohibition'], }, + { + action: 'project_maintenance', + actorRule: 'active local session actor', + requiredGuards: ['expected revision', 'explicit confirmation'], + }, { action: 'team_policy_config_transport', actorRule: 'joined actor', @@ -251,7 +258,10 @@ export function evaluateAuthorization( validateRequest(input); const failures: AuthorizationFailureCode[] = []; requireActiveSession(input, failures); - if (input.action !== 'local_workflow_mutation') { + if ( + input.action !== 'local_workflow_mutation' && + input.action !== 'project_maintenance' + ) { if (input.action !== 'actor_profile_publish' && !input.joined) { failures.push('MANCODE_JOIN_REQUIRED'); } @@ -423,6 +433,15 @@ export function evaluateAuthorization( failures.push('MANCODE_WAIVER_FORBIDDEN'); } break; + case 'project_maintenance': + requireExpectedRevision(input, failures); + requireCondition( + input, + 'explicitConfirmation', + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + failures, + ); + break; case 'team_policy_config_transport': requireExpectedRevision(input, failures); requireCondition( diff --git a/src/team/checkpoints.ts b/src/team/checkpoints.ts index ea07c79..05418e9 100644 --- a/src/team/checkpoints.ts +++ b/src/team/checkpoints.ts @@ -7,6 +7,7 @@ import { assertKnownKeys, assertRecord } from '../context/validation.js'; export type CheckpointKind = | 'plan_confirmed' | 'scope_changed' + | 'requirements_reframed' | 'diagnostic_started' | 'base_changed' | 'verification_completed' @@ -46,6 +47,7 @@ export interface CheckpointV1 { const CHECKPOINT_KINDS = new Set([ 'plan_confirmed', 'scope_changed', + 'requirements_reframed', 'diagnostic_started', 'base_changed', 'verification_completed', diff --git a/src/team/git-ref-client.ts b/src/team/git-ref-client.ts index f1069a7..6726a29 100644 --- a/src/team/git-ref-client.ts +++ b/src/team/git-ref-client.ts @@ -1,4 +1,4 @@ -import type { SchemaManifestV1 } from '../context/manifest.js'; +import type { SchemaManifest } from '../context/manifest.js'; import { GitRefTeamManifestStore } from './git-ref-transport.js'; import { type ProjectConfigV1, projectConfigDigest } from './policy.js'; @@ -6,7 +6,7 @@ import { type ProjectConfigV1, projectConfigDigest } from './policy.js'; export function createGitRefTeamManifestStore( projectRoot: string, config: ProjectConfigV1, - manifest: SchemaManifestV1, + manifest: SchemaManifest, ): GitRefTeamManifestStore { if (config.transport.mode !== 'git-ref' || config.transport.remote === null) { throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); diff --git a/src/templates/agents/film-analyst-defense.ts b/src/templates/agents/film-analyst-defense.ts index 1342346..77aedf5 100644 --- a/src/templates/agents/film-analyst-defense.ts +++ b/src/templates/agents/film-analyst-defense.ts @@ -1,7 +1,7 @@ import type { AgentSpec } from './index.js'; /** - * Film Analyst #2(录像分析师·防守)agent — 边界/安全审查(docs/05-agents.md §5)。 + * Film Analyst #2(录像分析师·防守)agent — 边界/安全审查(见 docs/workflows.md)。 * * 触发:/man Step 8、/manteam Step 8。 * 审查维度:边界条件、安全(XSS/SQL/越权/敏感信息)、性能、资源管理、错误恢复;UI 任务还要审查可访问性与失败路径。 diff --git a/src/templates/agents/film-analyst-offense.ts b/src/templates/agents/film-analyst-offense.ts index 4896bca..f836a46 100644 --- a/src/templates/agents/film-analyst-offense.ts +++ b/src/templates/agents/film-analyst-offense.ts @@ -1,7 +1,7 @@ import type { AgentSpec } from './index.js'; /** - * Film Analyst #1(录像分析师·进攻)agent — 代码质量审查(docs/05-agents.md §4)。 + * Film Analyst #1(录像分析师·进攻)agent — 代码质量审查(见 docs/workflows.md)。 * * 触发:/man Step 7、/manteam Step 7。 * 审查维度:可读性、可维护性、风格一致性、DRY、YAGNI、复杂度、错误处理;UI 任务还要审查交互与视觉层级。 diff --git a/src/templates/agents/head-coach.ts b/src/templates/agents/head-coach.ts index 79267ea..eeb9b96 100644 --- a/src/templates/agents/head-coach.ts +++ b/src/templates/agents/head-coach.ts @@ -1,11 +1,11 @@ import type { AgentSpec } from './index.js'; /** - * Head Coach(主教练)agent — 主决策者(docs/05-agents.md §3 + docs/11)。 + * Head Coach(主教练)agent — 主决策者(见 docs/workflows.md 和 docs/engineering.md)。 * * 触发:solo 主代理;/man Step 3、5、7、9。 * 职责:整合 Scout Report → 写 plan → 实施 → 自测 → 修复 → 收尾。 - * 包含完整的 5 条铁律和 Phase 1-3 执行协议(docs/11)。 + * 包含完整的 5 条铁律和执行协议(见 docs/engineering.md)。 */ export const HEAD_COACH_AGENT: AgentSpec = { name: 'head-coach', @@ -81,6 +81,31 @@ export const HEAD_COACH_AGENT: AgentSpec = { - **验证依赖**:用某库前确认项目已检测到的 manifest、锁文件或现有 import 中确实存在 - **委托子代理**:深度探索、大范围搜索 → Scout agent +### Solo 升级建议 + +Solo 中出现以下任一情况时,停在当前动作并建议改用 \`/man\`,同时说明命中的事实: + +- 平台入口或流程不一致; +- semantic owner 不清; +- source of truth 或唯一 writer 不清; +- status、contract、policy 或 workflow transition 的语义会变化; +- scope、架构、成本或验收出现跨文件/跨模块变化; +- 需要历史兼容、迁移、跨平台或团队协调证据。 + +这只是建议。除非用户明确发起新的 \`/man\` workflow,否则不自动改变 mode、current step、policy 或任何 authority。 + +### 停止并重新对齐 + +执行现有 requirements/plan 时,如果新证据推翻已确认的目标、owner、source of truth 或验收,出现会导致不同语义的跨平台入口、stale adapter、不兼容 writer、未完成 operation、active child、open handoff、active solo assignment,或需求已超出当前 scope,立即停止并返回: + +\`\`\`text +NEEDS_REALIGNMENT +reason: MANCODE_REFRAME_REQUIRED +trigger: <具体事实> +\`\`\` + +这是只读诊断。保留 requirements、plan、review/verification ledger、claims、handoff 和 metadata;不调用通用 \`workflow update\`,不写 blocked/currentStep/planning,不归档旧文件,不释放 claim,也不宣称已经回到 Step 2。 + --- ## 4. Phase 2:外科手术式执行 diff --git a/src/templates/agents/plan-coach.ts b/src/templates/agents/plan-coach.ts index f48499a..6797db3 100644 --- a/src/templates/agents/plan-coach.ts +++ b/src/templates/agents/plan-coach.ts @@ -27,11 +27,34 @@ export const PLAN_COACH_AGENT: AgentSpec = { 先读取 \`requirements.json\`,以其中的 confirmedScope、excludedScope、technicalDecisions、defaults、blockingUnknowns、coverage 和 acceptanceCriteria 为权威输入;\`requirements.md\` 只用于阅读。逐项检查 platform、core_scope、technical_stack、data_and_persistence、performance、compatibility、security 的状态和理由是否与事实一致。检查需求是否覆盖任务实际适用的用户目标、核心流程、首期范围、排除项、技术与运行约束、数据/状态/集成、关键性能/兼容性/安全要求和验收标准。blockingUnknowns 非空、coverage 用无根据的 not_applicable 掩盖决策、核心行为缺少验收 ID,或文档与结构化输入矛盾时必须返回 NEEDS_CLARIFICATION。 +同时执行方案完整性检查: + +1. 所有候选方案必须解决同一个用户目标、同一验收边界和同一 scope;偷换目标或把问题改成另一个问题时返回 \`NEEDS_CLARIFICATION\`。 +2. 每个方案必须说明复杂度由谁承担以及可观察成本,承担者可以是实现代码、迁移、运行维护、用户操作、兼容层或测试;不能只写“更简单”。 +3. 必须给出唯一 recommendation,并说明拒绝其他方向的主要理由;不能把多个未决方向原样交给用户。 +4. 简单任务没有真实替代方案时只列一个明显可行方向,并解释为什么不存在真实替代;不得制造伪选项。这个单一方向仍必须成为 recommendation 并有 stop conditions。 + +Scout 若给出两个同等候选 semantic owner、无法确定 authority/writer,或 evidence 与 confirmed requirements 冲突,不能自行选一个继续。 + 只能返回两种结果: 1. \`NEEDS_CLARIFICATION\`:存在 blocking 缺口时,不生成计划。列出缺失决策、对计划的影响、2–3 个可行选项、明确推荐和建议问题。 2. \`READY_FOR_PLAN\`:没有 blocking 缺口时,输出标记后再生成完整计划。defaultable 细节必须作为显式默认值写入计划。 +\`READY_FOR_PLAN\` 先返回以下机器可辨认的决策摘要,再返回 Markdown 计划: + +\`\`\`text +READY_FOR_PLAN +goal: +options: + - id: + solves: + complexity_bearer: + tradeoffs: +recommendation: +stop_conditions: +\`\`\` + ## 输出格式 \`\`\`markdown @@ -56,8 +79,26 @@ export const PLAN_COACH_AGENT: AgentSpec = { ## 验证计划 - 把每个 required 验收 ID 映射到 automated / manual / hybrid 验证,不得用页面加载或代码阅读代替核心交互 - build / lint / typecheck / test / smoke test +## Domain Matrix(仅高风险任务) +| Domain | Current behavior/evidence | Candidate owner | Source of truth | Contract/state impact | Compatibility/history | Validation | Rollback/stop | +| --- | --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | | ## 预估 \`\`\` -如果任务不应继续实施,返回 NEEDS_CLARIFICATION,不要用一份看似完整的计划掩盖缺失需求。`, +Domain Matrix 只是高风险 \`plan.md\` 的可选章节,不是新的 authority。入口/流程跨平台不一致、owner/source of truth 不清、状态或 contract 语义变化、跨 workflow/child/team/transport,或迁移/兼容影响超过一个版本时加入;普通局部任务省略。 + +## 停止并重新对齐 + +若新证据推翻已确认的目标、owner、source of truth 或验收,发现平台入口产生不同语义,需要改变 status/contract/policy/workflow transition 的含义,发现 stale adapter、不兼容 writer、未完成 operation、active child、open handoff、active solo assignment,或用户变化超出当前 requirements/plan scope,不返回 \`NEEDS_CLARIFICATION\` 或计划,而只返回: + +\`\`\`text +NEEDS_REALIGNMENT +reason: MANCODE_REFRAME_REQUIRED +trigger: <具体事实> +\`\`\` + +这是只读诊断:保留现有 requirements、plan、ledgers、claims、handoff 和 metadata;不调用通用 \`workflow update\`,不写 blocked/currentStep/planning,不归档或释放任何 authority,也不宣称已回到 Step 2。重复检查必须返回同一类诊断,而不能把它升级为持久 blocker。 + +普通 blocking 决策缺口返回 NEEDS_CLARIFICATION;只有命中上述已确认契约失效条件才返回 NEEDS_REALIGNMENT。两种情况都不要用一份看似完整的计划掩盖问题。`, }; diff --git a/src/templates/agents/scout.ts b/src/templates/agents/scout.ts index 76a2096..383257f 100644 --- a/src/templates/agents/scout.ts +++ b/src/templates/agents/scout.ts @@ -1,7 +1,7 @@ import type { AgentSpec } from './index.js'; /** - * Scout(球探)agent — 调研代码库(docs/05-agents.md §2)。 + * Scout(球探)agent — 调研代码库(见 docs/workflows.md)。 * * 触发:/man Step 1。 * 职责:找相似实现、可复用资源、风险点,输出 Scout Report 给 Head Coach。 @@ -48,6 +48,14 @@ export const SCOUT_AGENT: AgentSpec = { - README / docs / STYLE.md 是否有相关说明 - 是否有相关的设计文档 +5. **治理证据** + - 记录至少一个可复现的当前行为,并引用仓库路径、测试或实际命令;只有推测时明确写 unknown + - 区分候选 semantic owner 与最终 owner;写明置信度和未解决冲突,不替 Head Coach 猜结论 + - 区分 source of truth 与 derived copy;adapter、cache 和 Markdown 默认不是任务状态 authority + - 检查旧 workflow、legacy fixture、transport、迁移和 rollback;不受影响的项也明确写 no change + +四个治理小节按相关性填写。不能从项目事实验证时可以省略,但不得为了填满模板编造 owner 或 source of truth;一旦写 Current Behavior Evidence,就必须同时给出可复现观察与证据。 + ## 输出格式(严格遵守) \`\`\`markdown @@ -56,6 +64,28 @@ export const SCOUT_AGENT: AgentSpec = { ## 项目 Profile - 项目类型 / 语言 / framework / source roots / manifests / 可用验证 / UI 资产 / 置信度 +## Current Behavior Evidence +- Observation: <当前真实行为;未知时写 unknown,不把推测写成事实> +- Evidence: \`\` +- Reproduction/validation: <如何复现或验证> + +## Candidate Semantic Owner +- Candidate: <模块、实体或命令> +- Confidence: high | medium | low +- Unresolved conflict: + +## Source of Truth +- Authority: <结构化文件、ledger、manifest 或外部系统> +- Readers: <读取方> +- Writers: <唯一写入路径或 operation> +- Derived copies: + +## Historical / Compatibility Impact +- Existing workflows: <影响或 no change> +- Legacy fixtures: <影响或 no change> +- Transport/platform: <影响或 no change> +- Migration/rollback: <需要的证据或 no change> + ## 相似实现 - \`/:\` — 简述可复用的逻辑 @@ -75,6 +105,18 @@ export const SCOUT_AGENT: AgentSpec = { - 是否需要支持 X?(请 Head Coach 确认) \`\`\` +## 停止并重新对齐 + +新证据若推翻已确认的目标、semantic owner、source of truth 或验收,或暴露会产生不同语义的跨平台入口、status/contract/policy/transition 语义变化,立即停止调研后的推进,只返回: + +\`\`\`text +NEEDS_REALIGNMENT +reason: MANCODE_REFRAME_REQUIRED +trigger: <被新证据推翻或超出当前 requirements/plan 的事实> +\`\`\` + +这是只读诊断。不要调用通用 \`workflow update\`,不要写 metadata、step、policy、requirements、plan、claim 或 handoff,也不要归档文件、释放 claim 或宣称已经回到 Step 2。保留当前 authority,等待用户显式选择新的 \`/man\` workflow 或受支持的 reframe operation。 + ## 工具使用 - 用 Grep / Glob 找代码,不要 cat 整个文件 diff --git a/src/templates/defaults.ts b/src/templates/defaults.ts index 40ffd4e..61a7d1f 100644 --- a/src/templates/defaults.ts +++ b/src/templates/defaults.ts @@ -1,7 +1,7 @@ /** * 默认的 .mancode/config.json 内容。 * - * 见 docs/08-cli-spec.md §11.1 + * 见 docs/workflows.md。 */ export const DEFAULT_CONFIG = { version: '1.0.0', @@ -25,7 +25,7 @@ export const DEFAULT_CONFIG = { * 空的 style-tokens.json(非前端项目或 matchLevel=none 时用)。 * * 结构对齐 src/system/scan-aesthetics.ts 的 AestheticsTokens。 - * 审美扫描见 docs/13-scanning.md。 + * 审美扫描见 docs/project-intelligence.md。 */ export const EMPTY_STYLE_TOKENS = { version: '1.0.0', diff --git a/src/templates/inline.ts b/src/templates/inline.ts index 88ef0ce..36be0ee 100644 --- a/src/templates/inline.ts +++ b/src/templates/inline.ts @@ -307,9 +307,31 @@ export const SOLO_SKILL = `# mancode · solo mode 1. 读取对应 workflow 的 \`requirements.md\` 和 \`plan.md\`,确认 taskId 与 planVersion。 2. 开工前回执目标、技术方案、包含范围、排除范围、验证方式和残余假设。 3. 只按计划轻量实施;不重新规划、不调用 Film Reviewer、不创建新的 /man workflow。 -4. 发现会改变架构、范围或验收的缺口时暂停并询问,不自行扩大计划。 +4. 发现会改变架构、范围或验收的缺口时按下方 realignment 契约停止,不自行扩大计划。 5. 完成最窄有效验证和一次受限 diff 自检后,运行 \`mancode workflow handoff --complete\`;由 CLI 原子清理 \`activeSoloPlan\`、把 workflow 标记 completed 并移出 Active Plans,保留计划文件。 +### 何时建议升级到 /man +以下任一情况出现时,停止当前动作并建议 \`/man\`,说明具体原因: +1. 平台入口或流程不一致 +2. semantic owner 不清 +3. source of truth 或唯一 writer 不清 +4. status、contract、policy 或 workflow transition 的语义会变化 +5. scope、架构、成本或验收出现跨文件/跨模块变化 +6. 需要历史兼容、迁移、跨平台或团队协调证据 + +这只是建议;用户未明确发起新的 \`/man\` workflow 时,不自动改变 mode、current step、policy 或 authority。 + +### 停止并重新对齐 +执行已有 requirements/plan 时,如果新证据推翻已确认的目标、owner、source of truth 或验收,平台入口会产生不同语义,发现 stale adapter、不兼容 writer、未完成 operation、active child、open handoff、active solo assignment,或变化已超出当前 scope,只返回: + +\`\`\`text +NEEDS_REALIGNMENT +reason: MANCODE_REFRAME_REQUIRED +trigger: <具体事实> +\`\`\` + +这是只读诊断。保留 requirements、plan、review/verification ledger、claims、handoff 和 metadata;不调用通用 \`workflow update\`,不写 blocked/currentStep/planning,不归档旧文件,不释放 claim,不取消 handoff,也不宣称已回到 Step 2。 + ### UI 任务(条件执行) 仅当 project-profile 确认有 UI 资产且任务确实涉及界面时: 1. UserPromptSubmit hook 会注入项目审美 token(如有) @@ -330,7 +352,7 @@ export const SOLO_SKILL = `# mancode · solo mode - 只做一次,只看本次 diff、需求和直接受影响路径;不调用额外 reviewer,不生成审查报告,不重复巡检。 - 验证失败、行为回归或遗留 debug 可直接修复并复验;复验不是新一轮 review。 - 命名、可读性、DRY、loading/error 形式等建议不自动扩大改动;与需求无关时不输出。 -- 只有鉴权、支付、敏感数据、迁移/删除、公开 API、未可信输入、并发或基础设施等硬风险出现时,才用一句话建议 \`/man\`;用户说“继续 solo”即可继续。 +- 鉴权、支付、敏感数据、删除、公开 API、未可信输入、并发或基础设施等硬风险同样属于升级信号;若没有触发 realignment,用户明确选择继续 solo 后可以继续。 ## 你的风格 diff --git a/src/templates/skills/man.ts b/src/templates/skills/man.ts index 1538ada..92f1cdf 100644 --- a/src/templates/skills/man.ts +++ b/src/templates/skills/man.ts @@ -19,7 +19,7 @@ export const MAN_SKILL: SkillSpec = { ### Step 1: Scout 调研 -调用 \`scout\`,写 \`scout-report.md\`。报告必须包含“**不确定的地方**”。运行 \`mancode workflow update --step 2\`。 +调用 \`scout\`,写 \`scout-report.md\`。报告必须包含“**不确定的地方**”,并按可验证事实补充 Current Behavior Evidence、Candidate Semantic Owner、Source of Truth、Historical / Compatibility Impact;字段不相关或无法验证时可以省略,不能编造。Current Behavior Evidence 至少有一个可复现观察和仓库路径、测试或命令证据;owner 写置信度与冲突;source of truth 区分 authority 和 derived copy;兼容影响覆盖旧 workflow、legacy fixture、transport、迁移和 rollback。运行 \`mancode workflow update --step 2\`。 ### Step 2: 需求澄清 @@ -38,7 +38,9 @@ export const MAN_SKILL: SkillSpec = { 调用只读 \`plan-coach\`,输入 task、scout-report、\`requirements.json\` 和渲染后的 \`requirements.md\`。Plan Coach 先返回 \`READY_FOR_PLAN\` 或 \`NEEDS_CLARIFICATION\`。后者只列缺失决策、影响、推荐和问题;主 skill 将 workflow 退回 Step 2 并重新 finalize requirements,不得强行补全计划。前者返回计划文本,由主 skill 写入 \`plan.md\`。 -计划必须含需求摘要、任务分级、技术选择及理由、模块索引、复用资源与 scout 行号、核心行为、最小策略、不做什么、步骤、风险/回退、完成定义、真实验证与 smoke test、预估和非阻塞默认值。首次计划写入成功后运行 \`mancode workflow update --step 4\`;重写计划时保持 Step 4,运行 \`--plan-version <当前版本+1>\`,不得直接编辑 planVersion。 +Plan Coach 必须证明所有选项解决同一个 goal、验收边界和 scope;逐项写明 complexity bearer 及可观察成本;给出且只给出一个 recommendation、拒绝其他方向的主要理由和 stop conditions。简单任务没有真实替代时只列一个方向并说明原因,不制造伪选项。两个同等 owner 候选或 authority writer 未决时返回 \`NEEDS_CLARIFICATION\`。 + +计划必须含需求摘要、任务分级、技术选择及理由、模块索引、复用资源与 scout 行号、核心行为、最小策略、不做什么、步骤、风险/回退、完成定义、真实验证与 smoke test、预估和非阻塞默认值。入口/流程跨平台不一致、owner/source of truth 不清、状态或 contract 语义变化、跨 workflow/child/team/transport,或迁移/兼容影响超过一个版本时,在 \`plan.md\` 内加入可选 Domain Matrix,列出 Domain、当前行为/证据、候选 owner、source of truth、contract/state 影响、compatibility/history、validation 和 rollback/stop;它不是新的 authority。首次计划写入成功后运行 \`mancode workflow update --step 4\`;重写计划时保持 Step 4,运行 \`--plan-version <当前版本+1>\`,不得直接编辑 planVersion。 ### Step 4: 计划关卡 @@ -52,6 +54,12 @@ export const MAN_SKILL: SkillSpec = { 确认执行后、修改业务文件前输出开工回执:计划版本、执行方式、目标与交付物、技术方案、包含范围、排除范围、验证方式和残余假设。仍有 blocking 未知项时不得声称开始实施。 +### 实施期间停止与重新对齐 + +以下任一情况出现时立即停止当前代理执行:新证据推翻已确认的目标、owner、source of truth 或验收;平台入口/流程会产生不同语义;需要改变 status、contract、policy 或 workflow transition 的含义;发现 adapter stale、writer 不兼容、未完成 operation、active child、open handoff 或 active solo assignment;用户变化超出当前 requirements/plan scope。 + +只返回 \`NEEDS_REALIGNMENT\`、原因 \`MANCODE_REFRAME_REQUIRED\` 和具体 trigger。该诊断只读:保留 requirements、plan、review/verification ledger、claims、handoff 和 metadata;不得调用通用 \`workflow update\` 写 blocked,不得手改 currentStep/planning,不归档旧文件,不释放 claim,不取消 handoff,也不宣称已回到 Step 2。等待用户显式选择新的 \`/man\` workflow 或受支持的 reframe operation。 + ### Step 5: 实施 调用 \`head-coach\` 按确认计划实施。多文件、新模块或高风险任务可建议 worktree,必须先获用户同意。实施完成后通过 CLI 更新至 Step 6。 diff --git a/src/templates/skills/mansolo.ts b/src/templates/skills/mansolo.ts index ff5b277..d636ed2 100644 --- a/src/templates/skills/mansolo.ts +++ b/src/templates/skills/mansolo.ts @@ -1,7 +1,7 @@ import type { SkillSpec } from './index.js'; /** - * /mansolo skill — Back to Practice(docs/03 §6)。 + * /mansolo skill — Back to Practice(见 docs/workflows.md)。 * * 切回 solo 模式:清理 state.json 里的 workflow 状态。 */ diff --git a/src/templates/skills/manteam.ts b/src/templates/skills/manteam.ts index f3582db..37b6609 100644 --- a/src/templates/skills/manteam.ts +++ b/src/templates/skills/manteam.ts @@ -50,9 +50,9 @@ export const MANTEAM_SKILL: SkillSpec = { 按 \`/man\` 的 9 步流程执行(含澄清、计划关卡和增强收尾),但每一步增加团队约束: -1. Scout Report:必须列出共享文件、近期相关提交、潜在冲突文件。 +1. Scout Report:必须列出共享文件、近期相关提交、潜在冲突文件,并按事实补充 Current Behavior Evidence、Candidate Semantic Owner、Source of Truth、Historical / Compatibility Impact;authority 与 Markdown/adapter 等 derived copy 必须区分。 2. 澄清:沿用 /man 的需求就绪门槛,不设固定轮数或每批问题数量;问出所有会改变决策且无法从项目事实查清的疑问,不重复已确认内容。有合适方案时直接给出选项、优缺点和明确推荐。通过 \`workflow requirements ... finalize\` 固化七个 coverage 维度、结构化需求和带验证方式的验收 ID,只有 CLI 判定 ready 才进入计划。 -3. Game Plan:Plan Coach 先做输入就绪检查;计划里必须包含变更边界、技术选择理由、兼容性风险、回滚方式,禁止提前修改业务文件或团队 memory。 +3. Game Plan:Plan Coach 先做输入就绪检查;所有选项必须解决同一目标和验收边界,写明 complexity bearer,只保留一个 recommendation 和 stop conditions;简单任务可以只有一个真实方向。计划里必须包含变更边界、技术选择理由、兼容性风险、回滚方式;高风险或跨 team/transport/owner/authority 的任务在 plan.md 内加入非权威 Domain Matrix。禁止提前修改业务文件或团队 memory。 4. 计划关卡:用户可只保留计划、继续完整团队执行、明确交给 solo 轻量执行或重写计划;Active Plans 按 taskId 更新。团队共享文件或交接风险存在时推荐完整团队执行。 5. 实施:改动前再次检查 \`git status --short\`,避免踩用户或队友改动。 6. Self-test:在 Step 6 初始化 verification ledger,把每个 required 验收 ID 的自动、人工或 hybrid 结果和证据通过 CLI 记录;自动结果包含命令与退出码,未全部通过不得进入 review。优先跑项目已有验证命令;失败两次停下诊断根因。需要人工验证时标记 require-manual 并等待用户明确确认,不得自动恢复。remediation 后在 Step 9 重新登记全部验收。 @@ -60,6 +60,8 @@ export const MANTEAM_SKILL: SkillSpec = { 8. Film #2:仅 full 执行,先读 Film #1 报告并去重,只审查边界、安全、性能、并发和兼容性。targeted 的第二审是不适用,不能记为 skipped;只有用户明确要求才可跳过全部 review,并写入 \`review\` 和残余风险。 9. Post-game:汇总 blocker,只做一轮修复并用 \`workflow review ... remediate\` 记录;复验后写 summary 与 hand-off。验证失败、审查不完整或仍有 blocker 时标记 blocked,不得标 completed。 +实施中新证据若推翻已确认的目标、owner、source of truth 或验收,入口/流程会产生不同语义,status/contract/policy/transition 含义要变,发现 stale adapter、writer 不兼容、未完成 operation、active child/open handoff/active solo assignment,或变化超出 requirements/plan scope,立即停止,只返回 \`NEEDS_REALIGNMENT\` 和 \`MANCODE_REFRAME_REQUIRED\`。这是只读诊断:不得调用通用 workflow update 写 blocker,不改 metadata/step/policy/requirements/plan/claims/handoff,不归档或释放 authority。 + 如果用户在确认阶段选择"退出"或放弃团队 workflow:用 \`mancode workflow update --status abandoned\` 更新并清理 Active Plans;若有活跃 manba 子任务,先取得确认并逐个 abandoned。成功后再用 Edit 更新 state 回 solo。不要直接改 metadata,也不要把 abandoned workflow 留在 active state。 ## Team Hand-off Summary diff --git a/tests/adapter-status-contracts.test.ts b/tests/adapter-status-contracts.test.ts new file mode 100644 index 0000000..acbf1db --- /dev/null +++ b/tests/adapter-status-contracts.test.ts @@ -0,0 +1,193 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type AdapterStatusOptions, + adapterStatus, +} from '../src/commands/adapter.js'; +import { init } from '../src/commands/init.js'; +import type { PlatformName } from '../src/installers/registry.js'; +import { + type V3PlatformAdapterStatus, + installV3Adapter, +} from '../src/installers/v3-adapter.js'; + +interface AdapterStatusJson { + schemaVersion: 1; + renderer: 'mancode-adapter-digest-v1'; + ready: boolean; + manifestAdapters: Partial>; + adapters: Partial>; +} + +describe('adapter status command contracts', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-adapter-status-')); + expect( + await withoutConsoleOutput(() => + init(root, { v3: true, platform: 'codex' }), + ), + ).toBe(0); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('reports a Codex-only project ready while retaining all-platform discovery', async () => { + const result = await captureJson({ json: true }); + + expect(result).toMatchObject({ + schemaVersion: 1, + renderer: 'mancode-adapter-digest-v1', + ready: true, + manifestAdapters: { codex: '3' }, + adapters: { + codex: { installed: true, ready: true, status: 'ready' }, + cursor: { installed: false, ready: false, status: 'missing' }, + }, + }); + expect(Object.keys(result.adapters)).toEqual([ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', + ]); + }); + + it('reports not ready when a manifest-required adapter is incomplete', async () => { + await rm(path.join(root, '.agents', 'skills', 'man', 'SKILL.md')); + + const result = await captureJson({ json: true }); + + expect(result).toMatchObject({ + ready: false, + manifestAdapters: { codex: '3' }, + adapters: { codex: { installed: false, ready: false } }, + }); + }); + + it('scopes readiness to an explicitly selected platform', async () => { + const registered = await captureJson({ platform: 'codex', json: true }); + expect(registered).toMatchObject({ + ready: true, + manifestAdapters: { codex: '3' }, + adapters: { codex: { ready: true } }, + }); + expect(Object.keys(registered.adapters)).toEqual(['codex']); + + const unregistered = await captureJson({ + platform: 'cursor', + json: true, + }); + expect(unregistered).toMatchObject({ + ready: false, + manifestAdapters: {}, + adapters: { cursor: { installed: false, ready: false } }, + }); + expect(Object.keys(unregistered.adapters)).toEqual(['cursor']); + }); + + it('rejects a manifest renderer version that differs from ready disk content', async () => { + const schemaPath = path.join(root, '.mancode', 'schema.json'); + const manifest = JSON.parse(await readFile(schemaPath, 'utf8')) as { + managedAdapters: Record; + }; + manifest.managedAdapters.codex = '2'; + await writeFile(schemaPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const result = await captureJson({ json: true }); + + expect(result).toMatchObject({ + ready: false, + manifestAdapters: { codex: '2' }, + adapters: { codex: { version: '3', ready: true } }, + }); + }); + + it.each(['missing', 'stale', 'unreadable'] as const)( + 'does not confuse the %s content status with a manifest version', + async (collision) => { + const target = path.join(root, '.agents', 'skills', 'man', 'SKILL.md'); + if (collision === 'missing') { + await rm(target); + } else if (collision === 'stale') { + await writeFile(target, 'stale adapter content\n'); + } else { + await writeFile(target, Buffer.from([0xff])); + } + const schemaPath = path.join(root, '.mancode', 'schema.json'); + const manifest = JSON.parse(await readFile(schemaPath, 'utf8')) as { + managedAdapters: Record; + }; + manifest.managedAdapters.codex = collision; + await writeFile(schemaPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const result = await captureJson({ json: true }); + + expect(result).toMatchObject({ + ready: false, + manifestAdapters: { codex: collision }, + adapters: { codex: { status: collision, ready: false } }, + }); + }, + ); + + it('rejects an on-disk adapter that is absent from manifest inventory', async () => { + await installV3Adapter(root, 'cursor'); + + const result = await captureJson({ json: true }); + const selected = await captureJson({ platform: 'cursor', json: true }); + + expect(result).toMatchObject({ + ready: false, + manifestAdapters: { codex: '3' }, + adapters: { cursor: { version: '3', ready: true } }, + }); + expect(selected).toMatchObject({ + ready: false, + manifestAdapters: {}, + adapters: { cursor: { version: '3', ready: true } }, + }); + }); + + async function captureJson( + options: AdapterStatusOptions, + ): Promise { + const output: string[] = []; + const log = vi.spyOn(console, 'log').mockImplementation((value) => { + output.push(String(value)); + }); + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + expect(await adapterStatus(root, options)).toBe(0); + const serialized = output.at(-1); + if (serialized === undefined) { + throw new Error('missing adapter status JSON output'); + } + return JSON.parse(serialized) as AdapterStatusJson; + } finally { + log.mockRestore(); + error.mockRestore(); + } + } +}); + +async function withoutConsoleOutput( + operation: () => Promise, +): Promise { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + return await operation(); + } finally { + log.mockRestore(); + error.mockRestore(); + } +} diff --git a/tests/adapter-upgrade-contracts.test.ts b/tests/adapter-upgrade-contracts.test.ts new file mode 100644 index 0000000..35c9c7c --- /dev/null +++ b/tests/adapter-upgrade-contracts.test.ts @@ -0,0 +1,579 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { upgradeV3Adapters } from '../src/installers/adapter-upgrade.js'; +import { + V3_ADAPTER_DIGEST_DOMAIN, + V3_ADAPTER_PLATFORMS, + adapterManagedContentDigest, + inspectV3Adapter, + inspectV3AdapterVersions, + installV3Adapter, + v3ModeEntryPath, +} from '../src/installers/v3-adapter.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { createSession } from '../src/runtime/session.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-21T12:00:00.000Z'); + +describe('adapter managed-content digest and upgrade', () => { + let root: string; + let actorId: Ulid; + let sessionId: Ulid; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-adapter-upgrade-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + actorId = id(4); + sessionId = id(5); + await createLocalActor(root, { + actorId, + displayName: 'Adapter Maintainer', + now: NOW, + }); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('uses the frozen domain-separated digest byte sequence', () => { + const expected = createHash('sha256') + .update(Buffer.from(V3_ADAPTER_DIGEST_DOMAIN, 'utf8')) + .update(Buffer.from([0])) + .update(Buffer.from('agents#codex', 'utf8')) + .update(Buffer.from([0])) + .update(Buffer.from('managed\nbytes', 'utf8')) + .digest('hex'); + + expect(adapterManagedContentDigest('agents#codex', 'managed\nbytes')).toBe( + `sha256:${expected}`, + ); + expect( + adapterManagedContentDigest('agents#zcode', 'managed\nbytes'), + ).not.toBe(`sha256:${expected}`); + }); + + it('inspects all five renderers and ignores user-owned embedded content', async () => { + for (const platform of V3_ADAPTER_PLATFORMS) { + await installV3Adapter(root, platform); + } + await writeFile( + path.join(root, 'AGENTS.md'), + `${await readFile(path.join(root, 'AGENTS.md'), 'utf8')}\n# User-owned note\n`, + ); + + const statuses = await Promise.all( + V3_ADAPTER_PLATFORMS.map((platform) => inspectV3Adapter(root, platform)), + ); + expect(statuses.every((status) => status.status === 'ready')).toBe(true); + const codexMode = statuses[1]?.targets.find( + (target) => target.identity === 'agents-mode-man', + ); + const zcodeMode = statuses[4]?.targets.find( + (target) => target.identity === 'agents-mode-man', + ); + expect(codexMode?.actualDigest).toBe(zcodeMode?.actualDigest); + }); + + it('classifies content, CRLF, truncation, missing files, and invalid UTF-8', async () => { + await installV3Adapter(root, 'codex'); + const agentsPath = path.join(root, 'AGENTS.md'); + const original = await readFile(agentsPath, 'utf8'); + + await writeFile( + agentsPath, + original.replace('mancode bootstrap', 'changed bootstrap'), + ); + await expect(inspectV3Adapter(root, 'codex')).resolves.toMatchObject({ + status: 'stale', + ready: false, + }); + + await writeFile(agentsPath, original.replaceAll('\n', '\r\n')); + await expect(inspectV3Adapter(root, 'codex')).resolves.toMatchObject({ + status: 'stale', + }); + + await writeFile( + agentsPath, + original.replace('', ''), + ); + await expect(inspectV3Adapter(root, 'codex')).resolves.toMatchObject({ + status: 'stale', + }); + + await writeFile(agentsPath, original); + await unlink(v3ModeEntryPath(root, 'codex', 'man')); + await expect(inspectV3Adapter(root, 'codex')).resolves.toMatchObject({ + status: 'missing', + installed: false, + }); + + await writeFile(v3ModeEntryPath(root, 'codex', 'man'), Buffer.from([0xff])); + await expect(inspectV3Adapter(root, 'codex')).resolves.toMatchObject({ + status: 'unreadable', + ready: false, + }); + }); + + it('keeps required missing platforms in inventory without inferring shared hosts', async () => { + await installV3Adapter(root, 'codex'); + + await expect( + inspectV3AdapterVersions(root, ['codex', 'zcode', 'cursor']), + ).resolves.toEqual({ + codex: '3', + cursor: 'missing', + zcode: 'missing', + }); + }); + + it('stages a dry-run without publishing live targets, then commits explicitly', async () => { + const live = path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'); + const preview = await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + dryRun: true, + operationId: id(10), + now: NOW, + }); + expect(preview).toMatchObject({ + state: 'preview', + dryRun: true, + journal: null, + status: { cursor: { status: 'missing' } }, + }); + await expect( + new V3ContextStore(root).readProjectSnapshot(), + ).resolves.toMatchObject({ manifest: { managedAdapters: {} } }); + await expect(readFile(live, 'utf8')).rejects.toThrow(); + await expect( + readFile(path.join(root, preview.stagedTargets[0] ?? ''), 'utf8'), + ).resolves.toContain('# mancode bootstrap'); + + const committed = await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + explicitConfirmation: true, + sessionId, + operationId: id(10), + now: NOW, + }); + expect(committed).toMatchObject({ + state: 'committed', + journal: { state: 'committed', type: 'adapter_upgrade' }, + status: { cursor: { status: 'ready' } }, + }); + await expect( + new V3ContextStore(root).readProjectSnapshot(), + ).resolves.toMatchObject({ + manifest: { managedAdapters: { cursor: '3' } }, + }); + await expect(readFile(live, 'utf8')).resolves.toContain( + '# mancode bootstrap', + ); + }); + + it('does not create staging or live targets without dry-run or confirmation', async () => { + const operationId = id(12); + await expect( + upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + operationId, + now: NOW, + }), + ).rejects.toThrow('MANCODE_EXPLICIT_CONFIRMATION_REQUIRED'); + await expect( + readFile( + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), + 'utf8', + ), + ).rejects.toThrow(); + await expect( + readFile( + path.join( + root, + '.mancode', + 'staging', + 'adapters', + 'upgrade', + operationId, + '.cursor', + 'rules', + 'mancode-continuity.mdc', + ), + 'utf8', + ), + ).rejects.toThrow(); + }); + + it('rejects a confirmation when the previewed adapter plan has changed', async () => { + const operationId = id(13); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + dryRun: true, + operationId, + now: NOW, + }); + await mkdir(path.join(root, '.cursor', 'rules'), { recursive: true }); + await writeFile( + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), + '# User-owned cursor rule\n', + ); + + await expect( + upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + explicitConfirmation: true, + sessionId, + operationId, + now: NOW, + }), + ).rejects.toThrow(); + }); + + it('migrates managed V3 bootstrap paths and markers to Continuity', async () => { + const legacyRule = path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'); + const legacyMode = path.join(root, '.cursor', 'commands', 'man.md'); + await mkdir(path.dirname(legacyRule), { recursive: true }); + await mkdir(path.dirname(legacyMode), { recursive: true }); + await writeFile( + legacyRule, + '\n# old bootstrap\n', + ); + await writeFile( + legacyMode, + '\n# old mode\n', + ); + + const operationId = id(15); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + dryRun: true, + operationId, + now: NOW, + }); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + explicitConfirmation: true, + sessionId, + operationId, + now: NOW, + }); + + await expect(readFile(legacyRule, 'utf8')).rejects.toThrow(); + await expect(readFile(legacyMode, 'utf8')).resolves.toContain( + 'mancode:continuity-mode-entry', + ); + await expect(inspectV3Adapter(root, 'cursor')).resolves.toMatchObject({ + status: 'ready', + target: '.cursor/rules/mancode-continuity.mdc', + }); + + const legacyClaudeSkill = path.join( + root, + '.claude', + 'skills', + 'mancode-v3', + 'SKILL.md', + ); + await mkdir(path.dirname(legacyClaudeSkill), { recursive: true }); + await writeFile( + legacyClaudeSkill, + '\n# old bootstrap\n', + ); + const retiredContinuitySkill = path.join( + root, + '.claude', + 'skills', + 'mancode-continuity', + 'SKILL.md', + ); + await mkdir(path.dirname(retiredContinuitySkill), { recursive: true }); + await writeFile( + retiredContinuitySkill, + '\n# retired bootstrap\n', + ); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['claude-code'], + dryRun: true, + operationId: id(19), + now: NOW, + }); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['claude-code'], + explicitConfirmation: true, + sessionId, + operationId: id(19), + now: NOW, + }); + await expect(readFile(legacyClaudeSkill, 'utf8')).rejects.toThrow(); + await expect(readFile(retiredContinuitySkill, 'utf8')).rejects.toThrow(); + await expect(inspectV3Adapter(root, 'claude-code')).resolves.toMatchObject({ + status: 'ready', + target: 'CLAUDE.md', + }); + }); + + it('preserves a user-authored file at the retired V3 path', async () => { + const legacyRule = path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'); + await mkdir(path.dirname(legacyRule), { recursive: true }); + await writeFile(legacyRule, '# User-owned legacy-named cursor rule\n'); + + const operationId = id(16); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + dryRun: true, + operationId, + now: NOW, + }); + await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + explicitConfirmation: true, + sessionId, + operationId, + now: NOW, + }); + + await expect(readFile(legacyRule, 'utf8')).resolves.toBe( + '# User-owned legacy-named cursor rule\n', + ); + await expect(inspectV3Adapter(root, 'cursor')).resolves.toMatchObject({ + status: 'ready', + }); + }); + + it.each([ + ['codex', 'AGENTS.md', 'codex'], + ['zcode', 'AGENTS.md', 'zcode'], + ['copilot', '.github/copilot-instructions.md', 'copilot'], + ] as const)( + 'replaces legacy embedded V3 markers for %s without touching user content', + async (platform, relativeTarget, markerName) => { + const target = path.join(root, relativeTarget); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile( + target, + [ + '# User instructions', + ``, + '# old managed bootstrap', + ``, + '', + ].join('\n'), + ); + + const operationId = id(17); + await upgradeV3Adapters({ + projectRoot: root, + platforms: [platform], + dryRun: true, + operationId, + now: NOW, + }); + await upgradeV3Adapters({ + projectRoot: root, + platforms: [platform], + explicitConfirmation: true, + sessionId, + operationId, + now: NOW, + }); + + const content = await readFile(target, 'utf8'); + expect(content).toContain('# User instructions'); + expect(content).not.toContain(`mancode:v3:${markerName}`); + expect( + content.match( + new RegExp(`mancode:continuity:${markerName}:start`, 'g'), + ), + ).toHaveLength(1); + }, + ); + + it('upgrades all platforms while journaling shared targets only once', async () => { + await upgradeV3Adapters({ + projectRoot: root, + platforms: V3_ADAPTER_PLATFORMS, + dryRun: true, + operationId: id(14), + now: NOW, + }); + const upgraded = await upgradeV3Adapters({ + projectRoot: root, + platforms: V3_ADAPTER_PLATFORMS, + explicitConfirmation: true, + sessionId, + operationId: id(14), + now: NOW, + }); + + expect( + V3_ADAPTER_PLATFORMS.every( + (platform) => upgraded.status[platform].status === 'ready', + ), + ).toBe(true); + expect( + upgraded.filePlans.filter((plan) => plan.target === 'agents'), + ).toHaveLength(1); + expect( + upgraded.filePlans.filter((plan) => plan.target === 'agents-mode-man'), + ).toHaveLength(1); + const agents = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); + expect(agents).toContain(''); + expect(agents).toContain(''); + }); + + it('recovers every five-platform target from its write-before and write-after boundary', async () => { + const discovery = await upgradeV3Adapters({ + projectRoot: root, + platforms: V3_ADAPTER_PLATFORMS, + dryRun: true, + operationId: id(20), + now: NOW, + }); + const targets = discovery.filePlans.map((plan) => plan.target); + expect(targets.length).toBeGreaterThan(1); + expect(new Set(targets).size).toBe(targets.length); + + let caseIndex = 0; + for (const boundary of ['before', 'after'] as const) { + for (const [targetIndex, target] of targets.entries()) { + const caseRoot = path.join(root, `${boundary}-${caseIndex}`); + await mkdir(caseRoot, { recursive: true }); + const fixture = await bootstrapAdapterCase( + caseRoot, + 40 + caseIndex * 7, + ); + const operationId = id(45 + caseIndex * 7); + const preview = await upgradeV3Adapters({ + projectRoot: caseRoot, + platforms: V3_ADAPTER_PLATFORMS, + dryRun: true, + operationId, + now: NOW, + }); + expect(preview.filePlans.map((plan) => plan.target)).toEqual(targets); + + await expect( + withOperationCrashInjectionForTesting( + { + operationType: 'adapter_upgrade', + crashAfter: + boundary === 'before' + ? `replace-managed-adapters:before:${target}` + : `replace-managed-adapters:${target}`, + }, + () => + upgradeV3Adapters({ + projectRoot: caseRoot, + platforms: V3_ADAPTER_PLATFORMS, + explicitConfirmation: true, + sessionId: fixture.sessionId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const repaired = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + now: new Date(NOW.getTime() + 1_000), + }); + if (boundary === 'before' && targetIndex === 0) { + expect(repaired).toMatchObject({ + state: 'aborted', + journal: { state: 'aborted', type: 'adapter_upgrade' }, + }); + } else { + expect(repaired).toMatchObject({ + state: 'repaired', + journal: { state: 'committed', type: 'adapter_upgrade' }, + }); + } + const statuses = await Promise.all( + V3_ADAPTER_PLATFORMS.map((platform) => + inspectV3Adapter(caseRoot, platform), + ), + ); + if (boundary === 'before' && targetIndex === 0) { + expect(statuses.every((status) => status.status === 'missing')).toBe( + true, + ); + } else { + expect(statuses.every((status) => status.ready)).toBe(true); + } + caseIndex += 1; + } + } + }, 30_000); +}); + +async function bootstrapAdapterCase( + projectRoot: string, + offset: number, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(offset), + workspaceId: id(offset + 1), + schemaEpoch: id(offset + 2), + now: NOW, + }); + const actorId = id(offset + 3); + const sessionId = id(offset + 4); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Adapter Boundary Tester', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { actorId, sessionId }; +} + +function id(offset: number): Ulid { + return createUlid(NOW.getTime() + offset, new Uint8Array(10).fill(offset)); +} diff --git a/tests/agents.test.ts b/tests/agents.test.ts index eabfc66..9fb9fe2 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -49,6 +49,27 @@ describe('coaching staff agents', () => { expect(SCOUT_AGENT.tools).not.toContain('Edit'); }); + it('scout records planning-governance evidence without claiming authority', () => { + for (const heading of [ + 'Current Behavior Evidence', + 'Candidate Semantic Owner', + 'Source of Truth', + 'Historical / Compatibility Impact', + ]) { + expect(SCOUT_AGENT.body).toContain(heading); + } + expect(SCOUT_AGENT.body).toMatch(/可复现观察/); + expect(SCOUT_AGENT.body).toMatch(/Confidence: high \| medium \| low/); + expect(SCOUT_AGENT.body).toMatch(/Authority:.*结构化文件/); + expect(SCOUT_AGENT.body).toMatch(/Existing workflows:.*no change/); + expect(SCOUT_AGENT.body).toContain('NEEDS_REALIGNMENT'); + expect(SCOUT_AGENT.body).toContain('MANCODE_REFRAME_REQUIRED'); + expect(SCOUT_AGENT.body).toMatch(/不(?:要)?调用通用.*workflow update/); + expect(SCOUT_AGENT.body).not.toMatch( + /workflow update[^\n]*--status blocked/, + ); + }); + it('plan coach is read-only and owns pre-confirmation plans', () => { expect(PLAN_COACH_AGENT.body).toMatch(/只读/); expect(PLAN_COACH_AGENT.body).toMatch(/plan\.md/); @@ -61,6 +82,44 @@ describe('coaching staff agents', () => { expect(PLAN_COACH_AGENT.tools).not.toContain('Bash'); }); + it('plan coach enforces comparable options and one recommendation', () => { + expect(PLAN_COACH_AGENT.body).toMatch(/同一个用户目标/); + expect(PLAN_COACH_AGENT.body).toContain('complexity_bearer'); + expect(PLAN_COACH_AGENT.body).toMatch(/唯一 recommendation/); + expect(PLAN_COACH_AGENT.body).toMatch(/简单任务.*只列一个/); + expect(PLAN_COACH_AGENT.body).toContain( + 'recommendation: ', + ); + expect(PLAN_COACH_AGENT.body).toContain('stop_conditions:'); + expect(PLAN_COACH_AGENT.body).toContain('Domain Matrix'); + expect(PLAN_COACH_AGENT.body).toContain('NEEDS_REALIGNMENT'); + expect(PLAN_COACH_AGENT.body).toContain('MANCODE_REFRAME_REQUIRED'); + expect(PLAN_COACH_AGENT.body).toMatch(/重复检查.*同一类诊断/); + expect(PLAN_COACH_AGENT.body).not.toMatch( + /workflow update[^\n]*--status blocked/, + ); + }); + + it('head coach keeps solo escalation advisory and realignment read-only', () => { + const routingSignals = [ + /平台入口或流程不一致/, + /semantic owner 不清/, + /source of truth.*不清/, + /status、contract、policy.*语义会变化/, + /scope、架构、成本或验收.*跨文件\/跨模块/, + /历史兼容/, + /迁移、跨平台/, + /团队协调证据/, + ]; + for (const signal of routingSignals) { + expect(HEAD_COACH_AGENT.body).toMatch(signal); + } + expect(HEAD_COACH_AGENT.body).toMatch(/不自动改变 mode/); + expect(HEAD_COACH_AGENT.body).toContain('NEEDS_REALIGNMENT'); + expect(HEAD_COACH_AGENT.body).toContain('MANCODE_REFRAME_REQUIRED'); + expect(HEAD_COACH_AGENT.body).toMatch(/这是只读诊断/); + }); + it('head coach body includes 5 core principles', () => { // 五条铁律的关键词(中文) expect(HEAD_COACH_AGENT.body).toMatch(/不做无关修改/); diff --git a/tests/aggregate-contracts.test.ts b/tests/aggregate-contracts.test.ts index ec134ca..f4edc7c 100644 --- a/tests/aggregate-contracts.test.ts +++ b/tests/aggregate-contracts.test.ts @@ -277,7 +277,7 @@ function metadata( requirementsDigest: requirements.contentDigest, planVersion: 2, planDecision: 'governed_execution', - policyVersions: { planning: 2, review: 2, verification: 1 }, + policyVersions: { planning: 2, review: 1, verification: 1 }, reviewStatus: review.status, reviewLedgerDigest: review.contentDigest, verificationStatus: verification.status, diff --git a/tests/authorization-contracts.test.ts b/tests/authorization-contracts.test.ts index e370e98..02d3932 100644 --- a/tests/authorization-contracts.test.ts +++ b/tests/authorization-contracts.test.ts @@ -14,7 +14,7 @@ const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; describe('cooperative authorization matrix', () => { it('freezes all documented mutating actor rules and keeps the trust boundary explicit', () => { - expect(AUTHORIZATION_MATRIX).toHaveLength(15); + expect(AUTHORIZATION_MATRIX).toHaveLength(16); const decision = evaluateAuthorization(baseRequest()); expect(decision).toEqual({ allowed: true, @@ -47,6 +47,29 @@ describe('cooperative authorization matrix', () => { ).toThrow('MANCODE_SCOPE_OUTSIDE_IMPLEMENTATION_SCOPE'); }); + it('authorizes explicit project maintenance without requiring team join', () => { + const maintenance: AuthorizationRequest = { + ...baseRequest(), + action: 'project_maintenance', + joined: false, + sharedWriteGuard: 'unavailable', + task: null, + }; + expect(evaluateAuthorization(maintenance)).toMatchObject({ + allowed: true, + failures: [], + }); + expect(() => + assertAuthorized({ + ...maintenance, + conditions: { + ...maintenance.conditions, + explicitConfirmation: false, + }, + }), + ).toThrow('MANCODE_EXPLICIT_CONFIRMATION_REQUIRED'); + }); + it('forbids P0 waivers and binds repair to the original actor/session authorization', () => { const waiver = { ...baseRequest(), diff --git a/tests/beta-gate-contracts.test.ts b/tests/beta-gate-contracts.test.ts index d5eb67b..50aa38f 100644 --- a/tests/beta-gate-contracts.test.ts +++ b/tests/beta-gate-contracts.test.ts @@ -26,6 +26,13 @@ describe('V3 Beta gate', () => { await mkdir(root, { recursive: true }); await initializeV3Project({ projectRoot: root, + managedAdapters: { + 'claude-code': '3', + codex: '3', + cursor: '3', + copilot: '3', + zcode: '3', + }, operationId: id(1), workspaceId: id(2), schemaEpoch: id(3), @@ -38,6 +45,7 @@ describe('V3 Beta gate', () => { }); it('refuses Beta while real-host spike evidence and adapters are missing', async () => { + await rm(path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc')); const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); try { expect( @@ -61,7 +69,7 @@ describe('V3 Beta gate', () => { } }); - it('passes only after every V3 adapter and every platform spike is present', async () => { + it('passes after every adapter and explicit-session platform spike is proven', async () => { await makeBetaReady(root); const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); try { @@ -208,9 +216,10 @@ async function makeBetaReady( createPlatformSessionSpike({ platform, observedAt: '2026-07-18T12:00:00.000Z', - hostSessionSource: 'api', - firstWindowHostSessionKey: `${platform}-window-a`, - secondWindowHostSessionKey: `${platform}-window-b`, + sessionMode: 'explicit', + hostSessionSource: 'none', + firstWindowSessionKey: `${platform}-session-a`, + secondWindowSessionKey: `${platform}-session-b`, commandPropagation: 'proven', subagentInheritance, hookApproval: 'not_applicable', diff --git a/tests/cli-v3-surface-contracts.test.ts b/tests/cli-v3-surface-contracts.test.ts index 84c75ca..d24e8bb 100644 --- a/tests/cli-v3-surface-contracts.test.ts +++ b/tests/cli-v3-surface-contracts.test.ts @@ -17,14 +17,21 @@ describe('V3 CLI command surface', () => { 'operation', 'team', 'migrate', + 'adapter', ]), ); expect(commandAt(cliProgram, 'context', 'session', 'new')).toBeDefined(); + expect(commandAt(cliProgram, 'context', 'session', 'show')).toBeDefined(); + expect( + requiredOptions(commandAt(cliProgram, 'context', 'session', 'show')), + ).toEqual(['--session']); expect( commandAt(cliProgram, 'context', 'session', 'spike'), ).toBeDefined(); expect(commandAt(cliProgram, 'operation', 'repair')).toBeDefined(); expect(commandAt(cliProgram, 'operation', 'abort')).toBeDefined(); + expect(commandAt(cliProgram, 'adapter', 'status')).toBeDefined(); + expect(commandAt(cliProgram, 'adapter', 'upgrade')).toBeDefined(); expect(commandAt(cliProgram, 'team', 'sync', 'pull')).toBeDefined(); expect(commandAt(cliProgram, 'team', 'sync', 'push')).toBeDefined(); expect(commandAt(cliProgram, 'team', 'handoff', 'accept')).toBeDefined(); @@ -45,6 +52,7 @@ describe('V3 CLI command surface', () => { requiredOptions(commandAt(cliProgram, 'context', 'session', 'spike')), ).toEqual([ '--platform', + '--session-mode', '--host-session-source', '--command-propagation', '--subagent-inheritance', diff --git a/tests/compatibility-contracts.test.ts b/tests/compatibility-contracts.test.ts index acecbd6..bdd4dc5 100644 --- a/tests/compatibility-contracts.test.ts +++ b/tests/compatibility-contracts.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from 'vitest'; import { + CURRENT_WRITER_CAPABILITIES, type CompatibilityFailureCode, type CompatibilityGateInput, assertCompatibilityGate, compareSemver, evaluateCompatibilityGate, } from '../src/context/compatibility.js'; -import type { SchemaManifestV1 } from '../src/context/manifest.js'; +import type { + SchemaManifestV1, + SchemaManifestV2, +} from '../src/context/manifest.js'; const EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; const BASELINE = { @@ -65,9 +69,52 @@ describe('schema compatibility gate', () => { ); }); - it('blocks writes for both missing and mismatched managed adapters', () => { + it('requires the registered adapter inventory to match disk exactly', () => { const active = activeInput(); + const codexOnly = activeInput({ managedAdapters: { codex: '3' } }); + expect( + evaluateCompatibilityGate({ + ...codexOnly, + operation: 'v3_business_write', + }), + ).toMatchObject({ writeAllowed: true, failures: [] }); + expectWriteBlocked( + { + ...active, + adapterVersions: { + ...active.adapterVersions, + codex: 'missing', + }, + operation: 'v3_business_write', + }, + ['MANCODE_ADAPTER_CONTENT_STALE'], + true, + ); + try { + assertCompatibilityGate({ + ...codexOnly, + adapterVersions: { codex: 'stale' }, + operation: 'v3_business_write', + }); + throw new Error('expected stale adapter compatibility failure'); + } catch (error) { + expect(error).toMatchObject({ + details: { + repair: [ + { + platform: 'codex', + previewCommand: + 'mancode adapter upgrade --platform codex --dry-run', + confirmCommand: expect.stringContaining( + '--platform codex --confirm --operation-id ', + ), + }, + ], + }, + }); + expect(JSON.stringify(error)).not.toContain('--all'); + } expectWriteBlocked( { ...active, @@ -77,6 +124,15 @@ describe('schema compatibility gate', () => { ['MANCODE_ADAPTER_VERSION_MISMATCH'], true, ); + expectWriteBlocked( + { + ...codexOnly, + adapterVersions: { ...codexOnly.adapterVersions, cursor: '3' }, + operation: 'v3_business_write', + }, + ['MANCODE_ADAPTER_VERSION_MISMATCH'], + true, + ); expectWriteBlocked( { ...active, @@ -88,6 +144,96 @@ describe('schema compatibility gate', () => { ); }); + it('requires explicit writer capabilities before any Policy 2 write', () => { + const base = activeInput(); + const manifest: SchemaManifestV2 = { + ...base.manifest, + manifestVersion: 2, + workflowPolicyDefaults: { planning: 2 }, + }; + const input = { ...base, manifest }; + expectWriteBlocked( + { + ...input, + writerCapabilities: ['planning-policy:1'], + operation: 'v3_business_write', + }, + ['MANCODE_WRITER_CAPABILITY_MISSING'], + true, + ); + expect(() => + assertCompatibilityGate({ + ...input, + writerCapabilities: ['planning-policy:1'], + operation: 'v3_business_write', + }), + ).toThrow(/planning-policy:2/); + }); + + it('evaluates the declared 0.3.18 version and capability tuple at the V2 boundary', () => { + const base = activeInput({ + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + }); + const manifest: SchemaManifestV2 = { + ...base.manifest, + manifestVersion: 2, + workflowPolicyDefaults: { planning: 2 }, + }; + const result = evaluateCompatibilityGate({ + ...base, + manifest, + readerVersion: '0.3.18', + writerVersion: '0.3.18', + writerCapabilities: ['planning-policy:1'], + operation: 'v3_business_write', + }); + + expect(result).toEqual({ + readAllowed: false, + writeAllowed: false, + failures: [ + 'MANCODE_READER_VERSION_TOO_OLD', + 'MANCODE_WRITER_VERSION_TOO_OLD', + 'MANCODE_WRITER_CAPABILITY_MISSING', + ], + }); + expect(() => + assertCompatibilityGate({ + ...base, + manifest, + readerVersion: '0.3.18', + writerVersion: '0.3.18', + writerCapabilities: ['planning-policy:1'], + operation: 'v3_business_write', + }), + ).toThrow(/^MANCODE_READER_VERSION_TOO_OLD:/); + }); + + it('requires the adapter digest and local reframe capabilities for reframe', () => { + const input = activeInput(); + expectWriteBlocked( + { + ...input, + writerCapabilities: ['planning-policy:1', 'adapter-digest:1'], + operation: 'reframe', + }, + ['MANCODE_WRITER_CAPABILITY_MISSING'], + true, + ); + expect( + evaluateCompatibilityGate({ + ...input, + writerCapabilities: [ + 'planning-policy:1', + 'adapter-digest:1', + 'reframe-local:1', + ], + operation: 'reframe', + }), + ).toMatchObject({ writeAllowed: true, failures: [] }); + }); + it('blocks writes when the legacy authority drifts from its baseline', () => { expectWriteBlocked( { @@ -113,7 +259,7 @@ describe('schema compatibility gate', () => { { ...active, expectedSchemaEpoch: '01JZ4B6W5Z0A1B2C3D4E5F6G7J', - adapterVersions: withoutCodex(active.adapterVersions), + adapterVersions: { ...active.adapterVersions, codex: '2' }, currentLegacyBaseline: { ...BASELINE, workflowIndexDigest: `sha256:${'c'.repeat(64)}`, @@ -168,19 +314,16 @@ function expectWriteBlocked( failures, }); expect(() => assertCompatibilityGate(input)).toThrowError( - new RegExp(`^${failures[0]}$`), + new RegExp(`^${failures[0]}:`), ); } function withoutCodex( adapters: SchemaManifestV1['managedAdapters'], ): CompatibilityGateInput['adapterVersions'] { - return { - 'claude-code': adapters['claude-code'], - cursor: adapters.cursor, - copilot: adapters.copilot, - zcode: adapters.zcode, - }; + return Object.fromEntries( + Object.entries(adapters).filter(([adapter]) => adapter !== 'codex'), + ); } function baseInput(overrides: Partial) { @@ -208,6 +351,7 @@ function baseInput(overrides: Partial) { expectedSchemaEpoch: EPOCH, readerVersion: '0.4.0', writerVersion: '0.4.0', + writerCapabilities: CURRENT_WRITER_CAPABILITIES, adapterVersions: { ...manifest.managedAdapters }, currentLegacyBaseline: manifest.legacyBaseline, legacyAuthorityPresent: manifest.legacyBaseline !== null, diff --git a/tests/context-resolver-contracts.test.ts b/tests/context-resolver-contracts.test.ts index d2f3b59..e56910b 100644 --- a/tests/context-resolver-contracts.test.ts +++ b/tests/context-resolver-contracts.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -7,6 +14,7 @@ import { taskAggregateDigest, } from '../src/context/aggregate.js'; import { digestCanonicalJson } from '../src/context/canonical.js'; +import { CURRENT_WRITER_CAPABILITIES } from '../src/context/compatibility.js'; import { type RequirementsLedgerV1, requirementsLedgerDigest, @@ -165,6 +173,52 @@ describe('V3 Context Resolver', () => { }), ).rejects.toThrow('MANCODE_CONTEXT_PATH_UNSAFE'); }); + + it('short-circuits a stale adapter before parsing an unsupported workflow policy for mutation', async () => { + const fixture = await createFixture(); + const metadataPath = path.join( + fixture.root, + '.mancode', + 'shared', + 'workflows', + TASK_ID, + 'metadata.json', + ); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as { + governance: { + policyVersions: { planning: number | null }; + }; + }; + metadata.governance.policyVersions.planning = 3; + await writeJson(metadataPath, metadata); + + const request = { + session: fixture.session, + taskRef: `shared:${TASK_ID}`, + level: 'task' as const, + purpose: 'implement' as const, + intent: 'mutate' as const, + codeHead: 'abc1234', + }; + await expect( + fixture.resolver.resolve({ + ...request, + compatibility: { + ...fixture.compatibility, + adapterVersions: { + ...fixture.compatibility.adapterVersions, + codex: 'stale', + }, + }, + }), + ).rejects.toThrow('MANCODE_ADAPTER_CONTENT_STALE'); + await expect( + fixture.resolver.resolve({ + ...request, + compatibility: fixture.compatibility, + }), + ).rejects.toThrow('MANCODE_POLICY_VERSION_UNSUPPORTED'); + }); }); async function createFixture() { @@ -297,6 +351,7 @@ async function createFixture() { expectedSchemaEpoch: EPOCH, readerVersion: '1.0.0', writerVersion: '1.0.0', + writerCapabilities: CURRENT_WRITER_CAPABILITIES, adapterVersions: adapters(), }, resolver: new ContextResolver({ diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index 9154f9a..daff11b 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -66,6 +66,10 @@ describe('Cursor adapter', () => { expect(solo).toContain('read `.mancode/aesthetics/style-tokens.json`'); expect(solo).toContain('one bounded self-check'); expect(solo).toContain('Do not start another reviewer'); + expect(solo).toContain('semantic owner or source of truth is unclear'); + expect(solo).toContain('NEEDS_REALIGNMENT'); + expect(solo).toContain('MANCODE_REFRAME_REQUIRED'); + expect(solo).toContain('do not call generic workflow update'); const man = await readRule('mancode-man.mdc'); expect(man).toContain('alwaysApply: false'); diff --git a/tests/fixtures/legacy-cli/README.md b/tests/fixtures/legacy-cli/README.md new file mode 100644 index 0000000..87caf87 --- /dev/null +++ b/tests/fixtures/legacy-cli/README.md @@ -0,0 +1,21 @@ +# mancode 0.3.18 CLI fixture + +`mancode-0.3.18.tgz` is the unmodified public npm package used by the +black-box V2 compatibility contract. + +- Package: `mancode@0.3.18` +- npm integrity: `sha512-7jqphIAgW+XlTZWFwK06ekPOt//g4q9A+3KZJeSjZCdh3d4Rsb0Akgn3ku5Ayc1URZAtPJVPqLdqgxfCaszlWg==` +- npm shasum: `f3b14e98a3426bf3f2cf6f2461698c9bd4c52d13` +- Release commit: `a0f39f75d50f3ff2e985cd3e45cc250100710e45` +- npm registry `gitHead`: `ff407d7b07c18d9b56cdf3a7e0bf35f2d9921fef` + +To verify the registry metadata without changing the fixture: + +```bash +npm view mancode@0.3.18 version dist.integrity dist.shasum +npm pack mancode@0.3.18 --dry-run --json +``` + +Do not replace this fixture with a root dev dependency alias. npm exposes the +aliased package's `mancode` binary in `node_modules/.bin`, which would make +repository commands run the legacy CLI instead of the current build. diff --git a/tests/fixtures/legacy-cli/mancode-0.3.18.tgz b/tests/fixtures/legacy-cli/mancode-0.3.18.tgz new file mode 100644 index 0000000..0fb97fb Binary files /dev/null and b/tests/fixtures/legacy-cli/mancode-0.3.18.tgz differ diff --git a/tests/greenfield-init-contracts.test.ts b/tests/greenfield-init-contracts.test.ts index bdadc18..3105334 100644 --- a/tests/greenfield-init-contracts.test.ts +++ b/tests/greenfield-init-contracts.test.ts @@ -18,6 +18,7 @@ import { stageGreenfieldInitialization, } from '../src/context/greenfield-init.js'; import { parseSchemaManifest } from '../src/context/manifest.js'; +import { inspectV3AdapterVersions } from '../src/installers/v3-adapter.js'; import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; @@ -59,8 +60,10 @@ describe('journaled greenfield initialization contract', () => { ), ); expect(manifest).toMatchObject({ + manifestVersion: 2, activationState: 'v3_active', legacyBaseline: null, + workflowPolicyDefaults: { planning: 2 }, }); expect( await readFile(path.join(root, '.mancode', '.gitignore'), 'utf8'), @@ -196,6 +199,50 @@ describe('journaled greenfield initialization contract', () => { expect(registrations).toBeGreaterThanOrEqual(1); } }); + + it('keeps V2 inactive and forward-repairs a crash after the first adapter target', async () => { + const recoveryInput = { + projectRoot: root, + operationId: OPERATION_ID, + registerWorkspaceBinding: async () => {}, + now: new Date('2026-07-17T12:03:00.000Z'), + }; + await expect( + withOperationCrashInjectionForTesting( + { + operationType: 'greenfield_initialize', + crashAfter: 'publish-managed-adapters:claude-skill', + }, + () => initializeGreenfield(input(root), recoveryInput), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + expect( + parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ).activationState, + ).toBe('initializing'); + await expect(recoverGreenfieldInitialization(recoveryInput)).resolves.toBe( + 'forward_repaired', + ); + await expect( + inspectV3AdapterVersions(root, [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', + ]), + ).resolves.toEqual({ + 'claude-code': '3', + codex: '3', + cursor: '3', + copilot: '3', + zcode: '3', + }); + }); }); async function pathExists(target: string): Promise { diff --git a/tests/legacy-cli-compatibility-contracts.test.ts b/tests/legacy-cli-compatibility-contracts.test.ts new file mode 100644 index 0000000..0a62397 --- /dev/null +++ b/tests/legacy-cli-compatibility-contracts.test.ts @@ -0,0 +1,306 @@ +import { + type ExecFileException, + execFile as execFileCallback, +} from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + readlink, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync } from 'node:zlib'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; + +const NOW = new Date('2026-07-22T08:00:00.000Z'); +const LEGACY_FIXTURE = fileURLToPath( + new URL('./fixtures/legacy-cli/mancode-0.3.18.tgz', import.meta.url), +); +const LEGACY_INTEGRITY = + 'sha512-7jqphIAgW+XlTZWFwK06ekPOt//g4q9A+3KZJeSjZCdh3d4Rsb0Akgn3ku5Ayc1URZAtPJVPqLdqgxfCaszlWg=='; + +describe('published 0.3.18 CLI compatibility boundary', () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + }); + + it('rejects a V2 authority mutation before changing any authority bytes', async () => { + await expectRootBinUnregistered(); + const fixture = await readFile(LEGACY_FIXTURE); + expect( + `sha512-${createHash('sha512').update(fixture).digest('base64')}`, + ).toBe(LEGACY_INTEGRITY); + const extractionRoot = await mkdtemp( + path.join(process.cwd(), 'node_modules', '.mancode-legacy-cli-'), + ); + roots.push(extractionRoot); + const legacyPackageRoot = path.join(extractionRoot, 'package'); + await extractLegacyPackage(fixture, extractionRoot); + const legacyCli = path.join(legacyPackageRoot, 'dist', 'cli.js'); + const packageMetadata = JSON.parse( + await readFile(path.join(legacyPackageRoot, 'package.json'), 'utf8'), + ) as { + name: string; + version: string; + bin: Record; + exports: Record; + }; + expect(packageMetadata).toMatchObject({ + name: 'mancode', + version: '0.3.18', + bin: { mancode: 'dist/cli.js' }, + exports: { '.': './dist/index.js' }, + }); + expect(legacyCli).toBe( + path.join(legacyPackageRoot, packageMetadata.bin.mancode), + ); + await expect( + readFile(path.join(legacyPackageRoot, 'dist', 'index.js'), 'utf8'), + ).resolves.toContain('VERSION'); + const version = await runLegacyCli(legacyCli, ['--version'], process.cwd()); + expect(version).toMatchObject({ + exitCode: 0, + signal: null, + stderr: '', + }); + expect(version.stdout.trim()).toBe('0.3.18'); + + const root = await mkdtemp( + path.join(tmpdir(), 'mancode-legacy-cli-compatibility-'), + ); + roots.push(root); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + const actor = await createLocalActor(root, { + actorId, + displayName: 'Legacy CLI Fixture', + now: NOW, + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor, NOW)); + await createSession(root, { + actorId, + sessionId, + client: 'legacy-cli-contract', + identitySource: 'explicit', + now: NOW, + }); + + const manifest = JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ) as { manifestVersion: number; minWriterVersion: string }; + expect(manifest).toMatchObject({ + manifestVersion: 2, + minWriterVersion: '0.4.0', + }); + const authorityBefore = await snapshotTree(path.join(root, '.mancode')); + + const result = await runLegacyCli( + legacyCli, + [ + 'team', + 'policy', + 'off', + '--expected-revision', + '1', + '--session', + sessionId, + '--client', + 'legacy-cli-contract', + '--json', + ], + root, + ); + + expect(result).toMatchObject({ exitCode: 3, signal: null, stderr: '' }); + expect(JSON.parse(result.stdout)).toEqual({ + schemaVersion: 1, + error: { + code: 'MANCODE_TEAM_POLICY_UPDATE_FAILED', + message: + 'schema manifest contains unknown field(s): workflowPolicyDefaults', + }, + }); + expect(await snapshotTree(path.join(root, '.mancode'))).toEqual( + authorityBefore, + ); + }); +}); + +interface CliResult { + exitCode: number; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +function runLegacyCli( + legacyCli: string, + args: string[], + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + execFileCallback( + process.execPath, + [legacyCli, ...args], + { cwd, maxBuffer: 1024 * 1024 }, + (error, stdout, stderr) => { + if (error !== null && typeof error.code !== 'number') { + reject(error); + return; + } + const processError = error as ExecFileException | null; + resolve({ + exitCode: + processError !== null && typeof processError.code === 'number' + ? processError.code + : 0, + signal: processError?.signal ?? null, + stdout, + stderr, + }); + }, + ); + }); +} + +async function expectRootBinUnregistered(): Promise { + for (const name of ['mancode', 'mancode.cmd', 'mancode.ps1']) { + await expect( + lstat(path.join(process.cwd(), 'node_modules', '.bin', name)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } +} + +async function extractLegacyPackage( + fixture: Buffer, + destination: string, +): Promise { + const entries = readTarFiles(gunzipSync(fixture)); + const selected = [...entries].filter( + ([name]) => + name === 'package/package.json' || + (name.startsWith('package/dist/') && name.endsWith('.js')), + ); + if (selected.length < 2) { + throw new Error('legacy CLI fixture does not contain its published dist'); + } + for (const [name, bytes] of selected) { + const target = path.join(destination, name); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, bytes); + } +} + +function readTarFiles(archive: Buffer): Map { + const entries = new Map(); + let offset = 0; + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const name = tarText(header.subarray(0, 100)); + const prefix = tarText(header.subarray(345, 500)); + const entryName = prefix ? `${prefix}/${name}` : name; + const sizeText = tarText(header.subarray(124, 136)); + if (!/^[0-7]+$/.test(sizeText)) { + throw new Error( + `legacy CLI fixture has an invalid tar size: ${entryName}`, + ); + } + const size = Number.parseInt(sizeText, 8); + const dataStart = offset + 512; + const dataEnd = dataStart + size; + if (dataEnd > archive.length) { + throw new Error('legacy CLI fixture tar entry exceeds the archive'); + } + const type = header[156]; + if (type === 0 || type === 48) { + if ( + !entryName.startsWith('package/') || + entryName.split('/').includes('..') + ) { + throw new Error('legacy CLI fixture contains an unsafe tar path'); + } + entries.set(entryName, Buffer.from(archive.subarray(dataStart, dataEnd))); + } + offset = dataStart + Math.ceil(size / 512) * 512; + } + return entries; +} + +function tarText(field: Buffer): string { + const end = field.indexOf(0); + return field + .subarray(0, end === -1 ? field.length : end) + .toString() + .trim(); +} + +interface TreeEntry { + path: string; + type: 'directory' | 'file' | 'symlink' | 'other'; + bytes?: string; + target?: string; +} + +async function snapshotTree(root: string): Promise { + const snapshot: TreeEntry[] = []; + await visit(root, ''); + return snapshot; + + async function visit(absoluteDirectory: string, relativeDirectory: string) { + const names = (await readdir(absoluteDirectory)).sort(); + for (const name of names) { + const absolute = path.join(absoluteDirectory, name); + const relative = path.join(relativeDirectory, name); + const stats = await lstat(absolute); + if (stats.isDirectory()) { + snapshot.push({ path: relative, type: 'directory' }); + await visit(absolute, relative); + } else if (stats.isFile()) { + snapshot.push({ + path: relative, + type: 'file', + bytes: (await readFile(absolute)).toString('base64'), + }); + } else if (stats.isSymbolicLink()) { + snapshot.push({ + path: relative, + type: 'symlink', + target: await readlink(absolute), + }); + } else { + snapshot.push({ path: relative, type: 'other' }); + } + } + } +} + +function id(offset: number): Ulid { + return createUlid(NOW.getTime() + offset, new Uint8Array(10).fill(offset)); +} diff --git a/tests/manifest-session-contracts.test.ts b/tests/manifest-session-contracts.test.ts index 3baabc5..614c0b9 100644 --- a/tests/manifest-session-contracts.test.ts +++ b/tests/manifest-session-contracts.test.ts @@ -88,6 +88,55 @@ describe('schema manifest contract', () => { /invalid schema manifest transition/, ); }); + + it('strictly separates V1 from the Policy 2 V2 manifest shape', () => { + expect(() => + parseSchemaManifest({ + ...manifest({}), + workflowPolicyDefaults: { planning: 2 }, + }), + ).toThrow(/unknown field\(s\): workflowPolicyDefaults/); + + const v2 = parseSchemaManifest({ + ...manifest({}), + manifestVersion: 2, + workflowPolicyDefaults: { planning: 2 }, + }); + expect(v2).toMatchObject({ + manifestVersion: 2, + workflowPolicyDefaults: { planning: 2 }, + }); + expect(() => + parseSchemaManifest({ + ...v2, + minWriterVersion: '0.4.0-beta.1', + }), + ).toThrow(/V2 requires minReaderVersion and minWriterVersion 0.4.0/); + expect(() => + parseSchemaManifest({ + ...manifest({}), + manifestVersion: 2, + workflowPolicyDefaults: { + planning: 2, + review: 1, + }, + }), + ).toThrow(/unknown field\(s\): review/); + }); + + it('treats managedAdapters as the registered required-platform inventory', () => { + expect( + parseSchemaManifest(manifest({ managedAdapters: {} })).managedAdapters, + ).toEqual({}); + expect( + parseSchemaManifest( + manifest({ managedAdapters: { codex: '3', cursor: '3' } }), + ).managedAdapters, + ).toEqual({ codex: '3', cursor: '3' }); + expect(() => + parseSchemaManifest(manifest({ managedAdapters: { codex: '' } })), + ).toThrow(/managedAdapters\.codex must be a non-empty version/); + }); }); describe('session identity contract', () => { diff --git a/tests/migrate-contracts.test.ts b/tests/migrate-contracts.test.ts index 626d443..a93d1db 100644 --- a/tests/migrate-contracts.test.ts +++ b/tests/migrate-contracts.test.ts @@ -255,8 +255,8 @@ describe('legacy migration stage contract', () => { expect(migratedAgents).toContain('# User instructions'); expect(migratedAgents).not.toContain('Legacy mancode instructions'); expect(migratedAgents).not.toContain('Legacy ZCode mancode instructions'); - expect(migratedAgents).toContain('mancode:v3:codex:start'); - expect(migratedAgents).toContain('mancode:v3:zcode:start'); + expect(migratedAgents).toContain('mancode:continuity:codex:start'); + expect(migratedAgents).toContain('mancode:continuity:zcode:start'); const migratedClaudeSettings = await readFile( path.join(root, '.claude', 'settings.json'), 'utf8', @@ -551,7 +551,7 @@ async function writeLegacyFixture(root: string): Promise { status: 'in_progress', planVersion: 2, planningPolicyVersion: 2, - reviewPolicyVersion: 2, + reviewPolicyVersion: 1, verificationPolicyVersion: 1, requirementsStatus: 'ready', requirementsDigest: diff --git a/tests/operation-definition-contracts.test.ts b/tests/operation-definition-contracts.test.ts index f22a301..a59a01a 100644 --- a/tests/operation-definition-contracts.test.ts +++ b/tests/operation-definition-contracts.test.ts @@ -11,7 +11,7 @@ const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; describe('machine-readable operation definitions', () => { it('gives every hard-consistency operation steps, a visible commit point, and crash recovery fixtures', () => { - expect(Object.keys(OPERATION_DEFINITIONS)).toHaveLength(23); + expect(Object.keys(OPERATION_DEFINITIONS)).toHaveLength(27); for (const definition of Object.values(OPERATION_DEFINITIONS)) { expect(definition.steps.at(-1)).toMatchObject({ id: 'commit', diff --git a/tests/operation-journal-contracts.test.ts b/tests/operation-journal-contracts.test.ts index 019e364..d17f035 100644 --- a/tests/operation-journal-contracts.test.ts +++ b/tests/operation-journal-contracts.test.ts @@ -113,6 +113,24 @@ describe('operation journal contract', () => { expect(() => assertOperationJournalTransition(applying, aborted, { canAbort: false }), ).toThrow(/cannot abort/); + const repairRequired = parseOperationJournal({ + ...applying, + state: 'repair_required', + }); + const safelyAbortedRepair = parseOperationJournal({ + ...repairRequired, + state: 'aborted', + }); + expect(() => + assertOperationJournalTransition(repairRequired, safelyAbortedRepair, { + canAbort: true, + }), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(repairRequired, safelyAbortedRepair, { + canAbort: false, + }), + ).toThrow(/cannot abort/); }); }); diff --git a/tests/operation-recovery-executor-contracts.test.ts b/tests/operation-recovery-executor-contracts.test.ts index 5e6fa43..3fce163 100644 --- a/tests/operation-recovery-executor-contracts.test.ts +++ b/tests/operation-recovery-executor-contracts.test.ts @@ -127,6 +127,8 @@ describe('operation recovery executor', () => { inspectOperationRecovery(root, operationId), ).resolves.toMatchObject({ journal: { state: 'committed' }, + recoveryAction: 'none', + recoveryReason: 'terminal', payloadBound: true, }); await expect(readLocalDiagnostics(root)).resolves.toMatchObject({ diff --git a/tests/platform-spike-contracts.test.ts b/tests/platform-spike-contracts.test.ts index e4cd764..da94e1b 100644 --- a/tests/platform-spike-contracts.test.ts +++ b/tests/platform-spike-contracts.test.ts @@ -15,9 +15,10 @@ describe('platform session identity spike contract', () => { const spike = createPlatformSessionSpike({ platform: 'codex', observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'api', - firstWindowHostSessionKey: 'desktop-window-a-private-key', - secondWindowHostSessionKey: 'desktop-window-b-private-key', + firstWindowSessionKey: 'desktop-window-a-private-key', + secondWindowSessionKey: 'desktop-window-b-private-key', commandPropagation: 'proven', subagentInheritance: 'not_tested', hookApproval: 'not_applicable', @@ -44,9 +45,10 @@ describe('platform session identity spike contract', () => { const collision = createPlatformSessionSpike({ platform: 'cursor', observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'environment', - firstWindowHostSessionKey: 'same-window-key', - secondWindowHostSessionKey: 'same-window-key', + firstWindowSessionKey: 'same-window-key', + secondWindowSessionKey: 'same-window-key', commandPropagation: 'not_proven', subagentInheritance: 'proven', hookApproval: 'not_applicable', @@ -59,6 +61,50 @@ describe('platform session identity spike contract', () => { ready: false, missingPlatforms: expect.arrayContaining(['codex']), explicitRequiredPlatforms: expect.arrayContaining(['cursor']), + unverifiedPlatforms: expect.arrayContaining(['cursor']), + }); + }); + + it('accepts proven explicit-session isolation without trusting host identity', () => { + const spikes = ['claude-code', 'codex', 'cursor', 'copilot', 'zcode'].map( + (platform, index) => + createPlatformSessionSpike({ + platform: platform as + | 'claude-code' + | 'codex' + | 'cursor' + | 'copilot' + | 'zcode', + observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'explicit', + hostSessionSource: 'none', + firstWindowSessionKey: `session-${index}-a`, + secondWindowSessionKey: `session-${index}-b`, + commandPropagation: 'proven', + subagentInheritance: 'not_applicable', + subagentInheritanceReason: 'This host exposes no child-agent API.', + hookApproval: 'not_applicable', + evidence: evidence(), + }), + ); + + const firstSpike = spikes[0]; + if (firstSpike === undefined) throw new Error('missing platform spike'); + expect(evaluatePlatformSessionCapability(firstSpike)).toMatchObject({ + hostIdentity: 'explicit_required', + releaseEvidence: 'explicit_session_verified', + }); + expect(platformSpikeFreezeStatus(spikes)).toMatchObject({ + ready: true, + missingPlatforms: [], + unverifiedPlatforms: [], + explicitRequiredPlatforms: [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', + ], }); }); @@ -67,9 +113,10 @@ describe('platform session identity spike contract', () => { createPlatformSessionSpike({ platform: 'claude-code', observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'api', - firstWindowHostSessionKey: 'window-a', - secondWindowHostSessionKey: 'window-b', + firstWindowSessionKey: 'window-a', + secondWindowSessionKey: 'window-b', commandPropagation: 'proven', subagentInheritance: 'not_applicable', hookApproval: 'not_applicable', @@ -80,9 +127,10 @@ describe('platform session identity spike contract', () => { const documented = createPlatformSessionSpike({ platform: 'claude-code', observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'api', - firstWindowHostSessionKey: 'window-a', - secondWindowHostSessionKey: 'window-b', + firstWindowSessionKey: 'window-a', + secondWindowSessionKey: 'window-b', commandPropagation: 'proven', subagentInheritance: 'not_applicable', subagentInheritanceReason: 'This host does not expose child agents.', @@ -98,9 +146,10 @@ describe('platform session identity spike contract', () => { const spike = createPlatformSessionSpike({ platform: 'zcode', observedAt: '2026-07-17T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'api', - firstWindowHostSessionKey: 'window-a', - secondWindowHostSessionKey: 'window-b', + firstWindowSessionKey: 'window-a', + secondWindowSessionKey: 'window-b', commandPropagation: 'proven', subagentInheritance: 'proven', hookApproval: 'not_applicable', diff --git a/tests/platform-spike-store-contracts.test.ts b/tests/platform-spike-store-contracts.test.ts index 9d1fa7f..5b439d2 100644 --- a/tests/platform-spike-store-contracts.test.ts +++ b/tests/platform-spike-store-contracts.test.ts @@ -52,9 +52,10 @@ function fixtureSpike() { return createPlatformSessionSpike({ platform: 'codex', observedAt: '2026-07-18T12:00:00.000Z', + sessionMode: 'host', hostSessionSource: 'api', - firstWindowHostSessionKey: 'window-a', - secondWindowHostSessionKey: 'window-b', + firstWindowSessionKey: 'window-a', + secondWindowSessionKey: 'window-b', commandPropagation: 'proven', subagentInheritance: 'proven', hookApproval: 'not_applicable', diff --git a/tests/project-policy-upgrade-contracts.test.ts b/tests/project-policy-upgrade-contracts.test.ts new file mode 100644 index 0000000..4a4b5b5 --- /dev/null +++ b/tests/project-policy-upgrade-contracts.test.ts @@ -0,0 +1,322 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { status } from '../src/commands/status.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { + dryRunProjectPolicyUpgrade, + upgradeProjectPolicy, +} from '../src/context/project-policy-upgrade.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { parseWorkflowMetadata } from '../src/context/workflow-metadata.js'; +import { updateV3Workflow } from '../src/context/workflow-update.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { + executeOperationRecovery, + listUnfinishedOperationRecoveries, +} from '../src/runtime/operation-recovery-executor.js'; +import { createSession } from '../src/runtime/session.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-21T10:00:00.000Z'); + +describe('project Policy 2 upgrade', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-project-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('dry-runs without writes, commits V1 to V2, and preserves old workflow provenance', async () => { + const { actorId, sessionId } = await bootstrap(root, 0); + const oldWorkflow = await createV3Workflow({ + projectRoot: root, + task: 'Preserve the Policy 1 workflow.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + now: NOW, + }); + expect(oldWorkflow.metadata.governance.policyVersions.planning).toBe(1); + const schemaPath = path.join(root, '.mancode', 'schema.json'); + const oldMetadataPath = path.join( + taskRootPath(root, oldWorkflow.taskRef), + 'metadata.json', + ); + const [schemaBefore, oldMetadataBefore] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(oldMetadataPath, 'utf8'), + ]); + + const preview = await dryRunProjectPolicyUpgrade({ + projectRoot: root, + policyVersion: 2, + operationId: id(12), + now: NOW, + }); + expect(preview).toMatchObject({ + policy: 2, + currentManifestVersion: 1, + willUpgrade: true, + blockers: [], + operationId: id(12), + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + }); + await expect(readFile(schemaPath, 'utf8')).resolves.toBe(schemaBefore); + + const upgraded = await upgradeProjectPolicy({ + projectRoot: root, + policyVersion: 2, + sessionId, + operationId: id(12), + now: NOW, + }); + expect(upgraded).toMatchObject({ + state: 'committed', + manifest: { + manifestVersion: 2, + workflowPolicyDefaults: { planning: 2 }, + lastOperationId: id(12), + }, + operation: { type: 'project_policy_upgrade', state: 'committed' }, + }); + expect(digestCanonicalJson(upgraded.manifest)).toBe(preview.afterDigest); + await expect(readFile(oldMetadataPath, 'utf8')).resolves.toBe( + oldMetadataBefore, + ); + + const newWorkflow = await createV3Workflow({ + projectRoot: root, + task: 'Use the upgraded planning policy.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(14), + operationId: id(15), + now: new Date(NOW.getTime() + 1_000), + }); + expect(newWorkflow.metadata.governance.policyVersions).toEqual({ + planning: 2, + review: 1, + verification: 1, + }); + const standaloneManba = await createV3Workflow({ + projectRoot: root, + task: 'Keep diagnosis on its established planning policy.', + workflowMode: 'manba', + sessionId, + client: 'vitest', + taskId: id(16), + operationId: id(17), + now: new Date(NOW.getTime() + 2_000), + }); + expect(standaloneManba.metadata.governance.policyVersions.planning).toBe(1); + const oldMetadata = parseWorkflowMetadata( + JSON.parse(await readFile(oldMetadataPath, 'utf8')), + ); + expect(oldMetadata.governance.policyVersions.planning).toBe(1); + }); + + it('repairs or safely aborts every declared project-upgrade crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.project_policy_upgrade.entries()) { + const caseRoot = path.join(root, `crash-${index}`); + await mkdir(caseRoot, { recursive: true }); + const { actorId, sessionId } = await bootstrap(caseRoot, index + 20); + const operationId = id(index + 100); + + await dryRunProjectPolicyUpgrade({ + projectRoot: caseRoot, + policyVersion: 2, + operationId, + now: NOW, + }); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + upgradeProjectPolicy({ + projectRoot: caseRoot, + policyVersion: 2, + sessionId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId, + sessionId, + now: NOW, + }); + const manifest = JSON.parse( + await readFile(path.join(caseRoot, '.mancode', 'schema.json'), 'utf8'), + ) as { manifestVersion: number }; + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered.journal.state).toBe('aborted'); + expect(manifest.manifestVersion).toBe(1); + } else { + expect(recovered.journal.state).toBe('committed'); + expect(manifest.manifestVersion).toBe(2); + } + } + }); + + it('blocks ordinary writes after a visible upgrade crash and repairs on retry', async () => { + const { sessionId } = await bootstrap(root, 40); + const existing = await createV3Workflow({ + projectRoot: root, + task: 'Keep an existing Policy 1 workflow stable during repair.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(50), + operationId: id(51), + now: NOW, + }); + const operationId = id(52); + await dryRunProjectPolicyUpgrade({ + projectRoot: root, + policyVersion: 2, + operationId, + now: NOW, + }); + + await expect( + withOperationCrashInjectionForTesting( + { + operationType: 'project_policy_upgrade', + crashAfter: 'write-manifest', + }, + () => + upgradeProjectPolicy({ + projectRoot: root, + policyVersion: 2, + sessionId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + await expect( + listUnfinishedOperationRecoveries(root), + ).resolves.toMatchObject([ + { journal: { operationId, state: 'repair_required' } }, + ]); + + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await expect(status(root, { brief: true, json: true })).resolves.toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + state: 'unavailable', + ready: false, + blockers: expect.arrayContaining(['MANCODE_OPERATION_REPAIR_REQUIRED']), + }); + } finally { + logs.mockRestore(); + } + + await expect( + createV3Workflow({ + projectRoot: root, + task: 'Do not create authority while project repair is required.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(53), + operationId: id(54), + now: new Date(NOW.getTime() + 1_000), + }), + ).rejects.toThrow('MANCODE_OPERATION_REPAIR_REQUIRED'); + await expect( + updateV3Workflow({ + projectRoot: root, + taskRef: existing.taskRef, + sessionId, + expectedTaskRevision: existing.metadata.revision, + status: 'blocked', + blockingReason: 'Project policy repair must finish first.', + operationId: id(55), + now: new Date(NOW.getTime() + 1_000), + }), + ).rejects.toThrow('MANCODE_OPERATION_REPAIR_REQUIRED'); + + await expect( + upgradeProjectPolicy({ + projectRoot: root, + policyVersion: 2, + sessionId, + operationId, + now: new Date(NOW.getTime() + 2_000), + }), + ).resolves.toMatchObject({ + state: 'committed', + operation: { operationId, state: 'committed' }, + manifest: { manifestVersion: 2 }, + }); + await expect(listUnfinishedOperationRecoveries(root)).resolves.toEqual([]); + }); +}); + +async function bootstrap( + projectRoot: string, + offset: number, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(offset + 1), + workspaceId: id(offset + 2), + schemaEpoch: id(offset + 3), + now: NOW, + }); + const schemaPath = path.join(projectRoot, '.mancode', 'schema.json'); + const historical = JSON.parse(await readFile(schemaPath, 'utf8')) as Record< + string, + unknown + >; + historical.manifestVersion = 1; + historical.workflowPolicyDefaults = undefined; + await writeFile(schemaPath, `${JSON.stringify(historical, null, 2)}\n`); + const actorId = id(offset + 4); + const sessionId = id(offset + 5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Policy Upgrade User', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { actorId, sessionId }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-21T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/projection-eventual-consistency-e2e.test.ts b/tests/projection-eventual-consistency-e2e.test.ts index 3bafece..ff928dd 100644 --- a/tests/projection-eventual-consistency-e2e.test.ts +++ b/tests/projection-eventual-consistency-e2e.test.ts @@ -5,7 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { contextDoctor } from '../src/commands/context.js'; import { initializeV3Project } from '../src/commands/v3-init.js'; import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { CURRENT_WRITER_CAPABILITIES } from '../src/context/compatibility.js'; import { type Ulid, createUlid } from '../src/context/ids.js'; +import { reviseV3Plan } from '../src/context/plan-revision.js'; +import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { + REQUIREMENT_DIMENSIONS, + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; import { ContextResolver } from '../src/context/resolver.js'; import { V3ContextStore } from '../src/context/store.js'; import { taskRootPath } from '../src/context/task-locator.js'; @@ -15,6 +24,7 @@ import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; import { type CacheInvalidationProjectionTargetV1, + enqueueSessionPointerProjection, listProjectionIntents, projectionCachePath, } from '../src/runtime/projection-outbox.js'; @@ -164,6 +174,107 @@ describe('eventual projection filesystem E2E', () => { await expect(listProjectionIntents(root)).resolves.toEqual([]); }); + it('repairs a failed plan-only clear and never lets an older resume projection rebind it', async () => { + const created = await createV3Workflow({ + projectRoot: root, + task: 'Keep plan-only authority detached from the active session.', + workflowMode: 'man', + sessionId: SESSION_ID, + client: 'vitest', + taskId: id(40), + operationId: id(41), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: SESSION_ID, + expectedTaskRevision: created.metadata.revision, + requirements: confirmedRequirements(created.requirements), + operationId: id(42), + now: NOW, + }); + const plan = '# Plan\n\n1. Keep this plan without executing it.\n'; + const revised = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: SESSION_ID, + expectedTaskRevision: finalized.metadata.revision, + plan, + operationId: id(43), + now: NOW, + }); + const staleResumeOperationId = id(44); + await enqueueSessionPointerProjection(root, { + operationId: staleResumeOperationId, + action: 'resume', + sessionId: SESSION_ID, + expectedPreviousTaskRef: null, + taskRef: created.taskRef, + workflowMode: created.metadata.workflowMode, + taskRevision: revised.metadata.revision, + now: NOW, + }); + const planOnlyOperationId = id(45); + const sessionLock = path.join( + root, + '.mancode', + 'local', + 'sessions', + `.${SESSION_ID}.lock`, + ); + await mkdir(sessionLock); + + const planned = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: SESSION_ID, + expectedTaskRevision: revised.metadata.revision, + plan, + planDecision: 'plan_only', + operationId: planOnlyOperationId, + now: NOW, + }); + expect(planned).toMatchObject({ + metadata: { + status: 'planned', + governance: { + planDecision: 'plan_only', + planVersion: revised.metadata.governance.planVersion, + }, + }, + operation: { state: 'committed' }, + }); + await expect(readSession(root, SESSION_ID)).resolves.toMatchObject({ + activeTaskRef: created.taskRef, + }); + await expect(listProjectionIntents(root)).resolves.toHaveLength(2); + + await rm(sessionLock, { recursive: true }); + await expect(runDoctorRepair(root, planOnlyOperationId)).resolves.toBe(0); + await expect(readSession(root, SESSION_ID)).resolves.toMatchObject({ + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + }); + + await expect(runDoctorRepair(root, staleResumeOperationId)).resolves.toBe( + 0, + ); + await expect(readSession(root, SESSION_ID)).resolves.toMatchObject({ + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + }); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + await expect( + listProjectionIntents(root, { + operationId: staleResumeOperationId, + includeTerminal: true, + }), + ).resolves.toMatchObject([{ state: 'superseded' }]); + }); + it.each(['closed', 'missing'] as const)( 'supersedes a session projection when its target session is %s', async (targetState) => { @@ -359,7 +470,8 @@ async function resolveTask( expectedSchemaEpoch: project.manifest.epoch, readerVersion: project.manifest.minReaderVersion, writerVersion: project.manifest.minWriterVersion, - adapterVersions: project.manifest.managedAdapters, + writerCapabilities: CURRENT_WRITER_CAPABILITIES, + adapterVersions: {}, }, generatedAt: NOW, }); @@ -385,6 +497,49 @@ async function runDoctorRepair( } } +function confirmedRequirements( + previous: RequirementsLedgerV1, +): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + ...previous, + revision: 99, + status: 'confirmed', + goal: 'Preserve a plan without retaining an active workflow pointer.', + functionalScope: { + inScope: ['Plan-only session projection'], + outOfScope: [], + }, + technicalDecisions: [], + defaults: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension, index) => ({ + coverageId: id(60 + index), + dimension, + status: dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: `Confirmed ${dimension} coverage.`, + })), + requirements: [], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: null, + criterionId: id(68), + requirementIds: [], + statement: 'The active session pointer is cleared after plan-only.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + contentDigest: '', + lastOperationId: id(69), + updatedAt: NOW.toISOString(), + }; + return parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); +} + function id(offset: number): Ulid { return createUlid( Date.parse('2026-07-18T00:00:00.000Z') + offset, diff --git a/tests/release-check-contracts.test.ts b/tests/release-check-contracts.test.ts new file mode 100644 index 0000000..781d93c --- /dev/null +++ b/tests/release-check-contracts.test.ts @@ -0,0 +1,38 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const root = process.cwd(); +const scriptPath = path.join(root, 'scripts', 'release-check.mjs'); + +describe('release candidate check', () => { + it('documents the immutable candidate input without publishing npm', async () => { + const { stdout } = await execFileAsync(process.execPath, [ + scriptPath, + '--help', + ]); + expect(stdout).toContain('--candidate '); + expect(stdout).toContain('--output '); + + const script = await readFile(scriptPath, 'utf8'); + expect(script).toContain("'cross_clone'"); + expect(script).toContain("'legacy_migration'"); + expect(script).toContain("'tarball_install'"); + expect(script).toContain("'origin_main_unchanged'"); + expect(script).toContain("createHash('sha256')"); + expect(script).not.toMatch(/['"]publish['"]/); + expect(script).not.toContain('dist-tag'); + }); + + it('is registered as an explicit package script', async () => { + const packageMetadata = JSON.parse( + await readFile(path.join(root, 'package.json'), 'utf8'), + ) as { scripts: Record }; + expect(packageMetadata.scripts['release:check']).toBe( + 'node scripts/release-check.mjs', + ); + }); +}); diff --git a/tests/shared-content.test.ts b/tests/shared-content.test.ts index f604e24..cf9e1a4 100644 --- a/tests/shared-content.test.ts +++ b/tests/shared-content.test.ts @@ -52,6 +52,11 @@ describe('generateSharedContent', () => { expect(content).toContain('Platform Downgrade'); expect(content).toContain('Simulate the coaching staff'); expect(content).toContain('Scout, Plan Coach, Head Coach'); + expect(content).toContain('semantic owner or source of truth is unclear'); + expect(content).toContain('historical compatibility'); + expect(content).toContain('NEEDS_REALIGNMENT'); + expect(content).toContain('MANCODE_REFRAME_REQUIRED'); + expect(content).toContain('do not call generic workflow update'); }); it('renders the public manba name for legacy workflow state', async () => { diff --git a/tests/skills.test.ts b/tests/skills.test.ts index 50781fe..f961d54 100644 --- a/tests/skills.test.ts +++ b/tests/skills.test.ts @@ -66,6 +66,18 @@ describe('mvp-2 skills', () => { expect(MAN_SKILL.body).toMatch(/workflow decide/); expect(MAN_SKILL.body).toMatch(/solo 轻量执行/); expect(MAN_SKILL.body).toMatch(/开工回执/); + expect(MAN_SKILL.body).toMatch(/Current Behavior Evidence/); + expect(MAN_SKILL.body).toMatch(/Candidate Semantic Owner/); + expect(MAN_SKILL.body).toMatch(/Source of Truth/); + expect(MAN_SKILL.body).toMatch(/Historical \/ Compatibility Impact/); + expect(MAN_SKILL.body).toMatch(/complexity bearer/); + expect(MAN_SKILL.body).toMatch(/一个 recommendation/); + expect(MAN_SKILL.body).toMatch(/简单任务.*只列一个方向/); + expect(MAN_SKILL.body).toMatch(/stop conditions/); + expect(MAN_SKILL.body).toMatch(/Domain Matrix/); + expect(MAN_SKILL.body).toContain('NEEDS_REALIGNMENT'); + expect(MAN_SKILL.body).toContain('MANCODE_REFRAME_REQUIRED'); + expect(MAN_SKILL.body).toMatch(/不得调用通用.*workflow update.*blocked/); }); it('keeps solo review bounded and lightweight', () => { @@ -77,6 +89,22 @@ describe('mvp-2 skills', () => { expect(SOLO_SKILL).toMatch(/activeSoloPlan/); expect(SOLO_SKILL).toMatch(/不重新规划/); expect(SOLO_SKILL).toMatch(/handoff --complete/); + for (const signal of [ + /平台入口或流程不一致/, + /semantic owner 不清/, + /source of truth.*不清/, + /status、contract、policy.*语义会变化/, + /scope、架构、成本或验收.*跨文件\/跨模块/, + /历史兼容/, + /迁移、跨平台/, + /团队协调证据/, + ]) { + expect(SOLO_SKILL).toMatch(signal); + } + expect(SOLO_SKILL).toMatch(/不自动改变 mode/); + expect(SOLO_SKILL).toContain('NEEDS_REALIGNMENT'); + expect(SOLO_SKILL).toContain('MANCODE_REFRAME_REQUIRED'); + expect(SOLO_SKILL).toMatch(/这是只读诊断/); }); it('defines manba diagnosis and real browser validation boundaries', () => { @@ -122,6 +150,14 @@ describe('mvp-2 skills', () => { expect(man).toMatch(/targeted review/); expect(man).toMatch(/one remediation round/); expect(man).toMatch(/workflow review/); + expect(man).toMatch(/Current Behavior Evidence/); + expect(man).toMatch(/complexity bearer/); + expect(man).toMatch(/exactly one recommendation/); + expect(man).toMatch(/one real direction/); + expect(man).toMatch(/Domain Matrix/); + expect(man).toContain('NEEDS_REALIGNMENT'); + expect(man).toContain('MANCODE_REFRAME_REQUIRED'); + expect(man).toMatch(/Do not call generic workflow update/); const manba = renderModeSkill('manba', '/'); expect(manba).toMatch(/workflow create manba/); @@ -142,6 +178,10 @@ describe('mvp-2 skills', () => { const mansolo = renderModeSkill('mansolo', '/'); expect(mansolo).toMatch(/workflow show/); expect(mansolo).toMatch(/active children before their\s+parent/); + expect(mansolo).toMatch(/semantic owner or source of truth is unclear/); + expect(mansolo).toContain('NEEDS_REALIGNMENT'); + expect(mansolo).toContain('MANCODE_REFRAME_REQUIRED'); + expect(mansolo).toMatch(/This is read-only/); }); it('renders skill frontmatter', () => { diff --git a/tests/status.test.ts b/tests/status.test.ts index ce53417..1a16c5b 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -210,6 +210,22 @@ describe('mancode status', () => { expect(output).not.toContain('v3_active'); }); + it('--brief keeps an unregistered checkout in registration-required state', async () => { + await silentInit(dir, { v3: true, platform: 'codex' }); + await rm(path.join(dir, '.mancode', 'local', 'runtime', 'checkout.json')); + + const logs = await captureLog(() => + status(dir, { brief: true, json: true }), + ); + const result: ContinuityStatusResult = JSON.parse(logs.join('\n')); + + expect(result).toMatchObject({ + state: 'unavailable', + ready: false, + blockers: expect.arrayContaining(['MANCODE_WORKSPACE_BINDING_MISMATCH']), + }); + }); + it('omits top-level Hooks section for non-Claude Code projects', async () => { await silentInit(dir, { platform: 'codex' }); diff --git a/tests/v3-adapter-contracts.test.ts b/tests/v3-adapter-contracts.test.ts index d7aa4a9..0f194c7 100644 --- a/tests/v3-adapter-contracts.test.ts +++ b/tests/v3-adapter-contracts.test.ts @@ -2,15 +2,19 @@ import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { contextSessionNew } from '../src/commands/context.js'; import { init } from '../src/commands/init.js'; import { install } from '../src/commands/install.js'; import { listPlatforms } from '../src/commands/list-platforms.js'; import { refreshProject } from '../src/commands/refresh-project.js'; import { type V3StatusResult, status } from '../src/commands/status.js'; +import { teamIdentityCreate } from '../src/commands/team.js'; import { EXIT_V3_AUTHORITY_PROTECTED, uninstall, } from '../src/commands/uninstall.js'; +import { parseSchemaManifest } from '../src/context/manifest.js'; +import { upgradeV3Adapters } from '../src/installers/adapter-upgrade.js'; import type { PlatformName } from '../src/installers/registry.js'; import { V3_MODE_NAMES, @@ -80,12 +84,45 @@ describe('V3 adapter bootstrap integration', () => { expect(textOutput).toContain('codex'); expect(textOutput).not.toContain('explicit required ()'); - expect(await install(root, 'cursor')).toBe(0); + expect( + await teamIdentityCreate(root, { + name: 'Adapter Maintainer', + json: true, + }), + ).toBe(0); + expect( + await contextSessionNew(root, { client: 'fixture', json: true }), + ).toBe(0); + const sessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + const preview = await upgradeV3Adapters({ + projectRoot: root, + platforms: ['cursor'], + dryRun: true, + }); + expect( + await install(root, 'cursor', { + confirm: true, + operationId: preview.operationId, + session: sessionId, + client: 'fixture', + }), + ).toBe(0); const cursorRule = await readFile( - path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), 'utf8', ); expect(cursorRule).toContain('# mancode bootstrap'); + expect( + parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ).managedAdapters, + ).toMatchObject({ codex: '3', cursor: '3' }); await expect( readFile(path.join(root, '.mancode', 'config.json'), 'utf8'), ).rejects.toThrow(); @@ -115,6 +152,15 @@ describe('V3 adapter bootstrap integration', () => { const bootstrap = await readFile(target, 'utf8'); expect(bootstrap).toContain('# mancode bootstrap'); expect(bootstrap).toContain('mancode context show --purpose orient'); + expect(bootstrap).toContain( + './node_modules/.bin/mancode` when it exists, otherwise use `mancode', + ); + expect(bootstrap).toContain( + 'In every command below, `mancode` means that selected binary', + ); + expect(bootstrap).toContain( + 'mancode context session show --session --client --json', + ); expect(bootstrap).toContain('--session '); expect(bootstrap).toContain('mancode status --brief --json'); expect(bootstrap).toContain( @@ -123,6 +169,30 @@ describe('V3 adapter bootstrap integration', () => { expect(bootstrap).toContain( 'In operator-facing narration, say `mancode`', ); + expect(bootstrap).toContain( + 'treat an ordinary requested coding task as default Solo work', + ); + expect(bootstrap).toContain( + 'Ordinary Solo work requires no actor identity, session, TaskRef, or workflow', + ); + expect(bootstrap).toContain( + 'A supplied instruction is not automatically sound', + ); + expect(bootstrap).toContain( + 'classify each remaining unknown as blocking, recommendable, or defaultable', + ); + expect(bootstrap).toContain( + 'hard-risk change involving authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure', + ); + expect(bootstrap).toContain( + 'explicitly asking for research, a plan, architecture, migration design, or formal acceptance authorizes the `man` planning path', + ); + expect(bootstrap).toContain( + 'For governed task work only, if status has no `identity.actorId`', + ); + expect(bootstrap).toContain( + 'A plain-language Solo request is not a TaskRef and needs no Context Pack.', + ); expect(bootstrap).toContain('mancode team identity create --name'); expect(bootstrap).toContain( '`task: null` and `MANCODE_TASK_REQUIRED` do not make a session stale', @@ -156,7 +226,8 @@ describe('V3 adapter bootstrap integration', () => { ), ).toBe(true); if (platform === 'claude-code') { - expect(bootstrap).toContain('user-invocable: false'); + expect(installed.target).toBe('CLAUDE.md'); + expect(bootstrap).toContain('mancode:continuity:claude:start'); } for (const mode of V3_MODE_NAMES) { @@ -175,6 +246,10 @@ describe('V3 adapter bootstrap integration', () => { expect(description).toContain('mancode'); expect(description).not.toContain('V3'); expect(entry).toContain('# mancode mode'); + expect(entry).toContain('./node_modules/.bin/mancode` when it exists'); + expect(entry).toContain( + 'replace the literal `mancode` with that selected binary path', + ); expect(entry).toContain('## Enter through mancode'); expect(entry).toContain('In operator-facing narration, say `mancode`'); expect(entry).not.toMatch(/\bV3\b/); @@ -199,6 +274,56 @@ describe('V3 adapter bootstrap integration', () => { 'without creating an actor, session, TaskRef, or workflow', ); expect(entry).toContain('internal IDs and digests'); + expect(entry).toContain( + 'must contain exactly one item for each dimension', + ); + expect(entry).toContain('platform'); + expect(entry).toContain('core_scope'); + expect(entry).toContain('technical_stack'); + expect(entry).toContain('data_and_persistence'); + expect(entry).toContain('performance'); + expect(entry).toContain('compatibility'); + expect(entry).toContain('security'); + expect(entry).toContain('"status": "confirmed"'); + expect(entry).toContain('"rationale": "..."'); + expect(entry).toContain('acceptanceCriteria'); + expect(entry).toContain('"method": "automated"'); + expect(entry).toContain( + "clears this session's active workflow pointer", + ); + expect(entry).toContain('run a decision-readiness gate'); + expect(entry).toContain( + 'Do not ask ceremonial questions or manufacture alternatives when the request is already clear and sound.', + ); + expect(entry).toContain( + 'Classify unresolved decisions as blocking, recommendable, or defaultable.', + ); + expect(entry).toContain('2–3 bounded options'); + expect(entry).toContain( + 'stop before requirements finalization, explain the missing decision, ask focused questions, and wait for the user answer.', + ); + expect(entry).toContain( + 'never turn an unverified assumption into confirmed scope or confirmed coverage.', + ); + expect(entry).toContain( + 'workflow requirements draft --file ', + ); + expect(entry).toContain( + 'another session can resume the exact clarification state', + ); + expect(entry).toContain( + 'summarize the resolved requirements and any remaining defaults', + ); + expect(entry).toContain('clarity does not waive risk'); + } + if (mode === 'manba') { + expect(entry).toContain( + 'establish the expected behavior from reproducible evidence', + ); + expect(entry).toContain( + 'ask one focused question and wait instead of inventing product behavior', + ); + expect(entry).toContain('explicit but unsound fix instruction'); } if (mode === 'manps') { expect(entry).toContain( @@ -214,6 +339,27 @@ describe('V3 adapter bootstrap integration', () => { expect(entry).toContain( 'Only an explicit governed handoff mutation requires', ); + expect(entry).toContain( + 'assess both clarity and soundness using the project facts', + ); + expect(entry).toContain( + 'classify the rest as blocking, recommendable, or defaultable', + ); + expect(entry).toContain('A supplied implementation direction'); + expect(entry).toContain( + 'recommend `/man`, explain the trigger, and wait for the operator to choose', + ); + } + if (mode === 'manteam') { + expect(entry).toContain( + 'same decision-readiness gate as `man` before finalizing requirements', + ); + expect(entry).toContain( + 'ownership conflict, or hard-risk direction remains', + ); + expect(entry).toContain( + 'do not leave ownership questions or partial answers only in chat history', + ); } if (mode === 'man' || mode === 'manba' || mode === 'manteam') { expect(entry).toContain(`mancode workflow create ${mode}`); @@ -244,14 +390,30 @@ describe('V3 adapter bootstrap integration', () => { ); it('preserves user instructions outside the V3 managed block', async () => { - await init(root, { v3: true }); await writeFile(path.join(root, 'AGENTS.md'), '# User instructions\n'); - expect(await install(root, 'codex')).toBe(0); - expect(await install(root, 'codex')).toBe(0); + expect(await init(root, { v3: true, platform: 'codex' })).toBe(0); + expect(await init(root, { v3: true, platform: 'codex' })).toBe(1); const agents = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); expect(agents).toContain('# User instructions'); - expect(agents.match(/mancode:v3:codex:start/g)).toHaveLength(1); + expect(agents.match(/mancode:continuity:codex:start/g)).toHaveLength(1); + }); + + it('keeps the Claude bootstrap always loaded without replacing user memory', async () => { + const claudeMemory = path.join(root, 'CLAUDE.md'); + await writeFile(claudeMemory, '# User project memory\n\nKeep this rule.\n'); + + await installV3Adapter(root, 'claude-code'); + await installV3Adapter(root, 'claude-code'); + const installed = await readFile(claudeMemory, 'utf8'); + expect(installed).toContain('# User project memory'); + expect(installed).toContain('Keep this rule.'); + expect(installed.match(/mancode:continuity:claude:start/g)).toHaveLength(1); + + await removeV3Adapter(root, 'claude-code'); + const removed = await readFile(claudeMemory, 'utf8'); + expect(removed).toContain('# User project memory'); + expect(removed).not.toContain('mancode:continuity:claude'); }); it('stages the bootstrap and every original mode entry without changing live files', async () => { @@ -274,7 +436,10 @@ describe('V3 adapter bootstrap integration', () => { readFile(path.join(root, '.cursor', 'commands', 'man.md'), 'utf8'), ).rejects.toThrow(); await expect( - readFile(path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), 'utf8'), + readFile( + path.join(root, '.cursor', 'rules', 'mancode-continuity.mdc'), + 'utf8', + ), ).rejects.toThrow(); }); @@ -301,6 +466,31 @@ describe('V3 adapter bootstrap integration', () => { expect(agentsAfterCodex).toContain('Codex or ZCode'); }); + it('renders the complete plan and local reframe command contracts', async () => { + await init(root, { v3: true, platform: 'codex' }); + const man = await readFile(v3ModeEntryPath(root, 'codex', 'man'), 'utf8'); + + expect(man).toContain( + 'revise --expected-revision --file --session --client ', + ); + expect(man).toContain( + 'confirm --expected-revision --plan-decision --session --client ', + ); + expect(man).toContain( + 'workflow reframe --expected-revision --checkpoint-id ', + ); + expect(man).toContain('stops at Step 2 with draft requirements'); + expect(man).toContain( + 'Do not substitute plan revise, scope-change, or workflow update for reframe.', + ); + expect(man).toContain( + 'workflow archive show --json', + ); + expect(man).toContain( + 'workflow checkpoint show --json', + ); + }); + it('refuses to overwrite a user-authored original mode entry', async () => { await init(root, { v3: true }); const modePath = v3ModeEntryPath(root, 'codex', 'man'); @@ -498,7 +688,7 @@ describe('V3 adapter bootstrap integration', () => { '.mancode', 'staging', 'adapters', - 'v3', + 'continuity', 'codex', 'AGENTS.md', ), diff --git a/tests/v3-adapter-read-retry-contracts.test.ts b/tests/v3-adapter-read-retry-contracts.test.ts index 6b2e973..352ea41 100644 --- a/tests/v3-adapter-read-retry-contracts.test.ts +++ b/tests/v3-adapter-read-retry-contracts.test.ts @@ -63,9 +63,14 @@ describe('V3 adapter status reads', () => { it('keeps a persistent adapter read failure closed', async () => { reads.failuresRemaining = 4; - await expect(inspectV3Adapter(root, 'claude-code')).rejects.toMatchObject({ - code: 'EPERM', + await expect(inspectV3Adapter(root, 'claude-code')).resolves.toMatchObject({ + installed: true, + ready: false, + status: 'unreadable', + targets: expect.arrayContaining([ + expect.objectContaining({ status: 'unreadable', actualDigest: null }), + ]), }); - expect(reads.calls).toBe(4); + expect(reads.calls).toBe(V3_MODE_NAMES.length + 4); }); }); diff --git a/tests/v3-command-contracts.test.ts b/tests/v3-command-contracts.test.ts index f02d093..2204a14 100644 --- a/tests/v3-command-contracts.test.ts +++ b/tests/v3-command-contracts.test.ts @@ -8,6 +8,7 @@ import { contextReconcileTaskHead, contextResume, contextSessionNew, + contextSessionShow, contextSessionSpike, contextShow, contextWorktreeRegister, @@ -92,6 +93,44 @@ describe('V3 CLI command contracts', () => { client: 'fixture', status: 'active', }); + + expect( + await contextSessionShow(root, { + session: payload.session.sessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + session: { + sessionId: payload.session.sessionId, + client: 'fixture', + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + }, + }); + + expect( + await contextSessionShow(root, { + session: payload.session.sessionId, + client: 'other-client', + json: true, + }), + ).toBe(3); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_SESSION_NOT_FOUND' }, + }); + + expect( + await contextSessionShow(root, { + session: 'not-a-session-id', + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_SESSION_INVALID' }, + }); } finally { errors.mockRestore(); logs.mockRestore(); @@ -110,6 +149,7 @@ describe('V3 CLI command contracts', () => { expect( await contextSessionSpike(root, { platform: 'codex', + sessionMode: 'host', hostSessionSource: 'api', subagentInheritance: 'proven', hostVersion: 'fixture-host-1.0.0', @@ -139,6 +179,7 @@ describe('V3 CLI command contracts', () => { expect( await contextSessionSpike(root, { platform: 'codex', + sessionMode: 'host', hostSessionSource: 'api', commandPropagation: 'proven', subagentInheritance: 'proven', @@ -181,6 +222,102 @@ describe('V3 CLI command contracts', () => { } }); + it('records real explicit-session isolation without granting host trust', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'codex', json: true }); + const first = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + }; + await contextSessionNew(root, { client: 'codex', json: true }); + const second = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + }; + vi.stubEnv('MANCODE_SPIKE_SESSION_ID', first.session.sessionId); + vi.stubEnv('MANCODE_SPIKE_SECOND_SESSION_ID', second.session.sessionId); + + expect( + await contextSessionSpike(root, { + platform: 'codex', + sessionMode: 'explicit', + hostSessionSource: 'none', + commandPropagation: 'proven', + subagentInheritance: 'not_applicable', + subagentInheritanceReason: 'This host exposes no child-agent API.', + hostVersion: 'fixture-host-1.0.0', + releaseCandidate: '5c40d6b', + json: true, + }), + ).toBe(0); + const evidence = String(logs.mock.calls.at(-1)?.[0]); + expect(evidence).toContain('explicit_session_verified'); + expect(evidence).toContain('explicit_required'); + expect(evidence).not.toContain(first.session.sessionId); + expect(evidence).not.toContain(second.session.sessionId); + } finally { + vi.unstubAllEnvs(); + errors.mockRestore(); + logs.mockRestore(); + } + }); + + it('rejects fabricated or wrong-client explicit session evidence', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + vi.stubEnv('MANCODE_SPIKE_SESSION_ID', id(40)); + vi.stubEnv('MANCODE_SPIKE_SECOND_SESSION_ID', id(41)); + expect( + await contextSessionSpike(root, { + platform: 'codex', + sessionMode: 'explicit', + hostSessionSource: 'none', + commandPropagation: 'proven', + subagentInheritance: 'proven', + hostVersion: 'fixture-host-1.0.0', + releaseCandidate: '5c40d6b', + json: true, + }), + ).toBe(3); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'MANCODE_PLATFORM_SPIKE_SESSION_NOT_FOUND', + ); + + await contextSessionNew(root, { client: 'cursor', json: true }); + const first = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + }; + await contextSessionNew(root, { client: 'cursor', json: true }); + const second = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + }; + vi.stubEnv('MANCODE_SPIKE_SESSION_ID', first.session.sessionId); + vi.stubEnv('MANCODE_SPIKE_SECOND_SESSION_ID', second.session.sessionId); + expect( + await contextSessionSpike(root, { + platform: 'codex', + sessionMode: 'explicit', + hostSessionSource: 'none', + commandPropagation: 'proven', + subagentInheritance: 'proven', + hostVersion: 'fixture-host-1.0.0', + releaseCandidate: '5c40d6b', + json: true, + }), + ).toBe(3); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'MANCODE_PLATFORM_SPIKE_SESSION_CLIENT_MISMATCH', + ); + } finally { + vi.unstubAllEnvs(); + errors.mockRestore(); + logs.mockRestore(); + } + }); + it('keeps checkout runtime registration explicit and idempotent', async () => { const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -349,6 +486,8 @@ describe('V3 CLI command contracts', () => { expect(await operationShow(root, operationId, { json: true })).toBe(0); expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ journal: { operationId, state: 'committed' }, + recoveryAction: 'none', + recoveryReason: 'terminal', payloadBound: true, }); expect( @@ -403,6 +542,20 @@ describe('V3 CLI command contracts', () => { json: true, }), ).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + session: { + activeTaskRef: createPayload.taskRef, + activeMode: 'man', + lastSeenRevision: 1, + }, + pack: { + session: { + activeTaskRef: createPayload.taskRef, + activeMode: 'man', + }, + snapshot: { taskRevision: 1 }, + }, + }); expect( await contextShow(root, { task, @@ -712,6 +865,275 @@ describe('V3 CLI command contracts', () => { } }); + it('persists incomplete clarification and resumes it from another session', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'fixture', json: true }); + const firstSessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + await workflow( + root, + 'create', + ['man', 'Choose the durable notification owner.'], + { + session: firstSessionId, + client: 'fixture', + json: true, + }, + ); + const created = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + taskRef: TaskRef; + }; + const task = `${created.taskRef.namespace}:${created.taskRef.taskId}`; + const draftPath = path.join(root, 'requirements-draft.json'); + await writeFile( + draftPath, + `${JSON.stringify( + { + version: 1, + goal: 'Deliver durable notifications.', + confirmedScope: [], + excludedScope: [], + technicalDecisions: [], + defaults: [], + blockingUnknowns: [ + 'Choose whether the database or queue owns delivery state.', + ], + coverage: [], + acceptanceCriteria: [], + }, + null, + 2, + )}\n`, + ); + + expect( + await workflow(root, 'requirements', [task, 'draft'], { + session: firstSessionId, + client: 'fixture', + expectedRevision: '1', + file: draftPath, + json: true, + }), + ).toBe(0); + const saved = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + metadata: { + revision: number; + governance: { requirementsStatus: string }; + }; + requirements: RequirementsLedgerV1; + operation: { type: string; state: string }; + }; + expect(saved).toMatchObject({ + metadata: { + revision: 2, + governance: { requirementsStatus: 'needs_clarification' }, + }, + requirements: { + status: 'draft', + blockingUnknowns: [ + { + statement: + 'Choose whether the database or queue owns delivery state.', + status: 'open', + }, + ], + }, + operation: { type: 'requirements_draft', state: 'committed' }, + }); + + await contextSessionNew(root, { client: 'fixture', json: true }); + const secondSessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + expect(secondSessionId).not.toBe(firstSessionId); + expect( + await contextResume(root, task, { + session: secondSessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + expect( + await contextShow(root, { + task, + session: secondSessionId, + client: 'fixture', + purpose: 'plan', + level: 'task', + json: true, + }), + ).toBe(0); + const resumed = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + pack: { + governance: { + requirements: RequirementsLedgerV1; + }; + }; + }; + expect(resumed.pack.governance.requirements).toMatchObject({ + status: 'draft', + blockingUnknowns: [ + { + statement: + 'Choose whether the database or queue owns delivery state.', + status: 'open', + }, + ], + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('requires reframe concurrency inputs and returns the committed JSON contract', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'fixture', json: true }); + const sessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + await workflow(root, 'create', ['man', 'Reframe through the CLI.'], { + session: sessionId, + client: 'fixture', + json: true, + }); + const created = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + taskRef: TaskRef; + }; + const task = `${created.taskRef.namespace}:${created.taskRef.taskId}`; + const checkpointId = id(60); + + expect( + await workflow(root, 'reframe', [task], { + session: sessionId, + client: 'fixture', + checkpointId, + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_EXPECTED_REVISION_REQUIRED' }, + }); + + expect( + await workflow(root, 'reframe', [task], { + session: sessionId, + client: 'fixture', + expectedRevision: '1', + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_REFRAME_CHECKPOINT_REQUIRED' }, + }); + + const snapshot = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + const requirementsPath = path.join(root, 'reframe-requirements.json'); + await writeFile( + requirementsPath, + `${JSON.stringify(finalizedRequirements(snapshot.requirements), null, 2)}\n`, + ); + expect( + await workflow(root, 'requirements', [task, 'finalize'], { + session: sessionId, + client: 'fixture', + expectedRevision: '1', + file: requirementsPath, + json: true, + }), + ).toBe(0); + + const reframeCode = await workflow(root, 'reframe', [task], { + session: sessionId, + client: 'fixture', + expectedRevision: '2', + checkpointId, + summary: 'New evidence changes the requirements.', + nextAction: 'Clarify the replacement requirements.', + json: true, + }); + const reframeOutput = String(logs.mock.calls.at(-1)?.[0]); + expect(reframeCode, reframeOutput).toBe(0); + const result = JSON.parse(reframeOutput) as { + schemaVersion: number; + metadata: { revision: number; currentStep: number }; + requirements: { revision: number; status: string }; + checkpoint: { checkpointId: string; kind: string }; + archive: { archiveId: string; sourceTaskRevision: number }; + operation: { operationId: string; type: string; state: string }; + }; + expect(result).toMatchObject({ + schemaVersion: 1, + metadata: { revision: 4, currentStep: 2 }, + requirements: { revision: 3, status: 'draft' }, + checkpoint: { checkpointId, kind: 'requirements_reframed' }, + archive: { sourceTaskRevision: 2 }, + operation: { type: 'reframe', state: 'committed' }, + }); + expect(result.archive.archiveId).toBe(result.operation.operationId); + expect( + await workflow( + root, + 'archive', + [task, 'show', result.archive.archiveId], + { json: true }, + ), + ).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + taskRef: created.taskRef, + archive: { + archiveId: result.archive.archiveId, + sourceTaskRevision: 2, + sourceRequirementsRevision: 2, + }, + requirements: { status: 'confirmed', revision: 2 }, + plan: null, + }); + expect( + await workflow(root, 'checkpoint', [task, 'show', checkpointId], { + json: true, + }), + ).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + taskRef: created.taskRef, + checkpoint: { + checkpointId, + kind: 'requirements_reframed', + taskRevision: 4, + }, + }); + await expect( + new V3ContextStore(root).readTaskSnapshot(created.taskRef), + ).resolves.toMatchObject({ + metadata: { revision: 4, currentStep: 2 }, + requirements: { revision: 3, status: 'draft' }, + }); + await expect(readSession(root, sessionId)).resolves.toMatchObject({ + activeTaskRef: created.taskRef, + activeMode: 'man', + lastSeenRevision: 4, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + it('accepts the documented semantic requirements format and allocates V3 identities', async () => { const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tests/v3-init-command.test.ts b/tests/v3-init-command.test.ts index 4306e43..611348d 100644 --- a/tests/v3-init-command.test.ts +++ b/tests/v3-init-command.test.ts @@ -50,8 +50,14 @@ describe('journaled V3 init command', () => { JSON.parse( await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), ), - ).activationState, - ).toBe('v3_active'); + ), + ).toMatchObject({ + manifestVersion: 2, + activationState: 'v3_active', + workflowPolicyDefaults: { planning: 2 }, + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + }); await expect( readFile(path.join(root, '.agents', 'skills', 'man', 'SKILL.md'), 'utf8'), ).resolves.toContain('mancode workflow create man'); @@ -78,7 +84,7 @@ describe('journaled V3 init command', () => { ).resolves.toContain('mancode workflow create man'); }); - it('repairs the selected original entry when ordinary init is repeated', async () => { + it('does not repair a missing registered adapter through repeated init', async () => { expect( await init(root, { fromCli: true, empty: true, platform: 'codex' }), ).toBe(EXIT_OK); @@ -88,16 +94,34 @@ describe('journaled V3 init command', () => { }); expect(await init(root, { fromCli: true, platform: 'codex' })).toBe( - EXIT_ALREADY_INITIALIZED, + EXIT_INIT_FAILED, ); await expect( readFile(path.join(root, '.agents', 'skills', 'man', 'SKILL.md'), 'utf8'), - ).resolves.toContain('# mancode mode: man'); + ).rejects.toThrow(); expect(await init(root, { fromCli: true, interactive: false })).toBe( EXIT_ALREADY_INITIALIZED, ); }); + it('does not register a new platform through repeated init', async () => { + expect(await init(root, { v3: true })).toBe(EXIT_OK); + + expect(await init(root, { v3: true, platform: 'codex' })).toBe( + EXIT_INIT_FAILED, + ); + await expect( + readFile(path.join(root, 'AGENTS.md'), 'utf8'), + ).rejects.toThrow(); + expect( + parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ).managedAdapters, + ).toEqual({}); + }); + it('never creates legacy authority inside an active V3 project', async () => { expect(await init(root, { v3: true, platform: 'codex' })).toBe(EXIT_OK); const schemaBefore = await readFile( diff --git a/tests/v3-reframe-contracts.test.ts b/tests/v3-reframe-contracts.test.ts new file mode 100644 index 0000000..6cce937 --- /dev/null +++ b/tests/v3-reframe-contracts.test.ts @@ -0,0 +1,1201 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { reviseV3Plan } from '../src/context/plan-revision.js'; +import { reframeV3Workflow } from '../src/context/reframe.js'; +import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { + REQUIREMENT_DIMENSIONS, + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { startV3SoloHandoff } from '../src/context/solo-handoff.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from '../src/context/workflow-metadata.js'; +import { updateV3Workflow } from '../src/context/workflow-update.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readHandoff } from '../src/runtime/handoff-store.js'; +import { createOperationLockPauseForTesting } from '../src/runtime/operation-crash-injection.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession, readSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { readTaskCheckpointAtRoot } from '../src/runtime/task-operation.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; +import { createV3HandoffDraft } from '../src/team/handoff-operation.js'; +import { parseProjectConfig } from '../src/team/policy.js'; +import { confirmManteamPlan } from './helpers/manteam-plan.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-21T12:00:00.000Z'); + +describe('V3 local workflow reframe', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-reframe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('archives the confirmed contract, releases claims, and returns a shared workflow to clarification', async () => { + const actors = await bootstrap(root, { git: true, joined: true }); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Reframe a shared workflow without losing its confirmed evidence.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['governance'] }, + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const confirmed = await confirmManteamPlan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const acquired = await acquireV3Claim({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: confirmed.taskRevision, + scope: { + paths: ['src/context/**'], + modules: ['governance'], + apis: [], + schemas: [], + }, + claimId: id(12), + operationId: id(13), + now: NOW, + }); + const store = new V3ContextStore(root); + const before = await store.readTaskSnapshot(created.taskRef); + const taskRoot = taskRootPath(root, created.taskRef); + const [requirementsBytes, planBytes] = await Promise.all([ + readFile(path.join(taskRoot, 'requirements.json'), 'utf8'), + readFile(path.join(taskRoot, 'plan.md'), 'utf8'), + ]); + const operationId = id(14); + const checkpointId = id(15); + + const reframed = await reframeV3Workflow({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: before.metadata.revision, + checkpointId, + summary: 'New evidence changes the requirements semantic boundary.', + nextAction: 'Clarify and confirm the replacement requirements.', + operationId, + now: NOW, + }); + + expect(reframed).toMatchObject({ + metadata: { + status: 'in_progress', + currentStep: 2, + transitionState: 'stable', + blockingReason: null, + governance: { + requirementsStatus: 'needs_clarification', + planDecision: null, + reviewStatus: 'stale', + verificationStatus: 'stale', + }, + soloExecution: null, + latestCheckpointRef: { artifactId: checkpointId }, + lastOperationId: operationId, + }, + requirements: { + revision: before.requirements.revision + 1, + status: 'draft', + lastOperationId: operationId, + }, + review: { + revision: before.review.revision + 1, + status: 'stale', + lastOperationId: operationId, + }, + verification: { + revision: before.verification.revision + 1, + status: 'stale', + lastOperationId: operationId, + }, + checkpoint: { + checkpointId, + operationId, + kind: 'requirements_reframed', + summary: 'New evidence changes the requirements semantic boundary.', + nextAction: 'Clarify and confirm the replacement requirements.', + }, + archive: { + archiveId: operationId, + taskRef: created.taskRef, + sourceTaskRevision: before.metadata.revision, + sourceRequirementsRevision: before.requirements.revision, + sourceRequirementsDigest: before.requirements.contentDigest, + sourcePlanVersion: before.metadata.governance.planVersion, + sourcePlanDigest: before.plan?.digest, + createdAt: NOW.toISOString(), + }, + releasedClaims: [ + { + claimId: acquired.claim.claimId, + state: 'released', + revision: acquired.claim.revision + 1, + lastOperationId: operationId, + }, + ], + operation: { type: 'reframe', state: 'committed' }, + }); + + const archiveRoot = path.join(taskRoot, 'archives', operationId); + const [archive, archivedRequirements, archivedPlan] = await Promise.all([ + readFile(path.join(archiveRoot, 'archive.json'), 'utf8'), + readFile(path.join(archiveRoot, 'requirements.json'), 'utf8'), + readFile(path.join(archiveRoot, 'plan.md'), 'utf8'), + ]); + expect(JSON.parse(archive)).toMatchObject({ + ...reframed.archive, + schemaVersion: 1, + operationId, + requirementsFileDigest: expect.stringMatching(/^sha256:/), + planFileDigest: expect.stringMatching(/^sha256:/), + archiveDigest: expect.stringMatching(/^sha256:/), + }); + expect(archivedRequirements).toBe(requirementsBytes); + expect(archivedPlan).toBe(planBytes); + + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.metadata).toEqual(reframed.metadata); + expect(persisted.requirements).toEqual(reframed.requirements); + expect(persisted.review).toEqual(reframed.review); + expect(persisted.verification).toEqual(reframed.verification); + expect(persisted.latestCheckpoint).toEqual(reframed.checkpoint); + expect(persisted.aggregate).toEqual(reframed.aggregate); + expect(persisted.aggregateError).toBeNull(); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + const coordination = await store.readCoordinationSnapshot( + created.taskRef, + home, + ); + expect(coordination.claims).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + claimId: acquired.claim.claimId, + state: 'released', + lastOperationId: operationId, + }), + ]), + ); + expect(await readTaskHeadFence(home, created.taskRef)).toMatchObject({ + taskRevision: reframed.metadata.revision, + lastOperationId: operationId, + }); + await expect( + readOperationJournal(home, operationId), + ).resolves.toMatchObject({ + type: 'reframe', + state: 'committed', + entityLocks: expect.arrayContaining([ + `task:shared:${created.taskRef.taskId}`, + `archive:${operationId}`, + `checkpoint:${checkpointId}`, + `claim:${acquired.claim.claimId}`, + `task_head:${created.taskRef.taskId}`, + ]), + }); + await expect(readSession(root, actors.sessionId)).resolves.toMatchObject({ + activeTaskRef: created.taskRef, + activeMode: 'manteam', + lastSeenRevision: reframed.metadata.revision, + }); + }); + + it('rejects active child, open handoff, and active solo before writing authority', async () => { + const childRoot = await caseRoot(root, 'active-child'); + const childActors = await bootstrap(childRoot); + const parent = await createV3Workflow({ + projectRoot: childRoot, + task: 'Keep the parent stable while its diagnostic child is active.', + workflowMode: 'man', + sessionId: childActors.sessionId, + client: 'vitest', + taskId: id(20), + operationId: id(21), + now: NOW, + }); + const parentAtVerification = parseWorkflowMetadata({ + ...parent.metadata, + revision: 2, + currentStep: 6, + updatedAt: NOW.toISOString(), + }); + await writeMetadata(childRoot, parent.taskRef, parentAtVerification); + await createV3Workflow({ + projectRoot: childRoot, + task: 'Diagnose the parent verification failure.', + workflowMode: 'manba', + sessionId: childActors.sessionId, + client: 'vitest', + parentTaskRef: parent.taskRef, + taskId: id(22), + operationId: id(23), + now: NOW, + }); + await expectRejectedWithoutAuthorityChange({ + projectRoot: childRoot, + taskRef: parent.taskRef, + sessionId: childActors.sessionId, + expectedTaskRevision: parentAtVerification.revision, + checkpointId: id(24), + operationId: id(25), + error: 'MANCODE_REFRAME_ACTIVE_CHILD', + }); + + const handoffRoot = await caseRoot(root, 'open-handoff'); + const handoffActors = await bootstrap(handoffRoot, { + git: true, + joined: true, + receiver: true, + }); + const shared = await createV3Workflow({ + projectRoot: handoffRoot, + task: 'Keep a draft handoff intact when reframe is rejected.', + workflowMode: 'manteam', + sessionId: handoffActors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + participantActorIds: [handoffActors.receiverActorId as Ulid], + implementationScope: { include: ['src/**'], modules: ['handoff'] }, + taskId: id(30), + operationId: id(31), + now: NOW, + }); + const sharedPlan = await confirmManteamPlan({ + projectRoot: handoffRoot, + taskRef: shared.taskRef, + sessionId: handoffActors.sessionId, + requirements: shared.requirements, + now: NOW, + }); + await createV3HandoffDraft({ + projectRoot: handoffRoot, + taskRef: shared.taskRef, + sessionId: handoffActors.sessionId, + expectedTaskRevision: sharedPlan.taskRevision, + toActorId: handoffActors.receiverActorId as Ulid, + handoffId: id(32), + checkpointId: id(33), + checkpointOperationId: id(34), + operationId: id(35), + now: NOW, + }); + const sharedBefore = await new V3ContextStore(handoffRoot).readTaskSnapshot( + shared.taskRef, + ); + await expectRejectedWithoutAuthorityChange({ + projectRoot: handoffRoot, + taskRef: shared.taskRef, + sessionId: handoffActors.sessionId, + expectedTaskRevision: sharedBefore.metadata.revision, + checkpointId: id(36), + operationId: id(37), + error: 'MANCODE_REFRAME_OPEN_HANDOFF', + }); + + const soloRoot = await caseRoot(root, 'active-solo'); + const soloActors = await bootstrap(soloRoot); + const solo = await createV3Workflow({ + projectRoot: soloRoot, + task: 'Keep the active solo assignment intact.', + workflowMode: 'man', + sessionId: soloActors.sessionId, + client: 'vitest', + taskId: id(40), + operationId: id(41), + now: NOW, + }); + const soloRequirements = await finalizeV3Requirements({ + projectRoot: soloRoot, + taskRef: solo.taskRef, + sessionId: soloActors.sessionId, + expectedTaskRevision: solo.metadata.revision, + requirements: confirmedRequirements(solo.requirements), + operationId: id(42), + now: NOW, + }); + const soloPlan = await reviseV3Plan({ + projectRoot: soloRoot, + taskRef: solo.taskRef, + sessionId: soloActors.sessionId, + expectedTaskRevision: soloRequirements.metadata.revision, + plan: '# Plan\n\n1. Implement the confirmed local change.\n', + operationId: id(43), + now: NOW, + }); + const assignment = await startV3SoloHandoff({ + projectRoot: soloRoot, + taskRef: solo.taskRef, + sessionId: soloActors.sessionId, + expectedTaskRevision: soloPlan.metadata.revision, + operationId: id(44), + now: NOW, + }); + await expectRejectedWithoutAuthorityChange({ + projectRoot: soloRoot, + taskRef: solo.taskRef, + sessionId: soloActors.sessionId, + expectedTaskRevision: assignment.metadata.revision, + checkpointId: id(45), + operationId: id(46), + error: 'MANCODE_REFRAME_ACTIVE_SOLO', + }); + }); + + it('serializes reframe and child creation in both canonical parent-lock orders', async () => { + await assertChildReframeRace( + await caseRoot(root, 'concurrent-child-reframe-wins'), + 'reframe', + ); + await assertChildReframeRace( + await caseRoot(root, 'concurrent-child-create-wins'), + 'child', + ); + }); + + it('serializes reframe across both handoff checkpoint and draft lock phases', async () => { + await assertHandoffReframeRace( + await caseRoot(root, 'concurrent-handoff-reframe-wins'), + 'reframe', + ); + await assertHandoffReframeRace( + await caseRoot(root, 'concurrent-handoff-checkpoint-wins'), + 'handoff_checkpoint', + ); + await assertHandoffReframeRace( + await caseRoot(root, 'concurrent-handoff-draft-wins'), + 'handoff_draft', + ); + }); + + it('serializes reframe and solo handoff start in both canonical task-lock orders', async () => { + await assertSoloReframeRace( + await caseRoot(root, 'concurrent-solo-reframe-wins'), + 'reframe', + ); + await assertSoloReframeRace( + await caseRoot(root, 'concurrent-solo-start-wins'), + 'solo', + ); + }); + + it('rejects terminal, repair-pending, manba, and git-ref workflows without a reframe journal', async () => { + const terminalRoot = await caseRoot(root, 'terminal'); + const terminalActors = await bootstrap(terminalRoot); + const terminal = await createV3Workflow({ + projectRoot: terminalRoot, + task: 'Do not reopen an abandoned workflow through reframe.', + workflowMode: 'man', + sessionId: terminalActors.sessionId, + client: 'vitest', + taskId: id(50), + operationId: id(51), + now: NOW, + }); + const abandoned = await updateV3Workflow({ + projectRoot: terminalRoot, + taskRef: terminal.taskRef, + sessionId: terminalActors.sessionId, + expectedTaskRevision: terminal.metadata.revision, + status: 'abandoned', + operationId: id(52), + now: NOW, + }); + await expectRejectedWithoutAuthorityChange({ + projectRoot: terminalRoot, + taskRef: terminal.taskRef, + sessionId: terminalActors.sessionId, + expectedTaskRevision: abandoned.metadata.revision, + checkpointId: id(53), + operationId: id(54), + error: /MANCODE_(?:REFRAME|WORKFLOW)_/, + }); + + const repairRoot = await caseRoot(root, 'pending-repair'); + const repairActors = await bootstrap(repairRoot); + const repair = await createV3Workflow({ + projectRoot: repairRoot, + task: 'Do not reframe a workflow that requires operation repair.', + workflowMode: 'man', + sessionId: repairActors.sessionId, + client: 'vitest', + taskId: id(60), + operationId: id(61), + now: NOW, + }); + const pendingRepair = parseWorkflowMetadata({ + ...repair.metadata, + revision: repair.metadata.revision + 1, + transitionState: 'pending_repair', + lastOperationId: id(62), + updatedAt: NOW.toISOString(), + }); + await writeMetadata(repairRoot, repair.taskRef, pendingRepair); + await expectRejectedWithoutAuthorityChange({ + projectRoot: repairRoot, + taskRef: repair.taskRef, + sessionId: repairActors.sessionId, + expectedTaskRevision: pendingRepair.revision, + checkpointId: id(63), + operationId: id(64), + error: /MANCODE_(?:REFRAME|TASK|OPERATION)_/, + }); + + const manbaRoot = await caseRoot(root, 'manba'); + const manbaActors = await bootstrap(manbaRoot); + const manba = await createV3Workflow({ + projectRoot: manbaRoot, + task: 'Do not apply requirements reframe to a diagnostic workflow.', + workflowMode: 'manba', + sessionId: manbaActors.sessionId, + client: 'vitest', + taskId: id(70), + operationId: id(71), + now: NOW, + }); + await expectRejectedWithoutAuthorityChange({ + projectRoot: manbaRoot, + taskRef: manba.taskRef, + sessionId: manbaActors.sessionId, + expectedTaskRevision: manba.metadata.revision, + checkpointId: id(72), + operationId: id(73), + error: /MANCODE_REFRAME_/, + }); + + const gitRefRoot = await caseRoot(root, 'git-ref'); + const gitRefActors = await bootstrap(gitRefRoot); + const gitRef = await createV3Workflow({ + projectRoot: gitRefRoot, + task: 'Reject reframe whenever project transport is git-ref.', + workflowMode: 'man', + sessionId: gitRefActors.sessionId, + client: 'vitest', + taskId: id(80), + operationId: id(81), + now: NOW, + }); + await forceGitRefTransport(gitRefRoot); + await expectRejectedWithoutAuthorityChange({ + projectRoot: gitRefRoot, + taskRef: gitRef.taskRef, + sessionId: gitRefActors.sessionId, + expectedTaskRevision: gitRef.metadata.revision, + checkpointId: id(82), + operationId: id(83), + error: 'MANCODE_REFRAME_GIT_REF_UNSUPPORTED', + }); + }); + + it('rejects a workflow that is already clarifying requirements without writing authority', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Keep an existing clarification draft unchanged.', + workflowMode: 'man', + sessionId: actors.sessionId, + client: 'vitest', + taskId: id(80), + operationId: id(81), + now: NOW, + }); + + await expectRejectedWithoutAuthorityChange({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: created.metadata.revision, + checkpointId: id(82), + operationId: id(83), + error: 'MANCODE_REFRAME_REQUIREMENTS_NOT_CONFIRMED', + }); + }); +}); + +async function assertChildReframeRace( + projectRoot: string, + winner: 'reframe' | 'child', +): Promise { + const actors = await bootstrap(projectRoot); + const parent = await createV3Workflow({ + projectRoot, + task: 'Race requirements reframe against a diagnostic child.', + workflowMode: 'man', + sessionId: actors.sessionId, + client: 'vitest', + taskId: id(200), + operationId: id(201), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot, + taskRef: parent.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: parent.metadata.revision, + requirements: confirmedRequirements(parent.requirements), + operationId: id(202), + now: NOW, + }); + const planned = await reviseV3Plan({ + projectRoot, + taskRef: parent.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Plan\n\n1. Implement and verify the confirmed change.\n', + planDecision: 'governed_execution', + operationId: id(203), + now: NOW, + }); + await writeMetadata( + projectRoot, + parent.taskRef, + parseWorkflowMetadata({ ...planned.metadata, currentStep: 6 }), + ); + + const childTaskRef: TaskRef = { namespace: 'local', taskId: id(204) }; + const childOperationId = id(205); + const reframeOperationId = id(206); + const reframeCheckpointId = id(207); + const parentBefore = await authorityDigest(projectRoot, parent.taskRef); + const reframe = () => + reframeV3Workflow({ + projectRoot, + taskRef: parent.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: planned.metadata.revision, + checkpointId: reframeCheckpointId, + operationId: reframeOperationId, + now: NOW, + }); + const createChild = () => + createV3Workflow({ + projectRoot, + task: 'Diagnose the verification failure before parent reframe.', + workflowMode: 'manba', + sessionId: actors.sessionId, + client: 'vitest', + parentTaskRef: parent.taskRef, + taskId: childTaskRef.taskId, + operationId: childOperationId, + now: NOW, + }); + const store = new V3ContextStore(projectRoot); + const home = await taskHomeStore(projectRoot, parent.taskRef); + + if (winner === 'reframe') { + const result = await runPausedCanonicalLockRace( + reframeOperationId, + reframe, + createChild, + ); + const persisted = await store.readTaskSnapshot(parent.taskRef); + expect(persisted.metadata).toEqual(result.metadata); + expect(persisted.requirements).toEqual(result.requirements); + expect(persisted.aggregateError).toBeNull(); + await expect( + store.listActiveChildTaskRefs(parent.taskRef), + ).resolves.toEqual([]); + await expectTaskMissing(projectRoot, childTaskRef); + await expect( + readOperationJournal(home, childOperationId), + ).resolves.toBeNull(); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'reframe' }); + return; + } + + const result = await runPausedCanonicalLockRace( + childOperationId, + createChild, + reframe, + ); + expect(await authorityDigest(projectRoot, parent.taskRef)).toBe(parentBefore); + const child = await store.readTaskSnapshot(childTaskRef); + expect(child.metadata).toEqual(result.metadata); + expect(child.aggregateError).toBeNull(); + await expect(store.listActiveChildTaskRefs(parent.taskRef)).resolves.toEqual([ + childTaskRef, + ]); + await expect( + readOperationJournal(home, childOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'workflow_create' }); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toBeNull(); + await expectNoReframeArtifacts( + projectRoot, + parent.taskRef, + reframeOperationId, + reframeCheckpointId, + ); +} + +async function assertHandoffReframeRace( + projectRoot: string, + winner: 'reframe' | 'handoff_checkpoint' | 'handoff_draft', +): Promise { + const actors = await bootstrap(projectRoot, { + git: true, + joined: true, + receiver: true, + }); + const created = await createV3Workflow({ + projectRoot, + task: 'Race a named handoff against requirements reframe.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + participantActorIds: [actors.receiverActorId as Ulid], + implementationScope: { include: ['src/**'], modules: ['handoff'] }, + taskId: id(300), + operationId: id(301), + now: NOW, + }); + const planned = await confirmManteamPlan({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const handoffId = id(302); + const handoffCheckpointId = id(303); + const handoffCheckpointOperationId = id(304); + const handoffOperationId = id(305); + const reframeOperationId = id(306); + const reframeCheckpointId = id(307); + const createHandoff = () => + createV3HandoffDraft({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: planned.taskRevision, + toActorId: actors.receiverActorId as Ulid, + handoffId, + checkpointId: handoffCheckpointId, + checkpointOperationId: handoffCheckpointOperationId, + operationId: handoffOperationId, + now: NOW, + }); + const reframe = () => + reframeV3Workflow({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: planned.taskRevision, + checkpointId: reframeCheckpointId, + operationId: reframeOperationId, + now: NOW, + }); + const store = new V3ContextStore(projectRoot); + const home = await taskHomeStore(projectRoot, created.taskRef); + + if (winner === 'reframe') { + const result = await runPausedCanonicalLockRace( + reframeOperationId, + reframe, + createHandoff, + ); + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.metadata).toEqual(result.metadata); + expect(persisted.requirements).toEqual(result.requirements); + expect(persisted.aggregateError).toBeNull(); + await expect(readHandoff(home, handoffId)).resolves.toBeNull(); + await expect( + readTaskCheckpointAtRoot( + taskRootPath(projectRoot, created.taskRef), + handoffCheckpointId, + ), + ).resolves.toBeNull(); + await expect( + readOperationJournal(home, handoffCheckpointOperationId), + ).resolves.toBeNull(); + await expect( + readOperationJournal(home, handoffOperationId), + ).resolves.toBeNull(); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'reframe' }); + await expect( + readTaskHeadFence(home, created.taskRef), + ).resolves.toMatchObject({ + taskRevision: persisted.metadata.revision, + lastOperationId: reframeOperationId, + }); + return; + } + + const result = await runPausedCanonicalLockRace( + winner === 'handoff_checkpoint' + ? handoffCheckpointOperationId + : handoffOperationId, + createHandoff, + reframe, + ); + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.aggregateError).toBeNull(); + expect(persisted.metadata).toMatchObject({ + lastOperationId: handoffCheckpointOperationId, + latestCheckpointRef: { artifactId: handoffCheckpointId }, + }); + expect(persisted.requirements.status).toBe('confirmed'); + await expect(readHandoff(home, handoffId)).resolves.toEqual(result.handoff); + await expect( + readOperationJournal(home, handoffCheckpointOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'checkpoint_create' }); + await expect( + readOperationJournal(home, handoffOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'handoff_transition' }); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toBeNull(); + await expect(readTaskHeadFence(home, created.taskRef)).resolves.toMatchObject( + { + taskRevision: persisted.metadata.revision, + lastOperationId: handoffCheckpointOperationId, + }, + ); + await expectNoReframeArtifacts( + projectRoot, + created.taskRef, + reframeOperationId, + reframeCheckpointId, + ); +} + +async function assertSoloReframeRace( + projectRoot: string, + winner: 'reframe' | 'solo', +): Promise { + const actors = await bootstrap(projectRoot); + const created = await createV3Workflow({ + projectRoot, + task: 'Race solo execution assignment against requirements reframe.', + workflowMode: 'man', + sessionId: actors.sessionId, + client: 'vitest', + taskId: id(400), + operationId: id(401), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: created.metadata.revision, + requirements: confirmedRequirements(created.requirements), + operationId: id(402), + now: NOW, + }); + const planned = await reviseV3Plan({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Plan\n\n1. Execute the bounded solo change.\n', + operationId: id(403), + now: NOW, + }); + const soloOperationId = id(404); + const reframeOperationId = id(405); + const reframeCheckpointId = id(406); + const startSolo = () => + startV3SoloHandoff({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: planned.metadata.revision, + operationId: soloOperationId, + now: NOW, + }); + const reframe = () => + reframeV3Workflow({ + projectRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: planned.metadata.revision, + checkpointId: reframeCheckpointId, + operationId: reframeOperationId, + now: NOW, + }); + const store = new V3ContextStore(projectRoot); + const home = await taskHomeStore(projectRoot, created.taskRef); + + if (winner === 'reframe') { + const result = await runPausedCanonicalLockRace( + reframeOperationId, + reframe, + startSolo, + ); + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.metadata).toEqual(result.metadata); + expect(persisted.requirements).toEqual(result.requirements); + expect(persisted.metadata.soloExecution).toBeNull(); + expect(persisted.aggregateError).toBeNull(); + await expect( + readOperationJournal(home, soloOperationId), + ).resolves.toBeNull(); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'reframe' }); + return; + } + + const result = await runPausedCanonicalLockRace( + soloOperationId, + startSolo, + reframe, + ); + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.metadata).toEqual(result.metadata); + expect(persisted.metadata.soloExecution).toMatchObject({ state: 'active' }); + expect(persisted.requirements.status).toBe('confirmed'); + expect(persisted.aggregateError).toBeNull(); + await expect( + readOperationJournal(home, soloOperationId), + ).resolves.toMatchObject({ state: 'committed', type: 'solo_handoff' }); + await expect( + readOperationJournal(home, reframeOperationId), + ).resolves.toBeNull(); + await expectNoReframeArtifacts( + projectRoot, + created.taskRef, + reframeOperationId, + reframeCheckpointId, + ); +} + +async function runPausedCanonicalLockRace( + winnerOperationId: Ulid, + winner: () => Promise, + loser: () => Promise, +): Promise { + const pause = createOperationLockPauseForTesting({ + operationId: winnerOperationId, + pauseAfter: 'entity_locks_held', + }); + const winnerPromise = pause.run(winner); + void winnerPromise.catch(() => undefined); + await pause.reached; + const loserPromise = loser(); + const [loserWhilePaused] = await Promise.allSettled([loserPromise]); + pause.release(); + const [winnerResult, loserResult] = await Promise.allSettled([ + winnerPromise, + loserPromise, + ]); + + expect(loserWhilePaused).toMatchObject({ status: 'rejected' }); + expect(loserResult).toMatchObject({ status: 'rejected' }); + if (loserResult.status !== 'rejected') { + throw new Error('expected the lock contender to be rejected'); + } + expect( + loserResult.reason instanceof Error + ? loserResult.reason.message + : String(loserResult.reason), + ).toBe('MANCODE_LOCK_HELD'); + if (winnerResult.status === 'rejected') throw winnerResult.reason; + return winnerResult.value; +} + +async function taskHomeStore(projectRoot: string, taskRef: TaskRef) { + const runtime = await readProjectRuntimeContext(projectRoot); + return resolveTaskEntityHomeStore(runtime.entityHomeStoreContext, taskRef); +} + +async function expectTaskMissing( + projectRoot: string, + taskRef: TaskRef, +): Promise { + await expect( + readFile( + path.join(taskRootPath(projectRoot, taskRef), 'metadata.json'), + 'utf8', + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); +} + +async function expectNoReframeArtifacts( + projectRoot: string, + taskRef: TaskRef, + operationId: Ulid, + checkpointId: Ulid, +): Promise { + const taskRoot = taskRootPath(projectRoot, taskRef); + await expect( + readTaskCheckpointAtRoot(taskRoot, checkpointId), + ).resolves.toBeNull(); + await expect( + readFile( + path.join(taskRoot, 'archives', operationId, 'archive.json'), + 'utf8', + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); +} + +async function expectRejectedWithoutAuthorityChange(input: { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + checkpointId: Ulid; + operationId: Ulid; + error: string | RegExp; +}): Promise { + const before = await authorityDigest(input.projectRoot, input.taskRef); + const attempt = reframeV3Workflow({ + projectRoot: input.projectRoot, + taskRef: input.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + checkpointId: input.checkpointId, + operationId: input.operationId, + now: NOW, + }); + if (typeof input.error === 'string') { + await expect(attempt).rejects.toThrow(input.error); + } else { + await expect(attempt).rejects.toThrow(input.error); + } + expect(await authorityDigest(input.projectRoot, input.taskRef)).toBe(before); + + const runtime = await readProjectRuntimeContext(input.projectRoot); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + input.taskRef, + ); + await expect( + readOperationJournal(home, input.operationId), + ).resolves.toBeNull(); +} + +async function authorityDigest( + projectRoot: string, + taskRef: TaskRef, +): Promise { + const store = new V3ContextStore(projectRoot); + const runtime = await readProjectRuntimeContext(projectRoot); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + const [project, task, coordination] = await Promise.all([ + store.readProjectSnapshot(), + store.readTaskSnapshot(taskRef), + store.readCoordinationSnapshot(taskRef, home), + ]); + return digestCanonicalJson({ + project: project.fingerprint, + task: task.fingerprint, + coordination: coordination.fingerprint, + }); +} + +async function bootstrap( + projectRoot: string, + options: { git?: boolean; joined?: boolean; receiver?: boolean } = {}, +): Promise<{ + actorId: Ulid; + sessionId: Ulid; + receiverActorId: Ulid | null; +}> { + if (options.git === true) await initializeGitFixture(projectRoot); + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Reframe Owner', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + if (options.joined === true) { + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing reframe owner actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + } + const receiverActorId = options.receiver === true ? id(6) : null; + if (receiverActorId !== null) { + await publishSharedActorProfile(projectRoot, { + schemaVersion: 1, + actorId: receiverActorId, + displayName: 'Reframe Receiver', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + } + return { actorId, sessionId, receiverActorId }; +} + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +async function caseRoot(projectRoot: string, name: string): Promise { + const target = path.join(projectRoot, name); + await mkdir(target, { recursive: true }); + return target; +} + +async function writeMetadata( + projectRoot: string, + taskRef: TaskRef, + metadata: WorkflowMetadataV3, +): Promise { + await writeFile( + path.join(taskRootPath(projectRoot, taskRef), 'metadata.json'), + `${JSON.stringify(metadata, null, 2)}\n`, + ); +} + +async function forceGitRefTransport(projectRoot: string): Promise { + const project = await new V3ContextStore(projectRoot).readProjectSnapshot(); + const config = parseProjectConfig({ + ...project.config, + revision: project.config.revision + 1, + transport: { + mode: 'git-ref', + remote: 'origin/mancode-team', + epoch: project.config.transport.epoch + 1, + }, + lastOperationId: id(90), + updatedAt: NOW.toISOString(), + }); + await writeFile( + path.join(projectRoot, '.mancode', 'shared', 'config.json'), + `${JSON.stringify(config, null, 2)}\n`, + ); +} + +function confirmedRequirements( + previous: RequirementsLedgerV1, +): RequirementsLedgerV1 { + const requirementId = id(100); + const draft: RequirementsLedgerV1 = { + ...previous, + revision: 99, + status: 'confirmed', + goal: 'Implement the confirmed local plan.', + functionalScope: { + inScope: ['Local implementation'], + outOfScope: ['Unconfirmed changes'], + }, + technicalDecisions: [], + defaults: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension, index) => ({ + coverageId: id(110 + index), + dimension, + status: dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: `Confirmed ${dimension}.`, + })), + requirements: [ + { + displayId: 'REQ-1', + legacyId: null, + requirementId, + statement: 'The local plan remains explicit and testable.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: null, + criterionId: id(101), + requirementIds: [requirementId], + statement: 'The planned change has a reproducible validation.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + contentDigest: '', + lastOperationId: id(102), + updatedAt: NOW.toISOString(), + }; + return parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-21T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-reframe-recovery-contracts.test.ts b/tests/v3-reframe-recovery-contracts.test.ts new file mode 100644 index 0000000..1df9d6f --- /dev/null +++ b/tests/v3-reframe-recovery-contracts.test.ts @@ -0,0 +1,403 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { reframeV3Workflow } from '../src/context/reframe.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { + type OperationRecoveryActionV1, + type OperationRecoveryPayloadV1, + type TaskAuthorityFileName, + taskArchiveManifest, +} from '../src/runtime/operation-recovery-payload.js'; +import { readOperationRecoveryPayload } from '../src/runtime/operation-recovery-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; +import { confirmManteamPlan } from './helpers/manteam-plan.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-21T14:00:00.000Z'); + +describe('V3 reframe crash recovery', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-reframe-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('aborts preparation and forward-repairs every durable reframe write boundary exactly once', async () => { + const fixtures = OPERATION_CRASH_FIXTURES.reframe; + expect( + fixtures + .filter((fixture) => fixture.expectedRecovery === 'safe_abort') + .map((fixture) => fixture.crashAfter), + ).toEqual(['prepared', 'validate']); + + for (const [index, fixture] of fixtures.entries()) { + const caseRoot = path.join(root, `crash-${index}`); + await mkdir(caseRoot); + const actors = await bootstrap(caseRoot); + const created = await createV3Workflow({ + projectRoot: caseRoot, + task: 'Recover a shared reframe from every declared crash boundary.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/context/**'], + modules: ['governance'], + }, + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const confirmed = await confirmManteamPlan({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const acquired = await acquireV3Claim({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: confirmed.taskRevision, + scope: { + paths: ['src/context/**'], + modules: ['governance'], + apis: [], + schemas: [], + }, + claimId: id(12), + operationId: id(13), + now: NOW, + }); + const store = new V3ContextStore(caseRoot); + const beforeTask = await store.readTaskSnapshot(created.taskRef); + const runtime = await readProjectRuntimeContext(caseRoot); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + const beforeCoordination = await store.readCoordinationSnapshot( + created.taskRef, + home, + ); + const operationId = id(100 + index); + const checkpointId = id(200 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + reframeV3Workflow({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: beforeTask.metadata.revision, + checkpointId, + summary: 'Recover the interrupted requirements reframe.', + nextAction: 'Clarify the replacement requirements.', + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const payload = await readOperationRecoveryPayload(home, operationId); + if (payload === null) throw new Error('missing reframe recovery payload'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + now: NOW, + }); + + if (fixture.expectedRecovery === 'safe_abort') { + expect(['aborted', 'already_terminal']).toContain(recovered.state); + expect(recovered.journal.state).toBe('aborted'); + await expectAbortedAuthority({ + projectRoot: caseRoot, + taskRef: created.taskRef, + home, + beforeTask, + beforeCoordination, + operationId, + }); + } else { + expect(recovered.state).toBe( + fixture.crashAfter === 'commit' ? 'already_terminal' : 'repaired', + ); + await expectCommittedTarget({ + projectRoot: caseRoot, + taskRef: created.taskRef, + home, + payload, + operationId, + checkpointId, + beforeTask, + claimId: acquired.claim.claimId, + beforeClaimRevision: acquired.claim.revision, + }); + } + + const terminal = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + now: NOW, + }); + expect(terminal).toMatchObject({ + state: 'already_terminal', + journal: { + state: + fixture.expectedRecovery === 'safe_abort' ? 'aborted' : 'committed', + }, + }); + } + }, 120_000); +}); + +async function expectAbortedAuthority(input: { + projectRoot: string; + taskRef: TaskRef; + home: ReturnType; + beforeTask: Awaited>; + beforeCoordination: Awaited< + ReturnType + >; + operationId: Ulid; +}): Promise { + const store = new V3ContextStore(input.projectRoot); + const [task, coordination, journal] = await Promise.all([ + store.readTaskSnapshot(input.taskRef), + store.readCoordinationSnapshot(input.taskRef, input.home), + readOperationJournal(input.home, input.operationId), + ]); + expect(task.metadata).toEqual(input.beforeTask.metadata); + expect(task.requirements).toEqual(input.beforeTask.requirements); + expect(task.review).toEqual(input.beforeTask.review); + expect(task.verification).toEqual(input.beforeTask.verification); + expect(task.plan).toEqual(input.beforeTask.plan); + expect(task.latestCheckpoint).toEqual(input.beforeTask.latestCheckpoint); + expect(coordination.claims).toEqual(input.beforeCoordination.claims); + expect(coordination.taskHeadFence).toEqual( + input.beforeCoordination.taskHeadFence, + ); + expect(journal?.state).toBe('aborted'); + expect( + await directoryNames( + path.join(taskRootPath(input.projectRoot, input.taskRef), 'archives'), + ), + ).toEqual([]); +} + +async function expectCommittedTarget(input: { + projectRoot: string; + taskRef: TaskRef; + home: ReturnType; + payload: OperationRecoveryPayloadV1; + operationId: Ulid; + checkpointId: Ulid; + beforeTask: Awaited>; + claimId: Ulid; + beforeClaimRevision: number; +}): Promise { + const store = new V3ContextStore(input.projectRoot); + const [task, coordination, journal] = await Promise.all([ + store.readTaskSnapshot(input.taskRef), + store.readCoordinationSnapshot(input.taskRef, input.home), + readOperationJournal(input.home, input.operationId), + ]); + expect(task.metadata).toEqual( + authorityTarget(input.payload, 'metadata.json'), + ); + expect(task.requirements).toEqual( + authorityTarget(input.payload, 'requirements.json'), + ); + expect(task.review).toEqual( + authorityTarget(input.payload, 'review-ledger.json'), + ); + expect(task.verification).toEqual( + authorityTarget(input.payload, 'verification-ledger.json'), + ); + expect(task.plan).toEqual(input.beforeTask.plan); + expect(task.aggregateError).toBeNull(); + expect(task.aggregate).not.toBeNull(); + + const checkpoint = singleAction(input.payload, 'checkpoint').checkpoint; + expect(checkpoint.checkpointId).toBe(input.checkpointId); + expect(task.latestCheckpoint).toEqual(checkpoint); + + const targetClaim = singleAction(input.payload, 'claim').claim; + const persistedClaim = coordination.claims.find( + (claim) => claim.claimId === input.claimId, + ); + expect(targetClaim).toMatchObject({ + claimId: input.claimId, + state: 'released', + revision: input.beforeClaimRevision + 1, + lastOperationId: input.operationId, + }); + expect(persistedClaim).toEqual(targetClaim); + + const targetFence = singleAction(input.payload, 'task_head_fence').fence; + expect(coordination.taskHeadFence).toEqual(targetFence); + expect(targetFence).toMatchObject({ + taskRevision: task.metadata.revision, + lastOperationId: input.operationId, + }); + expect(coordination.pendingOperations).toEqual([]); + expect(journal).toMatchObject({ + operationId: input.operationId, + type: 'reframe', + state: 'committed', + }); + + const archive = singleAction(input.payload, 'task_archive'); + const archiveParent = path.join( + taskRootPath(input.projectRoot, input.taskRef), + 'archives', + ); + const archiveRoot = path.join(archiveParent, input.operationId); + expect(await directoryNames(archiveParent)).toEqual([input.operationId]); + expect(await directoryNames(archiveRoot)).toEqual( + ['archive.json', 'plan.md', 'requirements.json'].sort(), + ); + const [manifest, requirements, plan] = await Promise.all([ + readFile(path.join(archiveRoot, 'archive.json'), 'utf8'), + readFile(path.join(archiveRoot, 'requirements.json'), 'utf8'), + readFile(path.join(archiveRoot, 'plan.md'), 'utf8'), + ]); + expect(JSON.parse(manifest)).toEqual(taskArchiveManifest(archive)); + expect(requirements).toBe(archive.requirementsContent); + expect(plan).toBe(archive.planContent); +} + +function authorityTarget( + payload: OperationRecoveryPayloadV1, + fileName: TaskAuthorityFileName, +): unknown { + const action = payload.actions + .filter( + (candidate) => + candidate.kind === 'task_authority_file' && + candidate.fileName === fileName, + ) + .at(-1); + if (action?.kind !== 'task_authority_file') { + throw new Error(`missing ${fileName} recovery target`); + } + return JSON.parse(action.targetContent); +} + +function singleAction( + payload: OperationRecoveryPayloadV1, + kind: K, +): Extract { + const matches = payload.actions.filter((action) => action.kind === kind); + if (matches.length !== 1 || matches[0] === undefined) { + throw new Error(`expected one ${kind} recovery action`); + } + return matches[0] as Extract; +} + +async function directoryNames(directory: string): Promise { + try { + return (await readdir(directory)).sort(); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return []; + } + throw error; + } +} + +async function bootstrap( + projectRoot: string, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + await initializeGitFixture(projectRoot); + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Reframe Recovery Owner', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing reframe recovery actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + return { actorId, sessionId }; +} + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-21T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-requirements-finalize-contracts.test.ts b/tests/v3-requirements-finalize-contracts.test.ts index 7140f64..30a3b2f 100644 --- a/tests/v3-requirements-finalize-contracts.test.ts +++ b/tests/v3-requirements-finalize-contracts.test.ts @@ -8,7 +8,10 @@ import { initializeV3Project } from '../src/commands/v3-init.js'; import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; import { type Ulid, createUlid } from '../src/context/ids.js'; import { reviseV3Plan } from '../src/context/plan-revision.js'; -import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { + finalizeV3Requirements, + saveV3RequirementsDraft, +} from '../src/context/requirements-finalize.js'; import { REQUIREMENT_DIMENSIONS } from '../src/context/requirements-ledger.js'; import { type RequirementsLedgerV1, @@ -36,7 +39,8 @@ import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; import { readOperationJournal } from '../src/runtime/operation-store.js'; import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; -import { createSession } from '../src/runtime/session.js'; +import { listProjectionIntents } from '../src/runtime/projection-outbox.js'; +import { createSession, readSession } from '../src/runtime/session.js'; import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; import { createLocalActor, @@ -156,6 +160,116 @@ describe('V3 requirements finalization operation', () => { ).rejects.toThrow('MANCODE_EXPECTED_REVISION_CONFLICT'); }); + it('requires an open blocker for an incomplete clarification draft', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Persist an incomplete clarification safely.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(14), + operationId: id(15), + now: NOW, + }); + const invalid = { + ...clarificationDraft(created.requirements, created.taskRef), + blockingUnknowns: [], + }; + + await expect( + saveV3RequirementsDraft({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: { + ...invalid, + contentDigest: requirementsLedgerDigest(invalid), + }, + operationId: id(16), + now: NOW, + }), + ).rejects.toThrow('MANCODE_REQUIREMENTS_DRAFT_BLOCKER_REQUIRED'); + }); + + it('repairs or aborts an actual requirements draft at every declared crash point', async () => { + const fixtures = OPERATION_CRASH_FIXTURES.requirements_draft; + for (const [index, fixture] of fixtures.entries()) { + const caseRoot = path.join(root, `requirements-draft-crash-${index}`); + await mkdir(caseRoot); + const { sessionId } = await bootstrap(caseRoot, false, false); + const created = await createV3Workflow({ + projectRoot: caseRoot, + task: 'Resume clarification after an interrupted draft write.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(17), + operationId: id(18), + now: NOW, + }); + const operationId = id(120 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + saveV3RequirementsDraft({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + requirements: clarificationDraft( + created.requirements, + created.taskRef, + ), + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: id(4), + sessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered).toMatchObject({ + state: 'aborted', + journal: { state: 'aborted' }, + }); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + + const persisted = await new V3ContextStore(caseRoot).readTaskSnapshot( + created.taskRef, + ); + if (fixture.expectedRecovery === 'safe_abort') { + expect(persisted.requirements.revision).toBe(1); + } else { + expect(persisted).toMatchObject({ + metadata: { + revision: 2, + governance: { requirementsStatus: 'needs_clarification' }, + }, + requirements: { revision: 2, status: 'draft' }, + }); + expect(persisted.aggregateError).toBeNull(); + } + } + }); + it('requires the shared task-head fence and advances it with the aggregate', async () => { const { sessionId } = await bootstrap(root, true, true); const created = await createV3Workflow({ @@ -364,6 +478,92 @@ describe('V3 requirements finalization operation', () => { }); }); + it('does not create a new plan version when confirming the existing plan', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Confirm an existing plan without invalidating its evidence.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(100), + operationId: id(101), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(102), + now: NOW, + }); + const plan = '# Plan\n\n1. Confirm the existing plan.\n'; + const revised = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan, + operationId: id(103), + now: NOW, + }); + const beforeConfirmation = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + + const confirmed = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: revised.metadata.revision, + plan, + planDecision: 'plan_only', + operationId: id(104), + now: NOW, + }); + + expect(confirmed.metadata).toMatchObject({ + revision: revised.metadata.revision + 1, + status: 'planned', + currentStep: 4, + governance: { + planVersion: revised.metadata.governance.planVersion, + planDecision: 'plan_only', + }, + }); + expect(confirmed.planDigest).toBe(revised.planDigest); + expect(confirmed.review).toEqual(beforeConfirmation.review); + expect(confirmed.verification).toEqual(beforeConfirmation.verification); + expect(confirmed.metadata.governance.reviewLedgerDigest).toBe( + beforeConfirmation.metadata.governance.reviewLedgerDigest, + ); + expect(confirmed.metadata.governance.verificationLedgerDigest).toBe( + beforeConfirmation.metadata.governance.verificationLedgerDigest, + ); + await expect(readSession(root, sessionId)).resolves.toMatchObject({ + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + }); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + await expect( + listProjectionIntents(root, { + operationId: id(104), + includeTerminal: true, + }), + ).resolves.toMatchObject([ + { + state: 'completed', + target: { action: 'clear', taskRef: created.taskRef }, + }, + ]); + }); + it('releases every active shared claim before committing a completed task and fence', async () => { const { sessionId } = await bootstrap(root, true, true); const created = await createV3Workflow({ @@ -811,6 +1011,41 @@ function finalizedRequirements( }); } +function clarificationDraft( + previous: RequirementsLedgerV1, + taskRef: RequirementsLedgerV1['taskRef'], +): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + ...previous, + taskRef, + revision: 99, + status: 'draft', + goal: 'Preserve the known goal while clarification remains open.', + functionalScope: { inScope: [], outOfScope: [] }, + technicalDecisions: [], + defaults: [], + coverage: [], + requirements: [], + acceptanceCriteria: [], + blockingUnknowns: [ + { + displayId: 'U-1', + legacyId: null, + unknownId: id(63), + statement: 'Choose the semantic owner before implementation.', + status: 'open', + }, + ], + contentDigest: '', + lastOperationId: id(64), + updatedAt: NOW.toISOString(), + }; + return parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); +} + async function bootstrap( projectRoot: string, withGit: boolean, diff --git a/tests/v3-workflow-create-contracts.test.ts b/tests/v3-workflow-create-contracts.test.ts index 660b7fb..113c0a9 100644 --- a/tests/v3-workflow-create-contracts.test.ts +++ b/tests/v3-workflow-create-contracts.test.ts @@ -65,6 +65,7 @@ describe('V3 workflow create operation', () => { expect(created.metadata.governance.requirementsStatus).toBe( 'needs_clarification', ); + expect(created.metadata.governance.policyVersions.planning).toBe(2); expect(created.operation.state).toBe('committed'); expect( created.operation.steps.every((step) => step.state === 'completed'), @@ -91,6 +92,23 @@ describe('V3 workflow create operation', () => { ); }); + it('keeps standalone manba on planning Policy 1 in a Policy 2 project', async () => { + const { sessionId } = await bootstrap(root, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Diagnose an isolated regression.', + workflowMode: 'manba', + sessionId, + client: 'vitest', + taskId: id(30), + operationId: id(31), + now: NOW, + }); + + expect(created.metadata.parent).toBeNull(); + expect(created.metadata.governance.policyVersions.planning).toBe(1); + }); + it('requires a privacy confirmation and joined profile before creating shared authority', async () => { const { actorId, sessionId } = await bootstrap(root, true); const taskId = id(8); @@ -135,6 +153,7 @@ describe('V3 workflow create operation', () => { sharedPrivacyConfirmed: true, }); expect(created.taskRef.namespace).toBe('shared'); + expect(created.metadata.governance.policyVersions.planning).toBe(1); const runtime = await readProjectRuntimeContext(root); const home = resolveTaskEntityHomeStore( runtime.entityHomeStoreContext, @@ -195,6 +214,7 @@ describe('V3 workflow create operation', () => { include: ['src/**'], modules: ['core'], }); + expect(child.metadata.governance.policyVersions.planning).toBe(2); expect(child.resolution.dimensions.visibility.source).toBe('parent'); const staleParent = parseWorkflowMetadata({ diff --git a/tests/website-docs.test.ts b/tests/website-docs.test.ts index 686ca72..95e9879 100644 --- a/tests/website-docs.test.ts +++ b/tests/website-docs.test.ts @@ -66,7 +66,9 @@ describe('website documentation', () => { '.github/prompts/', 'ZCode', 'context session new --client', + 'context session show --session', 'context resume <namespace:ULID>', + '--confirm --operation-id <operationId> --session', ]) { expect(html, `${name} is missing ${requiredText}`).toContain( requiredText, diff --git a/tests/workflow-metadata-contracts.test.ts b/tests/workflow-metadata-contracts.test.ts index 5a34d95..5bba01e 100644 --- a/tests/workflow-metadata-contracts.test.ts +++ b/tests/workflow-metadata-contracts.test.ts @@ -124,6 +124,40 @@ describe('workflow metadata V3 contract', () => { }); expect(metadata.skippedSteps).toEqual(['review', 'clarification']); }); + + it('rejects unknown policy versions with stable component details', () => { + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + governance: { + ...rawMetadata().governance, + policyVersions: { planning: 2, review: 2, verification: 1 }, + }, + }), + ).not.toThrow(); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + governance: { + ...rawMetadata().governance, + policyVersions: { planning: 3, review: 1, verification: 1 }, + }, + }), + ).toThrow( + /MANCODE_POLICY_VERSION_UNSUPPORTED: component=planning observed=3 supported=1,2 requiredWriter=>0.4.0/, + ); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + governance: { + ...rawMetadata().governance, + policyVersions: { planning: 2, review: 3, verification: 1 }, + }, + }), + ).toThrow( + /MANCODE_POLICY_VERSION_UNSUPPORTED: component=review observed=3 supported=1,2 requiredWriter=>0.4.0/, + ); + }); }); function rawMetadata(): WorkflowMetadataV3 { @@ -170,7 +204,7 @@ function rawMetadata(): WorkflowMetadataV3 { requirementsDigest: DIGEST, planVersion: 2, planDecision: 'governed_execution', - policyVersions: { planning: 2, review: 2, verification: 1 }, + policyVersions: { planning: 2, review: 1, verification: 1 }, reviewStatus: 'passed', reviewLedgerDigest: DIGEST, verificationStatus: 'passed', diff --git a/vitest.config.ts b/vitest.config.ts index 38d90bf..ea62bee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + maxWorkers: 2, coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'lcov'], diff --git a/website/PLAN.md b/website/PLAN.md deleted file mode 100644 index fc9e694..0000000 --- a/website/PLAN.md +++ /dev/null @@ -1,83 +0,0 @@ -# mancode Website V1 Plan - -## Objective - -Turn the approved visual sample into a polished, deployable static landing page that explains why mancode exists, shows how its modes change workflow intensity, and gets a developer from first impression to installation without reading the full README. - -The page should feel like a live engineering broadcast: industrial, restrained, fast, and unmistakably tied to mancode's practice-to-playoffs metaphor. - -## Audience and primary action - -- Primary: developers already using Claude Code, Cursor, Codex, Copilot, or ZCode. -- Secondary: engineering teams that want repeatable AI-assisted planning and review without sending project memory to another service. -- Primary action: copy `npm install -g mancode`. -- Secondary actions: open GitHub and read the README. - -## Verified product claims - -Only claims already documented in the repository will appear on the page: - -- Five working modes: `solo`, `/manba`, `/man`, `/manteam`, and `/manps`. -- `/man` uses a nine-step workflow with targeted or full risk-based review and one remediation round. -- mancode detects project context and UI design signals when relevant. -- Workflow artifacts and team memory stay in the repository. -- mancode sends no telemetry. -- Adapters exist for Claude Code, Cursor, Codex, GitHub Copilot, and ZCode, with capability differences clearly represented. - -## Information architecture - -1. **Hero** — memorable outcome, install command, visual `/man` workflow card. -2. **Problem / response** — contrast generic AI over-building with mancode's reuse-first ladder. -3. **Mode selector** — interactive presentation of the five working modes and when to use each. -4. **Playoffs workflow** — compact nine-step `/man` sequence, grouped into scout, plan, execute, verify, and review phases. -5. **Project awareness** — show the files/signals mancode reads and the local artifacts it leaves behind. -6. **Platform matrix** — distinguish full Claude Code integration from adapter-based support without overstating parity. -7. **Quick start** — install, initialize, and choose a mode. -8. **Final call to action / footer** — repeat the install action and link to GitHub, English README, Chinese README, license, and privacy section. - -## Visual and interaction system - -- Keep the approved black, warm white, cyan, orange, and lime palette. -- Keep condensed sports-broadcast typography paired with monospace UI text. -- Extend the court-grid, scorebug, route, and numbered possession motifs through the page. -- Use large editorial section numbers instead of generic rounded feature cards. -- Add one mode-selector interaction, command copy buttons, anchor navigation, and scroll reveals. -- Respect `prefers-reduced-motion`; the page remains readable and complete with JavaScript disabled. - -## Technical implementation - -- Remain framework-free and deployable as a static directory. -- Split the current prototype into `index.html`, `styles.css`, and `site.js` so the completed page stays maintainable. -- Keep the existing local logo asset; add no external fonts, analytics, cookies, trackers, or third-party runtime dependencies. -- Use semantic sections, keyboard-accessible buttons, visible focus states, descriptive labels, and sufficient contrast. -- Use relative asset paths so the site works from a subdirectory or static host. - -## Responsive behavior - -- Desktop: asymmetric two-column hero and wide workflow/platform layouts. -- Tablet: collapse complex grids while keeping the visual card beside or below the copy. -- Mobile: single-column reading order, touch-friendly mode tabs, no horizontal page overflow, and preserved headline impact. - -## Verification - -- Run a local static server and inspect at 1440×900, 1024×768, and 390×844. -- Check the main navigation, all internal anchors, external links, mode selector, and every copy button. -- Check for console errors and horizontal overflow. -- Confirm reduced-motion and JavaScript-free fallbacks do not hide content. -- Run `git diff --check` and review the final change set for unrelated edits. - -## Explicitly out of scope - -- Deployment configuration or publishing to an external service. -- Blog, CMS, authentication, pricing, analytics, newsletter signup, or backend services. -- A separate documentation system or framework migration. -- Claims, testimonials, download numbers, or benchmarks not verified in this repository. - -## Plan self-review - -- **Value hierarchy:** The install action appears in the hero, quick start, and final CTA; supporting details do not block it. -- **Differentiation:** Modes, reuse-first behavior, project awareness, and the basketball workflow metaphor stay central instead of generic "AI productivity" claims. -- **Complexity:** A three-file static implementation is sufficient; no framework or build pipeline is justified. -- **Accuracy:** Platform parity is not implied, and all statements trace to the README. -- **Risk:** The main risks are mobile density, motion excess, and stale hard-coded metrics. The plan addresses these with responsive QA, reduced-motion support, and no volatile metrics. -- **Decision:** Approved for implementation with no blocking questions. diff --git a/website/docs.html b/website/docs.html index 71e8850..6927c88 100644 --- a/website/docs.html +++ b/website/docs.html @@ -24,7 +24,7 @@
mancode - Documentation / v0.3.18 + Documentation / v0.4.0 +
  • refresh-project updates detected project facts and reports stale adapters.
  • Claude Code reads refreshed context through hooks.
  • Cursor, Codex, Copilot, and ZCode use static files; publish a staged repair from an active session with --confirm --operation-id <operationId>.
  • Only UI projects receive design-token-specific guidance.
@@ -311,8 +316,8 @@

Uninstall safely

Remove one adapter

$ mancode uninstall cursor --force

Reinstall one adapter

-
$ mancode uninstall claude-code --force
-$ mancode install claude-code
+
$ mancode adapter upgrade --platform claude-code --dry-run
+$ mancode adapter upgrade --platform claude-code --confirm --operation-id <operationId> --session <id> --client <client>

Remove the CLI

$ npm uninstall -g mancode
@@ -322,9 +327,9 @@

Troubleshooting

mancode init rejects the directory

A safely empty directory is supported interactively, or explicitly with --empty. A non-empty directory still needs a recognized project marker or Git metadata so mancode does not initialize an arbitrary folder by accident.

The agent does not see a newly installed mode

-

Reload or restart the agent so it rereads repository instructions. Run mancode status; if the adapter is not ready, run mancode install <platform> --force. For ZCode and Copilot, also confirm that the installed client release supports repository skills or prompt files.

+

Reload or restart the agent so it rereads repository instructions. Run mancode status; if the adapter is not ready, preview mancode adapter upgrade --platform <platform> --dry-run, then repair it from an active session with --confirm --operation-id <operationId>. For ZCode and Copilot, also confirm that the installed client release supports repository skills or prompt files.

Claude Code hooks do not run

-

Restart Claude Code after initialization, then check mancode status for both hook files and registration in .claude/settings.json. Repair with mancode install claude-code --force. The hooks use Node.js and do not require Bash or jq.

+

Restart Claude Code after initialization, then check mancode status. Repair V3 managed content through the adapter preview and confirmation flow above; the legacy hook architecture uses mancode init --legacy --force. The hooks use Node.js and do not require Bash or jq.

Cursor rules do not trigger

Confirm that .cursor/rules/mancode-*.mdc and .cursor/commands/*.md exist. Core context and solo rules are persistent; higher-intensity rules are description-triggered, so invoke the generated mode command explicitly.

A workflow command is rejected

diff --git a/website/docs.zh-CN.html b/website/docs.zh-CN.html index ab1a377..29fdc9a 100644 --- a/website/docs.zh-CN.html +++ b/website/docs.zh-CN.html @@ -24,7 +24,7 @@
mancode - 文档 / v0.3.18 + 文档 / v0.4.0 +
  • refresh-project 更新项目事实并报告 stale adapter。
  • Claude Code 通过 hooks 读取刷新后的上下文。
  • Cursor、Codex、Copilot 和 ZCode 使用静态文件;从 active session 用 --confirm --operation-id <operationId> 发布 staging 修复。
  • 只有 UI 项目会生成设计 token 相关指导。
@@ -311,8 +316,8 @@

安全卸载

移除一个适配器

$ mancode uninstall cursor --force

重装一个适配器

-
$ mancode uninstall claude-code --force
-$ mancode install claude-code
+
$ mancode adapter upgrade --platform claude-code --dry-run
+$ mancode adapter upgrade --platform claude-code --confirm --operation-id <operationId> --session <id> --client <client>

移除 CLI

$ npm uninstall -g mancode
@@ -322,9 +327,9 @@

故障排查

mancode init 拒绝当前目录

安全的空目录支持交互初始化,也可以显式使用 --empty。非空目录仍需存在可识别的项目标记或 Git 元数据,防止 mancode 意外初始化任意文件夹。

Agent 看不到刚安装的模式

-

重载或重启 Agent,让它重新读取仓库 instructions。执行 mancode status;如果平台未 ready,运行 mancode install <platform> --force。ZCode 和 Copilot 还需要确认当前客户端版本支持仓库 skills 或 prompt files。

+

重载或重启 Agent,让它重新读取仓库 instructions。执行 mancode status;如果平台未 ready,先运行 mancode adapter upgrade --platform <platform> --dry-run,再从 active session 用 --confirm --operation-id <operationId> 修复。ZCode 和 Copilot 还需要确认当前客户端版本支持仓库 skills 或 prompt files。

Claude Code hooks 没有触发

-

初始化后重启 Claude Code,再用 mancode status 检查两个 hook 文件和 .claude/settings.json 注册状态。可执行 mancode install claude-code --force 修复。hooks 使用 Node.js,不依赖 Bash 或 jq。

+

初始化后重启 Claude Code,再用 mancode status 检查状态。V3 managed content 通过上面的 adapter 预览与确认流程修复;legacy hook 架构使用 mancode init --legacy --force。hooks 使用 Node.js,不依赖 Bash 或 jq。

Cursor rules 没有触发

确认 .cursor/rules/mancode-*.mdc.cursor/commands/*.md 都存在。核心上下文与 solo rules 持续生效;高强度规则按描述触发,因此应显式调用生成的模式命令。

workflow 命令被拒绝

diff --git a/website/index.html b/website/index.html index 2820c4d..4b39ca7 100644 --- a/website/index.html +++ b/website/index.html @@ -439,7 +439,7 @@

Stop babysitting
AI bloat.

中文 AGPL-3.0 - Continuity / v0.3.18 + Continuity / v0.4.0 diff --git a/website/index.zh-CN.html b/website/index.zh-CN.html index fbfb3e7..7763de5 100644 --- a/website/index.zh-CN.html +++ b/website/index.zh-CN.html @@ -88,6 +88,6 @@

一项任务。
下个会话继续。

简单任务少一点仪式。
关键任务多一点纪律。

别再替 AI
收拾臃肿。

> npm install -g mancode
GitHub 上查看 ↗
- +