diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a8ba87f..185e516 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,5 +23,7 @@ jobs: run: npm run typecheck - name: Build run: npm run build + - name: Verify compiled adapters + run: npm run test:dist - name: Test run: npm test diff --git a/README.en.md b/README.en.md index 92da94c..f789ebe 100644 --- a/README.en.md +++ b/README.en.md @@ -17,7 +17,7 @@

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

@@ -34,6 +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. + [Installation](#installation) · [Usage](#usage) mancode ships with adapters for Claude Code, Cursor, Codex in the ChatGPT @@ -102,7 +105,7 @@ explicitly resumes the existing TaskRef, preserving continuity without treating one window's temporary state as another window's identity. ```bash -mancode status --json +mancode status --brief --json mancode context session new --client claude-code mancode context resume --session --client claude-code mancode context show --purpose orient --session --client claude-code @@ -350,9 +353,8 @@ it should behave, and why previous decisions were made. ## Installation -**Status**: V3 beta v0.3.14. Claude Code, Cursor, Codex in the ChatGPT desktop -app and CLI, GitHub Copilot, and ZCode adapters are included. Stable release -still requires the five-host real-session acceptance and the `context beta` B1 gate. +**Status**: mancode Continuity v0.3.16. 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 are supported. Git is optional: without it, initialization continues with solo @@ -421,6 +423,7 @@ mancode init mancode init --legacy mancode status mancode status --json +mancode status --brief --json mancode install mancode list-platforms mancode team identity create --name "" @@ -449,7 +452,7 @@ mancode version Simplified output: ```text -mancode v0.3.14 +mancode v0.3.16 Project: my-app Runtime: ready @@ -488,12 +491,15 @@ mancode init ### `mancode status` -Shows activation, runtime binding, identity/session evidence, transport, and -the physical readiness of each platform bootstrap and original mode entry. +The default and full JSON views show activation, runtime binding, +identity/session evidence, transport, and the physical readiness of each +platform bootstrap and original mode entry. Coding agents should combine +`--brief --json` to read only the compact Continuity runtime view. ```bash mancode status mancode status --json +mancode status --brief --json ``` ### `mancode workflow` @@ -639,7 +645,7 @@ Ensure the `.cursor/rules/mancode-*.mdc` files exist. Rules with Mode-specific rules (manba, man, manteam, manps) trigger based on the description field — invoke them by asking for `/manba` or similar. -### How to reinstall V3 adapters +### How to reinstall Continuity adapters ```bash mancode uninstall claude-code --force @@ -654,7 +660,7 @@ mancode install copilot mancode install zcode ``` -V3 authority is protected, so `mancode uninstall --all` does not delete workflow +Continuity authority is protected, so `mancode uninstall --all` does not delete workflow authority. To inspect removable runtime records, run `mancode context compact --dry-run` first. @@ -665,7 +671,7 @@ npm uninstall -g mancode ``` Uninstalling each platform removes its mancode bootstrap while preserving -user-authored rules, instructions, and V3 workflow data. The `--all` form is only +user-authored rules, instructions, and Continuity workflow data. The `--all` form is only supported for projects explicitly initialized with `mancode init --legacy`. ## FAQ diff --git a/README.md b/README.md index a15a90e..f98f441 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

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

@@ -36,6 +36,8 @@ 日常任务用轻量 `solo`,关键任务用季后赛级别的 `/man`,复杂任务让教练组 subagents 负责调研、计划、实现和审查。 +**mancode Continuity(连续上下文运行时)**负责把任务、决策和验证证据安全地带到后续对话。 + mancode 当前支持 Claude Code、Cursor、ChatGPT 桌面端中的 Codex、Codex CLI、 GitHub Copilot 和 ZCode。每个平台继续使用原来的 `man*` 入口,并通过静态 bootstrap 接入统一的 Context Pack 与 workflow authority。 @@ -92,7 +94,7 @@ mancode 把目标、需求、计划、检查结果和交接信息保存在稳定 窗口的临时状态误当成另一个窗口的身份。 ```bash -mancode status --json +mancode status --brief --json mancode context session new --client claude-code mancode context resume --session --client claude-code mancode context show --purpose orient --session --client claude-code @@ -305,9 +307,8 @@ src/components/ ## 安装 -**状态**:V3 Beta v0.3.14。Claude Code、Cursor、ChatGPT 桌面端中的 Codex、 -Codex CLI 和 GitHub Copilot 均已接入;ZCode adapter 已接入。正式稳定发布仍需 -完成五平台真实宿主验收和 `context beta` B1 门禁。 +**状态**:mancode Continuity v0.3.16。Claude Code、Cursor、ChatGPT 桌面端中的 +Codex、Codex CLI、GitHub Copilot 和 ZCode adapter 均已接入。 需要 Node.js 20 或更高版本。原生支持 macOS、Linux、Windows CMD、 PowerShell 和 Git Bash。Git 是可选依赖:未安装时仍可初始化,只会把团队 @@ -374,6 +375,7 @@ mancode init mancode init --legacy mancode status mancode status --json +mancode status --brief --json mancode install mancode list-platforms mancode team identity create --name "" @@ -402,7 +404,7 @@ mancode version 以下是简化输出示例: ```text -mancode v0.3.14 +mancode v0.3.16 Project: my-app Runtime: ready @@ -440,12 +442,14 @@ mancode init ### `mancode status` -显示 activation、runtime binding、identity/session evidence、transport 和各平台 -bootstrap/原 mode 入口的实际就绪状态。 +默认输出和完整 JSON 显示 activation、runtime binding、identity/session evidence、 +transport 和各平台 bootstrap/原 mode 入口的实际就绪状态。编码 Agent 应组合使用 +`--brief --json`,只读取精简的 Continuity 运行时视图。 ```bash mancode status mancode status --json +mancode status --brief --json ``` ### `mancode workflow` @@ -579,7 +583,7 @@ mancode/ (context、practice、solo)在每次对话加载。模式规则(manba、man、manteam、 manps)按 description 触发——输入 `/manba` 等关键词即可激活。 -### 如何重装 V3 适配器 +### 如何重装 Continuity 适配器 ```bash mancode uninstall claude-code --force @@ -594,7 +598,7 @@ mancode install copilot mancode install zcode ``` -V3 authority 受保护,`mancode uninstall --all` 不会删除工作流权威数据。需要 +Continuity authority 受保护,`mancode uninstall --all` 不会删除工作流权威数据。需要 清理运行时保留记录时,先用 `mancode context compact --dry-run` 检查候选。 ### 如何移除 CLI @@ -604,7 +608,7 @@ npm uninstall -g mancode ``` 逐个平台卸载会移除对应的 mancode bootstrap,并保留用户自定义 rules、instructions -和 V3 工作流数据。旧项目若明确使用 `mancode init --legacy`,才支持 legacy 的 +和 Continuity 工作流数据。旧项目若明确使用 `mancode init --legacy`,才支持 legacy 的 `mancode uninstall --all --force`。 ## 常见问题 diff --git a/package-lock.json b/package-lock.json index af627fe..8ec0e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mancode", - "version": "0.3.14", + "version": "0.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mancode", - "version": "0.3.14", + "version": "0.3.16", "license": "AGPL-3.0-only", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index d5461a8..a5caea0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mancode", - "version": "0.3.14", - "description": "AI coding agent workflow harness. Five modes from practice to playoffs: stop over-engineering, reuse project context, and add multi-agent code review.", + "version": "0.3.16", + "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", "author": "whitelonng", @@ -59,13 +59,14 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", + "test:dist": "node scripts/verify-dist-adapters.mjs", "test:windows-smoke": "node scripts/windows-smoke.mjs", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "biome check src tests", "typecheck": "tsc --noEmit", "format": "biome format --write src tests", - "prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm test" + "prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm run test:dist && npm test" }, "dependencies": { "commander": "^12.1.0", diff --git a/scripts/verify-dist-adapters.mjs b/scripts/verify-dist-adapters.mjs new file mode 100644 index 0000000..7f2caac --- /dev/null +++ b/scripts/verify-dist-adapters.mjs @@ -0,0 +1,92 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm } 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 repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const cliPath = path.join(repoRoot, 'dist', 'cli.js'); +const projectRoot = await mkdtemp( + path.join(tmpdir(), 'mancode-dist-adapters-'), +); + +try { + await mkdir(projectRoot, { recursive: true }); + runCli(['init', '--empty', '--platform', 'all', '--lang', 'en']); + + const generatedFiles = [ + '.claude/skills/mancode-v3/SKILL.md', + '.claude/skills/man/SKILL.md', + '.cursor/rules/mancode-v3.mdc', + '.cursor/commands/man.md', + 'AGENTS.md', + '.agents/skills/man/SKILL.md', + '.github/copilot-instructions.md', + '.github/prompts/man.prompt.md', + ]; + + for (const relativePath of generatedFiles) { + const content = await readFile(path.join(projectRoot, relativePath), 'utf8'); + assertGeneratedContract(relativePath, content); + } + + const status = runCli(['status', '--brief', '--json']); + const parsed = JSON.parse(status.stdout); + assert( + parsed.runtime === 'mancode-continuity' && parsed.state === 'active', + `compiled status returned an unexpected runtime: ${status.stdout}`, + ); + assert( + !status.stdout.includes('"v3"') && !status.stdout.includes('v3_active'), + `compiled brief status exposed internal version fields: ${status.stdout}`, + ); + + console.log( + `Verified ${generatedFiles.length} adapters generated by dist/cli.js.`, + ); +} finally { + await rm(projectRoot, { recursive: true, force: true }); +} + +function runCli(args) { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: projectRoot, + encoding: 'utf8', + }); + assert( + result.status === 0, + `compiled CLI failed (${result.status}): ${result.stderr || result.stdout}`, + ); + return result; +} + +function assertGeneratedContract(relativePath, content) { + for (const required of [ + 'mancode status --brief --json', + 'snapshot already obtained in this conversation', + 'mancode Continuity', + ]) { + assert( + content.includes(required), + `${relativePath} is missing compiled guidance: ${required}`, + ); + } + for (const forbidden of [ + 'mancode status --json', + 'authority: "v3"', + 'v3_active', + ]) { + assert( + !content.includes(forbidden), + `${relativePath} exposes stale guidance: ${forbidden}`, + ); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} diff --git a/src/cli.ts b/src/cli.ts index 41bff6b..2f1e0e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -112,6 +112,7 @@ program .command('status') .description('Show current mancode project status') .option('--json', 'Output as JSON (for scripts)') + .option('--brief', 'Output compact mancode Continuity runtime status') .action(async (options) => { const code = await status(process.cwd(), options); process.exitCode = code; diff --git a/src/commands/context.ts b/src/commands/context.ts index 6092124..9d64da9 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -55,6 +55,10 @@ import { resumeSession, } from '../runtime/session.js'; import { readLocalActor } from '../team/actor.js'; +import { + capabilitiesFromGitRefCache, + readGitRefTeamCache, +} from '../team/git-ref-cache.js'; import { listGitRefWorkflowRepairs, recoverGitRefWorkflowRepair, @@ -810,6 +814,16 @@ async function resolveContext( projectRoot: project.projectRoot, entityHomeStoreContext: project.runtime.entityHomeStoreContext, }); + const capabilities = + project.project.config.transport.mode === 'git-ref' + ? capabilitiesFromGitRefCache( + project.project.config, + await readGitRefTeamCache( + project.projectRoot, + project.project.config, + ), + ) + : undefined; return resolver.resolve({ session, taskRef: request.taskRef, @@ -823,6 +837,7 @@ async function resolveContext( adapterVersions: project.project.manifest.managedAdapters, }, codeHead: await readCheckoutCodeHead(project.projectRoot), + ...(capabilities === undefined ? {} : { capabilities }), }); } diff --git a/src/commands/status.ts b/src/commands/status.ts index a1cc529..499a0bb 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -81,6 +81,8 @@ export interface StatusState { export interface StatusOptions { /** --json: 输出 JSON(脚本用)*/ json?: boolean; + /** --brief: 输出 Continuity 的紧凑运行时状态 */ + brief?: boolean; } export interface StatusResult { @@ -202,6 +204,19 @@ export interface V3StatusResult { legacyAuthorityPresent: boolean; } +export interface ContinuityStatusResult { + schemaVersion: 1; + runtime: 'mancode-continuity'; + state: 'active' | 'unavailable'; + version: string; + project: string; + ready: boolean; + identity: V3StatusResult['localIdentity']; + session: V3StatusResult['currentSession']; + task: V3StatusResult['currentTask']; + blockers: string[]; +} + interface DetectedTeamStatus { isTeam: boolean; contributors: number; @@ -452,8 +467,11 @@ async function statusV3( adapters, legacyAuthorityPresent: legacy.authorityPresent, }; + const output = options.brief ? toContinuityStatus(result) : result; if (options.json) { - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(output, null, 2)); + } else if (options.brief) { + printContinuityText(output as ContinuityStatusResult); } else { printV3Text(result); } @@ -466,6 +484,30 @@ async function statusV3( } } +function toContinuityStatus(result: V3StatusResult): ContinuityStatusResult { + const blockers = [ + ...result.compatibility.failures, + ...(result.runtime.error === null ? [] : [result.runtime.error]), + ]; + const ready = + result.activation.state === 'v3_active' && + result.compatibility.readAllowed && + result.compatibility.writeAllowed && + result.runtime.binding === 'ready'; + return { + schemaVersion: 1, + runtime: 'mancode-continuity', + state: ready ? 'active' : 'unavailable', + version: result.version, + project: result.project, + ready, + identity: result.localIdentity, + session: result.currentSession, + task: result.currentTask, + blockers, + }; +} + /** Status is read-only: an invalid ambient session is simply not current. */ async function readV3StatusSession(rootDir: string) { const sessionId = process.env.MANCODE_SESSION_ID; @@ -846,7 +888,7 @@ function printText(r: StatusResult): void { function printV3Text(result: V3StatusResult): void { console.log(''); - console.log(`mancode v${result.version}`); + console.log(`mancode Continuity v${result.version}`); console.log(''); console.log(`Project: ${result.project}`); console.log( @@ -913,6 +955,16 @@ function printV3Text(result: V3StatusResult): void { console.log(''); } +function printContinuityText(result: ContinuityStatusResult): void { + console.log(`mancode Continuity v${result.version}`); + console.log(`Project: ${result.project}`); + console.log(`State: ${result.state}`); + console.log(`Ready: ${result.ready ? 'yes' : 'no'}`); + if (result.blockers.length > 0) { + console.log(`Blockers: ${result.blockers.join('; ')}`); + } +} + function formatActivationState(state: string): string { return state.replace(/^v3_/, '').replaceAll('_', ' '); } diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 4fa4a97..34e1cb4 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -104,7 +104,7 @@ async function uninstallV3( if (!platform || options.all) { console.error('✗ mancode authority is protected from bulk uninstall.'); console.error( - ' Remove a single bootstrap with `mancode uninstall `. Inspect runtime retention with `mancode context compact --dry-run`; V3 workflow authority is not bulk-deleted.', + ' Remove a single bootstrap with `mancode uninstall `. Inspect runtime retention with `mancode context compact --dry-run`; Continuity workflow authority is not bulk-deleted.', ); return EXIT_V3_AUTHORITY_PROTECTED; } diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index e98762b..cc8c133 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -384,7 +384,7 @@ function workflowCleanV3(options: WorkflowOptions): number { return printV3Error( options.json, 'MANCODE_V3_WORKFLOW_CLEAN_UNSUPPORTED', - 'V3 workflow authority is durable and is not deleted by workflow clean. Use `mancode context compact --dry-run` to inspect eligible runtime retention records.', + 'Continuity workflow authority is durable and is not deleted by workflow clean. Use `mancode context compact --dry-run` to inspect eligible runtime retention records.', EXIT_INVALID_ARG, ); } diff --git a/src/context/task-head-reconcile.ts b/src/context/task-head-reconcile.ts index 78d5201..28b1a92 100644 --- a/src/context/task-head-reconcile.ts +++ b/src/context/task-head-reconcile.ts @@ -31,15 +31,18 @@ import { } from '../runtime/task-operation.js'; import type { ClaimV1 } from '../team/claims.js'; import { deriveClaimValidity } from '../team/conflicts.js'; +import { createGitRefTeamManifestStore } from '../team/git-ref-client.js'; import type { HandoffV1 } from '../team/handoff.js'; +import type { ProjectConfigV1 } from '../team/policy.js'; import { type TaskAggregateManifestV1, taskAggregateDigest, } from './aggregate.js'; import { type Ulid, assertUlid, createUlid } from './ids.js'; +import type { SchemaManifestV1 } from './manifest.js'; import { type StoredTaskSnapshot, V3ContextStore } from './store.js'; import { assertTaskCodeHeadUnchanged } from './task-mutation.js'; -import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; const execFile = promisify(execFileCallback); @@ -119,13 +122,19 @@ export async function previewV3TaskHeadReconcile( input.projectRoot, task.location.taskRoot, ); + const project = await store.readProjectSnapshot(); + await assertRemoteTaskUnpublished({ + projectRoot: input.projectRoot, + taskRef, + config: project.config, + manifest: project.manifest, + }); assertReconcileEligible({ task, aggregate, previousFence, codeHead, actorId: input.sessionActorId, - transportMode: (await store.readProjectSnapshot()).config.transport.mode, claims: coordination.claims, handoffs: coordination.handoffs, now, @@ -194,13 +203,18 @@ export async function reconcileV3TaskHead( if (reread.fingerprint !== context.task.fingerprint) { throw new Error('MANCODE_CONTEXT_STALE'); } + await assertRemoteTaskUnpublished({ + projectRoot: context.projectRoot, + taskRef, + config: context.project.config, + manifest: context.project.manifest, + }); assertReconcileEligible({ task: context.task, aggregate, previousFence, codeHead, actorId: context.session.actorId, - transportMode: context.project.config.transport.mode, claims: context.coordination.claims, handoffs: context.coordination.handoffs, now: context.now, @@ -396,14 +410,10 @@ function assertReconcileEligible(input: { previousFence: TaskHeadFenceV1; codeHead: string; actorId: Ulid; - transportMode: 'local' | 'git-ref'; claims: ClaimV1[]; handoffs: HandoffV1[]; now: Date; }): void { - if (input.transportMode !== 'local') { - throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); - } if (input.task.metadata.ownerActorId !== input.actorId) { throw new Error('MANCODE_TASK_OWNER_REQUIRED'); } @@ -420,6 +430,31 @@ function assertReconcileEligible(input: { assertHandoffsConsistent(input.handoffs); } +async function assertRemoteTaskUnpublished(input: { + projectRoot: string; + taskRef: TaskRef; + config: ProjectConfigV1; + manifest: SchemaManifestV1; +}): Promise { + if (input.config.transport.mode !== 'git-ref') return; + const snapshot = await createGitRefTeamManifestStore( + input.projectRoot, + input.config, + input.manifest, + ).pull(); + const remote = snapshot.manifest; + if (remote === null) return; + const hasTaskAuthority = [ + ...remote.ownershipFences, + ...remote.taskBundles, + ...remote.claims, + ...remote.handoffs, + ].some((entity) => sameTaskRef(entity.taskRef, input.taskRef)); + if (hasTaskAuthority) { + throw new Error('MANCODE_GIT_REF_TASK_ALREADY_PUBLISHED'); + } +} + function assertClaimsConsistent( claims: ClaimV1[], task: StoredTaskSnapshot, diff --git a/src/context/workflow-create.ts b/src/context/workflow-create.ts index 76be0b7..558d440 100644 --- a/src/context/workflow-create.ts +++ b/src/context/workflow-create.ts @@ -51,6 +51,11 @@ import { createTaskHeadFence } from '../runtime/task-head-store.js'; import { readSharedActorProfile } from '../team/actor.js'; import type { TeamAssessment } from '../team/assessment.js'; import { createAuthorizationBasis } from '../team/authorization.js'; +import { + capabilitiesFromGitRefCache, + readGitRefTeamCache, +} from '../team/git-ref-cache.js'; +import type { ProjectConfigV1 } from '../team/policy.js'; import { assertTransportCoordinationWriteAllowed } from '../team/transport-migration-freeze.js'; import { capabilitiesFromProjectConfig } from '../team/transport.js'; import { VERSION } from '../version.js'; @@ -225,6 +230,7 @@ export async function createV3Workflow( if (taskRef.namespace === 'shared') { await assertSharedCreationPrerequisites( projectRoot, + project.config, session.actorId, input.sharedPrivacyConfirmed === true, participants, @@ -247,6 +253,11 @@ export async function createV3Workflow( parent, participants, explicitScope: input.implementationScope, + ownershipEpoch: + taskRef.namespace === 'shared' && + project.config.transport.mode === 'git-ref' + ? 0 + : 1, timestamp, }); const homeStore = resolveTaskEntityHomeStore( @@ -527,6 +538,7 @@ async function resolveParentCreationContext( async function assertSharedCreationPrerequisites( projectRoot: string, + config: ProjectConfigV1, actorId: Ulid, privacyConfirmed: boolean, participants: readonly Ulid[], @@ -534,19 +546,40 @@ async function assertSharedCreationPrerequisites( if (!privacyConfirmed) { throw new Error('MANCODE_PRIVACY_CONFIRMATION_REQUIRED'); } - if ((await readSharedActorProfile(projectRoot, actorId)) === null) { + if (!(await actorHasSharedProfile(projectRoot, config, actorId))) { throw new Error('MANCODE_JOIN_REQUIRED'); } for (const participantActorId of participants) { if (participantActorId === actorId) continue; if ( - (await readSharedActorProfile(projectRoot, participantActorId)) === null + !(await actorHasSharedProfile(projectRoot, config, participantActorId)) ) { throw new Error('MANCODE_PARTICIPANT_JOIN_REQUIRED'); } } } +async function actorHasSharedProfile( + projectRoot: string, + config: ProjectConfigV1, + actorId: Ulid, +): Promise { + if ((await readSharedActorProfile(projectRoot, actorId)) !== null) + return true; + if (config.transport.mode !== 'git-ref') return false; + const cache = await readGitRefTeamCache(projectRoot, config); + if ( + capabilitiesFromGitRefCache(config, cache).transportFreshness !== 'fresh' + ) { + throw new Error('MANCODE_TRANSPORT_NOT_FRESH'); + } + return ( + cache?.manifest?.actorProfiles.some( + (profile) => profile.actorId === actorId, + ) === true + ); +} + async function requireSharedCodeHead(projectRoot: string): Promise { const head = await readCheckoutCodeHead(projectRoot); if (head === null) throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); @@ -595,6 +628,7 @@ function buildInitialEntities(input: { parent: ParentCreationContext | null; participants: Ulid[]; explicitScope: WorkflowCreateScope | undefined; + ownershipEpoch: number; timestamp: string; }): InitialEntities { const scope = initialScope(input.parent, input.explicitScope); @@ -691,7 +725,7 @@ function buildInitialEntities(input: { transitionState: 'stable', lastOperationId: input.operationId, ownerActorId: input.actorId, - ownershipEpoch: 1, + ownershipEpoch: input.ownershipEpoch, participants: input.participants, createdBy: { actorId: input.actorId, diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts index 3de59c4..c9b2cce 100644 --- a/src/installers/v3-adapter.ts +++ b/src/installers/v3-adapter.ts @@ -581,8 +581,8 @@ export function renderV3Bootstrap(platform: PlatformName): string { const sessionArguments = sessionArgumentsFor(platform); const sessionCreationGuidance = platform === 'codex' || platform === 'zcode' - ? 'When status has no `currentSession`, first reuse any explicit session ID already returned in this conversation. Only when neither exists, create one once with `mancode context session new --client codex` in Codex or `mancode context session new --client zcode` in ZCode.' - : `When status has no \`currentSession\`, first reuse any explicit session ID already returned in this conversation. Only when neither exists, create one once with \`mancode context session new --client ${platform}\`.`; + ? 'When status has no `session`, first reuse any explicit session ID already returned in this conversation. Only when neither exists, create one once with `mancode context session new --client codex` in Codex or `mancode context session new --client zcode` in ZCode.' + : `When status has no \`session\`, first reuse any explicit session ID already returned in this conversation. Only when neither exists, create one once with \`mancode context session new --client ${platform}\`.`; const spikePlatform = platform === 'codex' || platform === 'zcode' ? 'the active Codex or ZCode host' @@ -597,12 +597,12 @@ export function renderV3Bootstrap(platform: PlatformName): string { '', `- Platform: ${platformLabel}. This file is a non-authoritative bootstrap.`, '- Locate the project root before running mancode commands.', - '- First run `mancode status --json` from the project root.', - '- Treat status values such as `authority: "v3"` and `v3_active` as internal machine fields. In operator-facing narration, say `mancode`; never prefix a mode or action with a version label.', + '- 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.', + '- 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 `localIdentity.actorId`, ask for a display name and run `mancode team identity create --name ""` before creating a session.', - '- If status reports `currentSession`, reuse it. `currentTask: null` and `MANCODE_TASK_REQUIRED` do not make a session stale.', + '- 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`.', '- 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.', @@ -627,6 +627,8 @@ export function renderV3ModeEntry( const definition = V3_MODE_DEFINITIONS[mode]; const sessionArguments = sessionArgumentsFor(platform); 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 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.' @@ -634,31 +636,31 @@ export function renderV3ModeEntry( let authoritySteps: string[]; if (mode === 'manps') { authoritySteps = [ - '1. Run `mancode status --json` from the project root and require active mancode authority.', + `1. ${statusGuidance} Require an active, ready mancode Continuity runtime.`, '2. Run the health action below directly. A local scan needs no TaskRef, actor identity, or explicit session.', '3. Never read or write legacy mode authority before or after the scan.', ]; } else if (mode === 'mansolo') { authoritySteps = [ - '1. Run `mancode status --json` from the project root. Never read or write legacy mode authority.', + `1. ${statusGuidance} Never read or write legacy mode authority.`, '2. If no governed task is being handed off, continue with focused solo work without creating a persistent mode, actor, session, or TaskRef.', - `3. For an explicit governed handoff, ensure \`localIdentity.actorId\`, reuse or create the current session, and bind the existing TaskRef. ${sessionCreationGuidance} ${sessionClientGuidance}`, + `3. For an explicit governed handoff, ensure \`identity.actorId\`, reuse or create the current session, and bind the existing TaskRef. ${sessionCreationGuidance} ${sessionClientGuidance}`, `4. For that governed task only, read \`mancode context show --purpose implement ${sessionArguments}\` using the bound or explicit TaskRef.`, ]; } else if (mode === 'man') { authoritySteps = [ - '1. Run `mancode status --json` from the project root. Never read or write the legacy authority file.', + `1. ${statusGuidance} Never read or write the legacy authority file.`, '2. If the request is only a read-only project orientation, introduction, explanation, or summary, inspect the repository and answer directly without creating an actor, session, TaskRef, or workflow. Do not turn an orientation request into a `plan_only` workflow.', - '3. Only for requested governed task work, if `localIdentity.actorId` is absent, ask for a display name and run `mancode team identity create --name ""`.', - `4. Reuse \`currentSession.sessionId\` when present. ${sessionCreationGuidance} ${sessionClientGuidance}`, + '3. Only for requested governed task work, if `identity.actorId` is absent, ask for a display name and run `mancode team identity create --name ""`.', + `4. Reuse \`session.sessionId\` when present. ${sessionCreationGuidance} ${sessionClientGuidance}`, `5. Reuse the current TaskRef. To bind a supplied existing task, run \`mancode context resume ${sessionArguments}\`.`, `6. For an existing task, read only the needed Context Pack with \`mancode context show --purpose ${definition.contextPurpose} ${sessionArguments}\`; include \`--task \` when it is not yet bound. For a new task, create it through the mode action first, then read the returned TaskRef's Context Pack.`, ]; } else { authoritySteps = [ - '1. Run `mancode status --json` from the project root. Never read or write the legacy authority file.', - '2. If `localIdentity.actorId` is absent, ask for a display name and run `mancode team identity create --name ""`.', - `3. Reuse \`currentSession.sessionId\` when present. ${sessionCreationGuidance} ${sessionClientGuidance}`, + `1. ${statusGuidance} Never read or write the legacy authority file.`, + '2. If `identity.actorId` is absent, ask for a display name and run `mancode team identity create --name ""`.', + `3. Reuse \`session.sessionId\` when present. ${sessionCreationGuidance} ${sessionClientGuidance}`, `4. Reuse the current TaskRef. To bind a supplied existing task, run \`mancode context resume ${sessionArguments}\`.`, `5. For an existing task, read only the needed Context Pack with \`mancode context show --purpose ${definition.contextPurpose} ${sessionArguments}\`; include \`--task \` when it is not yet bound. For a new task, create it through the mode action first, then read the returned TaskRef's Context Pack.`, ]; @@ -700,7 +702,7 @@ export function renderV3ModeEntry( '', ...actions, '', - 'Treat status values such as `authority: "v3"` and `v3_active` as internal machine fields. In operator-facing narration, say `mancode`; never prefix this mode or its actions with a version label.', + 'Treat the compact status as the public mancode Continuity runtime view. In operator-facing narration, say `mancode` or `mancode Continuity`; never prefix this mode or its actions with a version label.', '', mutationGuidance, '', @@ -1313,7 +1315,7 @@ function renderRetiredCursorRule(target: V3LegacyAdapterFileTarget): string { : legacyName; const guidance = mode === 'context' || mode === 'practice' - ? 'Run `mancode status --json`, then use the matching mancode mode command.' + ? 'Run `mancode status --brief --json`, then use the matching mancode mode command.' : `Use the \`/${mode}\` mancode mode command.`; return [ '---', diff --git a/src/team/git-ref-operation.ts b/src/team/git-ref-operation.ts index a2dcb04..0739f2c 100644 --- a/src/team/git-ref-operation.ts +++ b/src/team/git-ref-operation.ts @@ -13,6 +13,7 @@ import { sameTaskRef, } from '../context/task-ref.js'; import { parseWorkflowMetadata } from '../context/workflow-metadata.js'; +import { readOperationJournal } from '../runtime/operation-store.js'; import { readCheckoutBranch } from '../runtime/project-runtime.js'; import { type OpenedV3TaskOperation, @@ -203,7 +204,6 @@ export async function syncGitRefTask( now, }); try { - await assertCleanGitWorktree(context.projectRoot); const transport = createGitRefTeamManifestStore( context.projectRoot, context.project.config, @@ -211,6 +211,21 @@ export async function syncGitRefTask( ); const snapshot = await transport.pull(); const bundle = await bundleFromContext(context, now); + const remoteTaskPublished = + snapshot.manifest?.ownershipFences.some((fence) => + sameTaskRef(fence.taskRef, taskRef), + ) === true && + snapshot.manifest.taskBundles.some((remoteBundle) => + sameTaskRef(remoteBundle.taskRef, taskRef), + ); + const taskAuthorityChangesAllowed = + remoteTaskPublished && + (remoteBundleMatches(snapshot, bundle) || + (await hasCommittedHandoffCheckpoint(context))); + await assertCleanGitWorktree( + context.projectRoot, + taskAuthorityChangesAllowed ? taskRef : undefined, + ); const result = await synchronizeBundle( context, transport, @@ -471,7 +486,7 @@ export async function createGitRefHandoffDraft( assertContextMatchesRemoteBundle(context, bundle); await Promise.all([ assertGitRefBundleCodeReachable(context.projectRoot, bundle), - assertCleanGitWorktree(context.projectRoot), + assertCleanGitWorktree(context.projectRoot, taskRef), ]); const checkpoint = context.task.latestCheckpoint; if ( @@ -571,7 +586,10 @@ export async function mutateGitRefHandoff( }); try { if (input.mutation.kind === 'offer') { - await assertCleanGitWorktree(opened.context.projectRoot); + await assertCleanGitWorktree( + opened.context.projectRoot, + opened.handoff.taskRef, + ); } const kind = input.mutation.kind === 'offer' @@ -848,15 +866,49 @@ function defaultRemoteHandoffSummary(checkpoint: CheckpointV1): HandoffSummary { }; } -async function assertCleanGitWorktree(projectRoot: string): Promise { - const { stdout } = await execFile( - 'git', - ['status', '--porcelain=v1', '--untracked-files=all'], - { cwd: projectRoot, windowsHide: true }, - ); +async function assertCleanGitWorktree( + projectRoot: string, + materializedTaskRef?: TaskRef, +): Promise { + const args = ['status', '--porcelain=v1', '--untracked-files=all']; + if (materializedTaskRef !== undefined) { + args.push( + '--', + '.', + `:(exclude,top).mancode/shared/workflows/${materializedTaskRef.taskId}/**`, + ); + } + const { stdout } = await execFile('git', args, { + cwd: projectRoot, + windowsHide: true, + }); if (stdout.trim()) throw new Error('MANCODE_HANDOFF_DIRTY_WORKTREE'); } +async function hasCommittedHandoffCheckpoint( + context: OpenedV3TaskOperation, +): Promise { + const checkpoint = context.task.latestCheckpoint; + if ( + checkpoint === null || + checkpoint.kind !== 'handoff_offered' || + context.task.metadata.lastOperationId !== checkpoint.operationId || + context.task.aggregate?.latestCheckpointId !== checkpoint.checkpointId + ) { + return false; + } + const journal = await readOperationJournal( + context.homeStore, + checkpoint.operationId, + ); + return ( + journal?.type === 'checkpoint_create' && + journal.state === 'committed' && + journal.actorId === context.session.actorId && + journal.sessionId === context.session.sessionId + ); +} + function assertPositiveRevision(value: number, label: string): void { if (!Number.isSafeInteger(value) || value < 1) { throw new Error(`${label} must be a positive integer`); @@ -883,10 +935,7 @@ async function synchronizeBundle( const fence = manifest.ownershipFences.find((candidate) => sameTaskRef(candidate.taskRef, context.taskRef), ); - if (fence === undefined) { - throw new Error('MANCODE_TRANSPORT_MIGRATION_REQUIRED'); - } - if (remoteBundleMatches(snapshot, bundle)) { + if (fence !== undefined && remoteBundleMatches(snapshot, bundle)) { return { remoteRevision: manifest.revision, ownershipEpoch: fence.ownershipEpoch, @@ -900,7 +949,7 @@ async function synchronizeBundle( actorId: context.session.actorId, taskRef: context.taskRef, expectedRemoteRevision: manifest.revision, - expectedOwnershipEpoch: fence.ownershipEpoch, + expectedOwnershipEpoch: fence?.ownershipEpoch ?? 0, taskBundle: bundle, now, }); diff --git a/tests/git-ref-cross-clone-e2e.test.ts b/tests/git-ref-cross-clone-e2e.test.ts index 7816274..73e4902 100644 --- a/tests/git-ref-cross-clone-e2e.test.ts +++ b/tests/git-ref-cross-clone-e2e.test.ts @@ -4,7 +4,11 @@ import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; -import { teamSyncPull } from '../src/commands/team.js'; +import { + contextReconcileTaskHead, + contextResume, +} from '../src/commands/context.js'; +import { teamClaim, teamSyncPull, teamSyncPush } from '../src/commands/team.js'; import { initializeV3Project } from '../src/commands/v3-init.js'; import { workflow } from '../src/commands/workflow.js'; import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; @@ -78,6 +82,238 @@ afterEach(async () => { }); describe('git-ref coordination across independent clones', () => { + it('publishes a newly created git-ref task after its Git commit', async () => { + const fixture = await createFixture(); + const storeA = await strictStore(fixture.cloneA); + const [profileA, profileB] = fixture.profiles; + if (profileA === undefined || profileB === undefined) { + throw new Error('missing fixture actor profiles'); + } + await storeA.publishActorProfile({ + operationId: id(15), + expectedRemoteRevision: 0, + profile: profileA, + }); + await storeA.publishActorProfile({ + operationId: id(16), + expectedRemoteRevision: 1, + profile: profileB, + }); + await expect( + captureJson(() => teamSyncPull(fixture.cloneA, { json: true })), + ).resolves.toMatchObject({ + exitCode: 0, + value: { remoteRevision: 2 }, + }); + await rm( + path.join( + fixture.cloneA, + '.mancode', + 'shared', + 'team', + 'actors', + `${ACTOR_B}.json`, + ), + ); + + const created = await captureJson(() => + workflow( + fixture.cloneA, + 'create', + [ + 'manteam', + 'Publish a new shared task through the public sync sequence.', + ], + { + session: SESSION_A, + client: 'vitest-a', + participants: [ACTOR_B], + confirmShared: true, + json: true, + }, + ), + ); + expect(created).toMatchObject({ + exitCode: 0, + value: { metadata: { revision: 1, ownershipEpoch: 0 } }, + }); + await publishSharedActorProfile(fixture.cloneA, profileB); + const taskRef = created.value.taskRef; + const formattedTaskRef = formatTaskRef(taskRef); + + await writeFile( + path.join(fixture.cloneA, 'README.md'), + '# git-ref cross-clone fixture\n\ncode head\n\nteam task\n', + ); + const dirtyPush = await captureJson(() => + teamSyncPush(fixture.cloneA, { + task: formattedTaskRef, + expectedTaskRevision: '1', + session: SESSION_A, + client: 'vitest-a', + json: true, + }), + ); + expect(dirtyPush).toMatchObject({ + exitCode: 3, + value: { error: { code: 'MANCODE_HANDOFF_DIRTY_WORKTREE' } }, + }); + + const taskRoot = path.join( + '.mancode', + 'shared', + 'workflows', + taskRef.taskId, + ); + await git(fixture.cloneA, ['add', '--force', 'README.md', taskRoot]); + await git(fixture.cloneA, ['commit', '-m', 'publish shared task']); + const reconciled = await captureJson(() => + contextReconcileTaskHead(fixture.cloneA, formattedTaskRef, { + expectedFenceRevision: '1', + fromGit: true, + session: SESSION_A, + client: 'vitest-a', + json: true, + }), + ); + expect(reconciled.exitCode).toBe(0); + + const synced = await captureJson(() => + teamSyncPush(fixture.cloneA, { + task: formattedTaskRef, + expectedTaskRevision: '1', + session: SESSION_A, + client: 'vitest-a', + json: true, + }), + ); + expect(synced).toMatchObject({ + exitCode: 0, + value: { + changed: true, + remoteRevision: 3, + ownershipEpoch: 0, + taskRevision: 1, + }, + }); + const publishedReconcile = await captureJson(() => + contextReconcileTaskHead(fixture.cloneA, formattedTaskRef, { + expectedFenceRevision: '2', + fromGit: true, + session: SESSION_A, + client: 'vitest-a', + json: true, + }), + ); + expect(publishedReconcile).toMatchObject({ + exitCode: 3, + value: { error: { code: 'MANCODE_GIT_REF_TASK_ALREADY_PUBLISHED' } }, + }); + + await git(fixture.cloneA, ['push', 'origin', 'main']); + await git(fixture.cloneB, ['fetch', 'origin']); + await git(fixture.cloneB, ['merge', '--ff-only', 'origin/main']); + const pulled = await captureJson(() => + teamSyncPull(fixture.cloneB, { + task: formattedTaskRef, + json: true, + }), + ); + expect(pulled).toMatchObject({ + exitCode: 0, + value: { + remoteRevision: 3, + materializedBundles: [{ status: 'unchanged' }], + }, + }); + const resumed = await captureJson(() => + contextResume(fixture.cloneB, formattedTaskRef, { + session: SESSION_B, + client: 'vitest-b', + json: true, + }), + ); + expect(resumed).toMatchObject({ + exitCode: 0, + value: { taskRef, taskRevision: 1 }, + }); + + const unconfirmedClaim = await captureJson(() => + teamClaim(fixture.cloneB, { + task: formattedTaskRef, + expectedTaskRevision: '1', + paths: ['src/**'], + session: SESSION_B, + client: 'vitest-b', + sync: true, + json: true, + }), + ); + expect(unconfirmedClaim).toMatchObject({ + exitCode: 3, + value: { + error: { code: 'MANCODE_MANTEAM_PLAN_CONFIRMATION_REQUIRED' }, + }, + }); + await expect( + new V3ContextStore(fixture.cloneB).readTaskSnapshot(taskRef), + ).resolves.toMatchObject({ + metadata: { revision: 1, ownershipEpoch: 0 }, + }); + + const checkpoint = await createV3Checkpoint({ + projectRoot: fixture.cloneA, + taskRef, + sessionId: SESSION_A, + expectedTaskRevision: 1, + kind: 'handoff_offered', + summary: 'Publish a journaled handoff checkpoint after initial sync.', + nextAction: 'Pull the successor bundle in the second clone.', + checkpointId: id(17), + operationId: id(18), + now: at(3), + }); + expect( + await gitOutput(fixture.cloneA, [ + 'status', + '--porcelain=v1', + '--untracked-files=all', + ]), + ).toContain(taskRoot); + const updatedSync = await captureJson(() => + teamSyncPush(fixture.cloneA, { + task: formattedTaskRef, + expectedTaskRevision: String(checkpoint.metadata.revision), + session: SESSION_A, + client: 'vitest-a', + json: true, + }), + ); + expect(updatedSync).toMatchObject({ + exitCode: 0, + value: { + remoteRevision: 4, + taskRevision: checkpoint.metadata.revision, + ownershipEpoch: 0, + }, + }); + const updatedPull = await captureJson(() => + teamSyncPull(fixture.cloneB, { task: formattedTaskRef, json: true }), + ); + expect(updatedPull).toMatchObject({ + exitCode: 0, + value: { + remoteRevision: 4, + materializedBundles: [ + { + status: 'updated', + taskRevision: checkpoint.metadata.revision, + }, + ], + }, + }); + }, 20_000); + it('fences claims, materializes a quarantined bundle, and transfers ownership once', async () => { const fixture = await createFixture(); const created = await createRemoteTask(fixture.cloneA, fixture.codeHead); @@ -119,7 +355,7 @@ describe('git-ref coordination across independent clones', () => { }); expect(claim).toMatchObject({ remoteRevision: 2, - ownershipEpoch: 1, + ownershipEpoch: 0, claim: { state: 'active', ownerActorId: ACTOR_A }, }); @@ -267,7 +503,7 @@ describe('git-ref coordination across independent clones', () => { actorId: ACTOR_A, taskRef: created.taskRef, expectedRemoteRevision: beforeAcceptManifest.revision, - expectedOwnershipEpoch: 1, + expectedOwnershipEpoch: 0, claimId: claim.claim.claimId, expectedClaimRevision: 1, expiresAt: at(2 * 24 * 60).toISOString(), @@ -289,11 +525,11 @@ describe('git-ref coordination across independent clones', () => { expect(preparedRemoteRevision).toBe(5); expect(accepted).toMatchObject({ remoteRevision: 5, - ownershipEpoch: 2, + ownershipEpoch: 1, handoff: { state: 'accepted', revision: 3 }, taskBundle: { taskRevision: initialBundle.taskRevision + 2, - ownershipEpoch: 2, + ownershipEpoch: 1, }, forwardRepair: { remoteRevision: 5, ownerActorId: ACTOR_B }, }); @@ -303,13 +539,13 @@ describe('git-ref coordination across independent clones', () => { manifest: { revision: 5, ownershipFences: [ - { ownerActorId: ACTOR_B, ownershipEpoch: 2, remoteRevision: 5 }, + { ownerActorId: ACTOR_B, ownershipEpoch: 1, remoteRevision: 5 }, ], receipts: expect.arrayContaining([ expect.objectContaining({ operationId: id(31), remoteRevision: 5, - ownershipEpoch: 2, + ownershipEpoch: 1, }), ]), }, @@ -319,7 +555,7 @@ describe('git-ref coordination across independent clones', () => { ).resolves.toMatchObject({ metadata: { ownerActorId: ACTOR_B, - ownershipEpoch: 2, + ownershipEpoch: 1, revision: initialBundle.taskRevision + 2, transitionState: 'stable', }, @@ -460,7 +696,7 @@ describe('git-ref coordination across independent clones', () => { { taskRevision: blocked.metadata.revision, lastOperationId: id(45) }, ], taskBundles: [ - { taskRevision: blocked.metadata.revision, ownershipEpoch: 1 }, + { taskRevision: blocked.metadata.revision, ownershipEpoch: 0 }, ], }, }); @@ -922,9 +1158,9 @@ async function setGitRefConfig(projectRoot: string): Promise { ); } -async function captureJson( +async function captureJson = SyncPullJson>( action: () => Promise, -): Promise<{ exitCode: number; value: SyncPullJson }> { +): Promise<{ exitCode: number; value: T }> { const writes: string[] = []; const previous = console.log; console.log = (value: unknown) => writes.push(String(value)); @@ -932,7 +1168,7 @@ async function captureJson( const exitCode = await action(); return { exitCode, - value: JSON.parse(writes.at(-1) ?? '{}') as SyncPullJson, + value: JSON.parse(writes.at(-1) ?? '{}') as T, }; } finally { console.log = previous; @@ -945,6 +1181,15 @@ interface SyncPullJson extends Record { >; } +interface CommandJson extends Record { + error?: { code: string }; +} + +interface WorkflowCreateJson extends CommandJson { + taskRef: { namespace: 'shared'; taskId: Ulid }; + metadata: { revision: number; ownershipEpoch: number }; +} + interface WorkflowCompletionJson extends Record { metadata: { revision: number; diff --git a/tests/status.test.ts b/tests/status.test.ts index 81cf0eb..ce53417 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { init } from '../src/commands/init.js'; import { install } from '../src/commands/install.js'; import { + type ContinuityStatusResult, EXIT_CORRUPT_STATE, EXIT_NOT_INITIALIZED, EXIT_OK, @@ -175,12 +176,40 @@ describe('mancode status', () => { const logs = await captureLog(() => status(dir)); const output = logs.join('\n'); + expect(output).toContain(`mancode Continuity v${VERSION}`); expect(output).toContain('Activation: active'); expect(output).toContain('mancode adapter status:'); expect(output).not.toContain('V3'); expect(output).not.toContain('v3_active'); }); + it('--brief --json exposes only the public Continuity runtime view', async () => { + await silentInit(dir, { v3: true, platform: 'codex' }); + + const logs = await captureLog(() => + status(dir, { brief: true, json: true }), + ); + const output = logs.join('\n'); + const result: ContinuityStatusResult = JSON.parse(output); + + expect(result).toMatchObject({ + schemaVersion: 1, + runtime: 'mancode-continuity', + state: 'active', + version: VERSION, + project: path.basename(dir), + ready: true, + identity: { actorId: null, displayName: null }, + session: null, + task: null, + blockers: [], + }); + expect(result).not.toHaveProperty('authority'); + expect(result).not.toHaveProperty('activation'); + expect(output).not.toContain('"v3"'); + expect(output).not.toContain('v3_active'); + }); + 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 0cbc025..d7aa4a9 100644 --- a/tests/v3-adapter-contracts.test.ts +++ b/tests/v3-adapter-contracts.test.ts @@ -116,13 +116,16 @@ describe('V3 adapter bootstrap integration', () => { expect(bootstrap).toContain('# mancode bootstrap'); expect(bootstrap).toContain('mancode context show --purpose orient'); expect(bootstrap).toContain('--session '); - expect(bootstrap).toContain('First run `mancode status --json`'); + expect(bootstrap).toContain('mancode status --brief --json'); + expect(bootstrap).toContain( + 'snapshot already obtained in this conversation', + ); expect(bootstrap).toContain( 'In operator-facing narration, say `mancode`', ); expect(bootstrap).toContain('mancode team identity create --name'); expect(bootstrap).toContain( - '`currentTask: null` and `MANCODE_TASK_REQUIRED` do not make a session stale', + '`task: null` and `MANCODE_TASK_REQUIRED` do not make a session stale', ); expect(bootstrap).toContain( 'Do not probe workflow subcommands to work around `MANCODE_TASK_REQUIRED`', @@ -175,7 +178,10 @@ describe('V3 adapter bootstrap integration', () => { expect(entry).toContain('## Enter through mancode'); expect(entry).toContain('In operator-facing narration, say `mancode`'); expect(entry).not.toMatch(/\bV3\b/); - expect(entry).toContain('mancode status --json'); + expect(entry).toContain('mancode status --brief --json'); + expect(entry).toContain( + 'snapshot already obtained in this conversation', + ); expect(entry).not.toContain('.mancode/state.json'); if (mode !== 'manps') { const sessionCommands = Array.from( diff --git a/tests/version.test.ts b/tests/version.test.ts index a2e1ae4..05b95de 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -21,8 +21,8 @@ describe('version', () => { for (const readme of readmes) { expect(readme).toContain(`v${VERSION}`); - expect(readme).toContain(`status-V3%20beta%20v${VERSION}`); - expect(readme).not.toContain(`status-stable%20v${VERSION}`); + expect(readme).toContain(`status-Continuity%20v${VERSION}`); + expect(readme).not.toMatch(/V3%20beta/i); } }); }); diff --git a/tests/website-docs.test.ts b/tests/website-docs.test.ts index 964d95f..686ca72 100644 --- a/tests/website-docs.test.ts +++ b/tests/website-docs.test.ts @@ -9,6 +9,13 @@ async function readPage(name: string): Promise { return readFile(path.join(website, name), 'utf8'); } +async function readPackageVersion(): Promise { + const packageJson = JSON.parse( + await readFile(path.join(root, 'package.json'), 'utf8'), + ) as { version: string }; + return packageJson.version; +} + function internalAnchors(html: string): string[] { return [...html.matchAll(/href="#([^"]+)"/g)].map((match) => match[1]); } @@ -71,13 +78,13 @@ describe('website documentation', () => { ); }); - it('shows the V3 workflow command contract', async () => { + it('shows the Continuity workflow command contract', async () => { for (const name of ['docs.html', 'docs.zh-CN.html']) { const html = await readPage(name); const start = html.indexOf( name === 'docs.html' - ? '

A valid V3 sequence

' - : '

一条合法的 V3 顺序

', + ? '

A valid Continuity sequence

' + : '

一条合法的 Continuity 顺序

', ); const end = html.indexOf( name === 'docs.html' @@ -145,8 +152,9 @@ describe('website documentation', () => { expect(chinese).toContain('代码,避免'); expect(chinese).toContain('AI 屎山。'); expect(chinese).toContain('预览适配器'); - expect(english).toContain('V3 Beta / v0.3.14'); - expect(chinese).toContain('V3 Beta / v0.3.14'); + const version = await readPackageVersion(); + expect(english).toContain(`Continuity / v${version}`); + expect(chinese).toContain(`Continuity / v${version}`); }); it('documents the cross-session boundary in both languages', async () => { @@ -160,16 +168,14 @@ describe('website documentation', () => { }); it('keeps version labels aligned with package.json', async () => { - const packageJson = JSON.parse( - await readFile(path.join(root, 'package.json'), 'utf8'), - ) as { version: string }; + const version = await readPackageVersion(); for (const name of [ 'index.html', 'index.zh-CN.html', 'docs.html', 'docs.zh-CN.html', ]) { - expect(await readPage(name)).toContain(`v${packageJson.version}`); + expect(await readPage(name)).toContain(`v${version}`); } }); }); diff --git a/website/docs.html b/website/docs.html index 605447b..5b32e8d 100644 --- a/website/docs.html +++ b/website/docs.html @@ -24,7 +24,7 @@
mancode - Documentation / v0.3.14 + Documentation / v0.3.16