Plan only
StopUse when the requested deliverable is the confirmed plan, not code.
$ mancode workflow plan <local:ULID> confirm --plan-decision plan_only --expected-revision <revision> --session <session-id>unstructured
', + page + page, + page.replace('"schemaVersion":1', '"schemaVersion":2'), + page.replace('"tasks":', '"tasks":broken'), + page.replace('"taskId":"later"', '"taskId":"export"'), + ]) { + expect(() => + updateManProgressHtml(html, 'export', '进行中', null), + ).toThrow(); + } + expect(() => + updateManProgressHtml(page, 'unknown', '进行中', null), + ).toThrow(); + expect(() => + updateManProgressHtml(``, 'export', '进行中', null), + ).toThrow(); + expect(() => + updateManProgressHtml( + ``, + 'export', + '进行中', + null, + ), + ).toThrow(); + expect(() => + updateManProgressHtml( + `${page}`, + 'export', + '进行中', + null, + ), + ).toThrow(); + }); + it('degrades absent, invalid or out-of-scope pages without creating or changing them', async () => { + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toEqual({ status: 'absent' }); + const file = path.join(root, '项目进度.html'); + await writeFile(file, page); + expect( + await syncManProgressPage(root, 'export', '进行中', null, false), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(file, 'utf8')).toBe(page); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toEqual({ status: 'synced' }); + await writeFile(file, 'unstructured
'); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(file, 'utf8')).toBe('unstructured
'); + }); + it('does not follow a symlink to any other file', async () => { + await writeFile(path.join(root, 'other.html'), page); + await symlink( + path.join(root, 'other.html'), + path.join(root, '项目进度.html'), + ); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(path.join(root, 'other.html'), 'utf8')).toBe(page); + }); +}); diff --git a/tests/operation-crash-matrix-contracts.test.ts b/tests/operation-crash-matrix-contracts.test.ts index b6906df..6ad5c87 100644 --- a/tests/operation-crash-matrix-contracts.test.ts +++ b/tests/operation-crash-matrix-contracts.test.ts @@ -98,6 +98,10 @@ describe('operation crash recovery matrix', () => { it('executes safe abort or forward repair at every declared crash point', async () => { let exercised = 0; for (const definition of Object.values(OPERATION_DEFINITIONS)) { + // Reframe repair validates its semantic archive, plan, checkpoint, and + // aggregate bundle. Its real payload matrix is covered separately by + // v3-reframe-recovery-contracts rather than this synthetic plan writer. + if (definition.type === 'reframe') continue; for (const fixture of OPERATION_CRASH_FIXTURES[definition.type]) { exercised += 1; const operationId = nextId(); diff --git a/tests/operation-journal-contracts.test.ts b/tests/operation-journal-contracts.test.ts index d17f035..a6fc791 100644 --- a/tests/operation-journal-contracts.test.ts +++ b/tests/operation-journal-contracts.test.ts @@ -6,7 +6,10 @@ import { } from '../src/runtime/operation-journal.js'; const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const CHECKPOINT_A = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECKPOINT_B = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; const DIGEST = `sha256:${'a'.repeat(64)}`; +const NEXT_DIGEST = `sha256:${'b'.repeat(64)}`; describe('operation journal contract', () => { it('rejects malformed entity locks, reservations, and steps before they can become durable', () => { @@ -132,6 +135,81 @@ describe('operation journal contract', () => { }), ).toThrow(/cannot abort/); }); + + it('allows only the exact checkpoint lock and payload rebind for a repair-required reframe', () => { + const previous = parseOperationJournal({ + ...journal(), + type: 'reframe', + state: 'repair_required', + recoveryPayloadDigest: DIGEST, + entityLocks: ['task:local:01JZ', `checkpoint:${CHECKPOINT_A}`], + expectedRevisions: { + 'task:local:01JZ': 7, + [`checkpoint:${CHECKPOINT_A}`]: 0, + }, + steps: [ + { id: 'validate', state: 'completed' }, + { id: 'write', state: 'pending' }, + ], + }); + const next = parseOperationJournal({ + ...previous, + recoveryPayloadDigest: NEXT_DIGEST, + entityLocks: ['task:local:01JZ', `checkpoint:${CHECKPOINT_B}`], + expectedRevisions: { + 'task:local:01JZ': 7, + [`checkpoint:${CHECKPOINT_B}`]: 0, + }, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + const replacement = { + canAbort: false, + reframeCheckpointReplacement: { + fromCheckpointId: CHECKPOINT_A, + toCheckpointId: CHECKPOINT_B, + }, + } as const; + + expect(() => + assertOperationJournalTransition(previous, next, replacement), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(previous, next, { canAbort: false }), + ).toThrow(/identity fields are immutable/); + expect(() => + assertOperationJournalTransition( + previous, + parseOperationJournal({ + ...next, + steps: next.steps.map((step) => ({ + ...step, + state: 'completed' as const, + })), + }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + expect(() => + assertOperationJournalTransition( + previous, + parseOperationJournal({ + ...next, + expectedRevisions: { + ...next.expectedRevisions, + 'task:local:01JZ': 8, + }, + }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + expect(() => + assertOperationJournalTransition( + parseOperationJournal({ ...previous, type: 'handoff_accept' }), + parseOperationJournal({ ...next, type: 'handoff_accept' }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + }); }); function journal(): OperationJournalV1 { diff --git a/tests/operation-recovery-executor-contracts.test.ts b/tests/operation-recovery-executor-contracts.test.ts index 3fce163..5a42242 100644 --- a/tests/operation-recovery-executor-contracts.test.ts +++ b/tests/operation-recovery-executor-contracts.test.ts @@ -155,6 +155,21 @@ describe('operation recovery executor', () => { }); }); + it('rejects replacement checkpoint recovery for non-reframe operations', async () => { + const operationId = id(12); + await prepareInterruptedPlan(operationId, '# Interrupted plan\n', true); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + replacementCheckpointId: id(13), + }), + ).rejects.toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + }); + it('removes only the abandoned private workflow staging directory before a safe abort', async () => { const operationId = id(10); const recoveryTaskId = id(11); diff --git a/tests/requirements-ledger-v3-contracts.test.ts b/tests/requirements-ledger-v3-contracts.test.ts index a7dc601..68e6eb7 100644 --- a/tests/requirements-ledger-v3-contracts.test.ts +++ b/tests/requirements-ledger-v3-contracts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { type RequirementsLedgerV1, + assertManDeliveryVerificationSurfaces, parseRequirementsLedger, requirementsAreReady, requirementsLedgerDigest, @@ -90,6 +91,59 @@ describe('requirements ledger V3 contract', () => { outOfScope: ['Limit repeated login attempts.'], }); }); + + it('keeps historical criteria readable but requires explicit slot surfaces for new man delivery', () => { + const current = ledger(); + const [criterion] = current.acceptanceCriteria; + if (!criterion) throw new Error('missing fixture criterion'); + const { verificationSurfaces: _surfaces, ...historicalCriterion } = + criterion; + const historical = withDigest({ + ...current, + acceptanceCriteria: [historicalCriterion], + }); + + expect( + parseRequirementsLedger(historical).acceptanceCriteria[0] + ?.verificationSurfaces, + ).toBeUndefined(); + expect(() => + assertManDeliveryVerificationSurfaces( + parseRequirementsLedger(historical), + ), + ).toThrow('MANCODE_MAN_ACCEPTANCE_SURFACE_REQUIRED: AC-1'); + expect(() => + assertManDeliveryVerificationSurfaces(parseRequirementsLedger(current)), + ).not.toThrow(); + }); + + it('rejects invalid or verification-requirement-incompatible surfaces', () => { + const current = ledger(); + const incompatible = { + ...current, + acceptanceCriteria: current.acceptanceCriteria.map((criterion) => ({ + ...criterion, + verificationSurfaces: { automated: 'real_http' }, + })), + } as RequirementsLedgerV1; + const invalid = { + ...current, + acceptanceCriteria: current.acceptanceCriteria.map((criterion) => ({ + ...criterion, + verificationSurfaces: { + automated: 'mock_http', + manual: 'manual_observation', + }, + })), + } as unknown as RequirementsLedgerV1; + + expect(() => parseRequirementsLedger(withDigest(incompatible))).toThrow( + /verificationSurfaces must match verificationRequirement slots/, + ); + expect(() => parseRequirementsLedger(withDigest(invalid))).toThrow( + 'MANCODE_MAN_VERIFICATION_SURFACE_INVALID', + ); + }); }); function ledger(): RequirementsLedgerV1 { @@ -150,6 +204,10 @@ function ledger(): RequirementsLedgerV1 { statement: 'A repeated failed login receives a rate-limit response.', required: true, verificationRequirement: 'hybrid', + verificationSurfaces: { + automated: 'real_http', + manual: 'manual_observation', + }, }, ], blockingUnknowns: [], diff --git a/tests/requirements-ledger.test.ts b/tests/requirements-ledger.test.ts index 9e385c8..8160091 100644 --- a/tests/requirements-ledger.test.ts +++ b/tests/requirements-ledger.test.ts @@ -24,6 +24,7 @@ describe('requirements ledger', () => { description: 'Pointer lock movement works', required: true, method: 'manual', + verificationSurfaces: { manual: 'browser' }, }, ], }), @@ -32,6 +33,10 @@ describe('requirements ledger', () => { expect(requirementsAreReady(ledger)).toBe(true); expect(renderRequirementsMarkdown(ledger)).toContain('READY'); expect(renderRequirementsMarkdown(ledger)).toContain('AC-1'); + expect(renderRequirementsMarkdown(ledger)).toContain('manual=browser'); + expect(ledger.acceptanceCriteria[0]?.verificationSurfaces).toEqual({ + manual: 'browser', + }); }); it('rejects duplicate ids and manifests with no required acceptance', () => { @@ -108,6 +113,41 @@ describe('requirements ledger', () => { ).toThrow(/coverage is missing/); }); + it('rejects invalid or method-incompatible verification surfaces', () => { + const base = { + version: 1, + goal: 'Build it', + confirmedScope: ['Confirmed first release'], + excludedScope: [], + technicalDecisions: ['Use the existing stack'], + defaults: [], + blockingUnknowns: [], + coverage: completeCoverage(), + }; + const parseWithSurfaces = (verificationSurfaces: unknown) => + parseRequirementsLedger( + JSON.stringify({ + ...base, + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'The confirmed behavior works', + required: true, + method: 'manual', + verificationSurfaces, + }, + ], + }), + ); + + expect(() => parseWithSurfaces({ automated: 'component' })).toThrow( + /invalid acceptance verificationSurfaces/, + ); + expect(() => parseWithSurfaces({ manual: 'mock_http' })).toThrow( + /invalid acceptance verification surface/, + ); + }); + it('reads old contradictory scope but rejects it as a new confirmation', () => { const ledger = parseRequirementsLedger( JSON.stringify({ diff --git a/tests/retention-contracts.test.ts b/tests/retention-contracts.test.ts index df14346..dc0731b 100644 --- a/tests/retention-contracts.test.ts +++ b/tests/retention-contracts.test.ts @@ -14,6 +14,10 @@ import { operationDirectory, resolveLocalEntityHomeStore, } from '../src/runtime/entity-home-store.js'; +import { + operationRecoveryPayloadPath, + operationRecoveryPayloadVersionPath, +} from '../src/runtime/operation-recovery-store.js'; import { readOperationJournal } from '../src/runtime/operation-store.js'; import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; import { @@ -192,6 +196,123 @@ describe('V3 retention and compaction', () => { }); }); + it('deletes every recovery payload version with an aged terminal operation', async () => { + const runtime = await readProjectRuntimeContext(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const operationId = id(7); + const journal = await readOperationJournal(localStore, operationId); + if (journal?.recoveryPayloadDigest === undefined) { + throw new Error('missing terminal operation recovery payload'); + } + const journalTarget = path.join( + operationDirectory(localStore), + `${operationId}.json`, + ); + await writeFile( + journalTarget, + `${JSON.stringify( + { + ...journal, + startedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T01:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + const canonicalPayload = operationRecoveryPayloadPath( + localStore, + operationId, + ); + const boundVersion = operationRecoveryPayloadVersionPath( + localStore, + operationId, + journal.recoveryPayloadDigest, + ); + const orphanVersion = operationRecoveryPayloadVersionPath( + localStore, + operationId, + `sha256:${'f'.repeat(64)}`, + ); + await Promise.all([ + writeFile(boundVersion, '{}\n'), + writeFile(orphanVersion, '{}\n'), + ]); + + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + const candidate = plan.candidates.find( + (entry) => entry.target === journalTarget, + ); + expect(candidate?.relatedTargets).toEqual( + expect.arrayContaining([canonicalPayload, boundVersion, orphanVersion]), + ); + + const applied = await applyContextCompaction(plan); + expect(applied.deleted).toEqual( + expect.arrayContaining([ + journalTarget, + canonicalPayload, + boundVersion, + orphanVersion, + ]), + ); + await Promise.all( + [journalTarget, canonicalPayload, boundVersion, orphanVersion].map( + (target) => expect(readFile(target, 'utf8')).rejects.toThrow(), + ), + ); + }); + + it('keeps the operation journal when recovery payload deletion is interrupted', async () => { + const runtime = await readProjectRuntimeContext(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const operationId = id(7); + const journal = await readOperationJournal(localStore, operationId); + if (journal?.recoveryPayloadDigest === undefined) { + throw new Error('missing terminal operation recovery payload'); + } + const journalTarget = path.join( + operationDirectory(localStore), + `${operationId}.json`, + ); + await writeFile( + journalTarget, + `${JSON.stringify( + { + ...journal, + startedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T01:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + const candidate = plan.candidates.find( + (entry) => entry.target === journalTarget, + ); + const failingTarget = candidate?.relatedTargets[0]; + if (candidate === undefined || failingTarget === undefined) { + throw new Error('missing operation recovery retention target'); + } + await rm(failingTarget, { force: true }); + await mkdir(failingTarget); + + await expect( + applyContextCompaction({ + ...plan, + candidates: [candidate], + }), + ).rejects.toThrow('MANCODE_RETENTION_PATH_UNSAFE'); + await expect(readFile(journalTarget, 'utf8')).resolves.toContain( + operationId, + ); + }); + it('keeps a repair-required journal and the session and task artifacts it protects', async () => { const taskRef = { namespace: 'local' as const, taskId }; const checkpointPaths = await completeTaskWithDiagnosticCheckpoints( diff --git a/tests/skills.test.ts b/tests/skills.test.ts index 8706ac2..01e0b7c 100644 --- a/tests/skills.test.ts +++ b/tests/skills.test.ts @@ -89,6 +89,13 @@ describe('mvp-2 skills', () => { expect(MAN_SKILL.body).toMatch(/明确排除.*excludedScope/); expect(MAN_SKILL.body).toMatch(/未接受.*自动塞入.*excludedScope/); expect(MAN_SKILL.body).toMatch(/implementationScope/); + expect(MAN_SKILL.body).toContain('document-bound delivery'); + expect(MAN_SKILL.body).toContain('review_incomplete'); + expect(MAN_SKILL.body).toContain('observation surface'); + expect(MAN_SKILL.body).toContain('verificationSurfaces'); + expect(MAN_SKILL.body).toContain('自述'); + expect(MAN_SKILL.body).toContain('finalization blockers'); + expect(MAN_SKILL.body).toContain('repo-relative path 或 glob'); }); it('keeps solo review bounded and lightweight', () => { diff --git a/tests/v3-adapter-contracts.test.ts b/tests/v3-adapter-contracts.test.ts index d82d30c..6bea061 100644 --- a/tests/v3-adapter-contracts.test.ts +++ b/tests/v3-adapter-contracts.test.ts @@ -208,6 +208,22 @@ describe('V3 adapter bootstrap integration', () => { expect(bootstrap).toContain( 'hard-risk change involving authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure', ); + if (['AGENTS.md', 'CLAUDE.md'].includes(path.basename(target))) { + expect(bootstrap).toContain( + '仅用于显式启用模块交付策略的新 `/man` 任务', + ); + expect(bootstrap).toContain('项目指定的计划基线目录,默认 `doc/`'); + expect(bootstrap).toContain('已有 `docs/` 等明确约定时沿用它'); + expect(bootstrap).toContain('mancode-progress-data'); + expect(bootstrap).toContain('被忽略不代表本地不可读'); + expect(bootstrap).toContain('交付未发布'); + expect(bootstrap).toContain('优先定位并修复根因,避免治标不治本'); + expect(bootstrap).toContain( + '完整性校验、缓存键、证据适用性和发布溯源仍可使用哈希', + ); + expect(bootstrap).not.toContain('扩大到十几行'); + } else + expect(bootstrap).not.toContain('project:documentation-handoff-policy'); expect(bootstrap).toContain( 'explicitly asking for research, a plan, architecture, migration design, or formal acceptance authorizes the `man` planning path', ); @@ -255,6 +271,16 @@ describe('V3 adapter bootstrap integration', () => { if (platform === 'claude-code') { expect(installed.target).toBe('CLAUDE.md'); expect(bootstrap).toContain('mancode:continuity:claude:start'); + expect(bootstrap).toContain( + '', + ); + } + if (platform === 'codex') { + expect(installed.target).toBe('AGENTS.md'); + expect(bootstrap).toContain('mancode:continuity:codex:start'); + expect(bootstrap).toContain( + '', + ); } if (platform === 'dsh') { expect(installed.target).toBe('AGENTS.md'); @@ -329,6 +355,7 @@ describe('V3 adapter bootstrap integration', () => { expect(entry).toContain('"rationale": "..."'); expect(entry).toContain('acceptanceCriteria'); expect(entry).toContain('"method": "automated"'); + expect(entry).toContain('"verificationSurfaces"'); expect(entry).toContain( "clears this session's active workflow pointer", ); @@ -427,6 +454,12 @@ describe('V3 adapter bootstrap integration', () => { 'upgraded, already-running local `man` task has no executable implementation scope', ); expect(entry).toContain('exact unchanged current plan'); + expect(entry).toContain('repo-relative path or glob'); + expect(entry).toContain('"surface": "real_http"'); + expect(entry).toContain('self-declared'); + expect(entry).toContain('uncommitted outside-scope'); + expect(entry).toContain('exit code 0'); + expect(entry).toContain('review_incomplete'); } if (mode === 'manteam') { expect(entry).toContain( @@ -592,7 +625,7 @@ describe('V3 adapter bootstrap integration', () => { const man = await readFile(v3ModeEntryPath(root, 'codex', 'man'), 'utf8'); expect(man).toContain( - 'revise --expected-revisionmancode workflow review ·
mancode workflow verify ·
mancode workflow complete ·
+ mancode workflow delivery ·
mancode workflow scope ·
mancode workflow reframe ·
mancode workflow archive ·
@@ -394,6 +395,8 @@ Use when the requested deliverable is the confirmed plan, not code.
$ mancode workflow plan <local:ULID> confirm --plan-decision plan_only --expected-revision <revision> --session <session-id>All three choices require an active man workflow, confirmed requirements, and plan.md. Revise the plan with workflow plan ... revise; every successful mutation returns the revision required by the next command.
Newly generated man entries create tasks with --delivery (planning policy 3). This opt-in binds an approved baseline and a separate delivery record in one versionable plan, using the project's directory convention or doc/ by default. Other modes, older tasks, and Solo handoffs keep their existing contracts.
workflow delivery <TaskRef> inspect|verify|confirm|review|sync|check|publication supports one module-level review, real command evidence, manual confirmation, document writeback, and separate commit/publication checks. Mutating actions require the active session and latest expected revision. A relevant command can cover multiple criteria through --acceptance AC-1,AC-2 without repeated runs. Review checks goal coverage, concrete defects, and unnecessary complexity; zero findings is valid. Missing upstreams or failed pushes mean unpublished delivery, not a business blocker. This mechanism does not certify an agent's semantic judgment or production readiness.
Continuity uses explicit TaskRefs and expected revisions. The CLI owns every durable mutation; do not edit metadata files or use the legacy --step protocol.
mancode workflow review ·
mancode workflow verify ·
mancode workflow complete ·
+ mancode workflow delivery ·
mancode workflow scope ·
mancode workflow reframe ·
mancode workflow archive ·
@@ -394,6 +395,8 @@ 用户需要的交付物是计划,而不是代码。
$ mancode workflow plan <local:ULID> confirm --plan-decision plan_only --expected-revision <revision> --session <session-id>三种选择都要求活动中的 man 工作流、已确认需求和 plan.md。使用 workflow plan ... revise 修订计划;每次成功写入都会返回下一个命令需要的 revision。
新生成的 man 入口通过 --delivery 显式启用 planning policy 3。它在同一份可版本化计划中区分批准基线与交付记录,沿用项目计划目录约定,新项目默认 doc/。其他模式、旧任务和 Solo handoff 保持原契约。
workflow delivery <TaskRef> inspect|verify|confirm|review|sync|check|publication 支持一次模块总审、实际命令证据、人工确认、文档回写,以及提交与发布分离检查。写操作需要活动 session 和最新 expected revision。一个命令确实覆盖多个验收项时,可用 --acceptance AC-1,AC-2 一次登记,不重复运行。总审检查目标兑现、具体缺陷与不必要的复杂度,允许零 finding。无上游或推送失败是交付未发布,不是业务阻塞;这些机制不等于已经证明 agent 的语义判断或生产适用性。
Continuity 使用显式 TaskRef 和 expected revision。所有持久化变更都由 CLI 完成;不要手工编辑 metadata,也不要使用 legacy --step 协议。