From 80cae8d500908f2e2dd1771fb58df069f4abf901 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Sun, 25 Oct 2026 09:20:13 +0800 Subject: [PATCH] feat(workflow): require plan presentation before approval on planning 4 In real /man runs, agents self-recorded `plan confirm --plan-decision governed_execution` from an earlier generic "go with your recommendations" without ever showing the plan. Add a CLI gate for new tasks (planning policy 4); planning 1-3 tasks keep their confirm semantics and metadata bytes. - `workflow plan present`: journalled `plan_present` operation bound to (planVersion, plan digest, scope digest, requirementsDigest); prints a plan summary and "Show this plan to the operator and wait for their reply before running plan confirm." Idempotent for an unchanged tuple, owner-only, validates the bound plan file before writing. - `plan confirm` on planning 4 needs a current, unconsumed presentation plus exactly one of `--operator-reply` or `--unattended-waiver`, and records `planApproval.approvalSource`. - Close bypasses: `revise --plan-decision`, solo handoff start, plan_only -> governed_execution resumption, and confirm after reframe all go through the gate. The metadata parser rejects a planning-4 decision without an approval record, covering non-CLI writers. - Project config `unattendedWaiver: allowed|forbidden` (absent: allowed for local single tasks, forbidden for manteam/shared). Waivers and approvals whose scope changed later are flagged in delivery inspect/check, the context index and the progress page. - Reply reuse is checked against human-authored task text only, after normalising case, whitespace and trailing punctuation. The gate cannot prove a reply came from the operator; it guarantees the plan is presented before approval, stale presentations are rejected, and the approval basis is auditable. Docs say so explicitly. New man/manteam tasks use planning 4; older CLIs fail closed on them with MANCODE_POLICY_VERSION_UNSUPPORTED, so teams must upgrade together. --- README.en.md | 3 +- README.md | 3 +- docs/platform-adapters.md | 4 + docs/workflows.md | 23 +- src/cli.ts | 8 + src/commands/man-delivery.ts | 17 + src/commands/workflow.ts | 119 ++- src/context/context-index.ts | 18 +- src/context/man-delivery-runtime.ts | 13 +- src/context/plan-gate.ts | 339 ++++++ src/context/plan-present.ts | 256 +++++ src/context/plan-revision.ts | 56 + src/context/project-progress.ts | 6 + src/context/reframe.ts | 3 + src/context/requirements-finalize.ts | 4 +- src/context/resolver.ts | 7 + src/context/solo-handoff.ts | 39 + src/context/workflow-create.ts | 28 +- src/context/workflow-metadata.ts | 231 ++++- src/installers/v3-adapter.ts | 17 +- src/runtime/operation-definition.ts | 7 + src/runtime/operation-journal.ts | 2 + src/team/policy.ts | 18 + src/templates/project-progress.ts | 2 +- src/templates/skills/man.ts | 2 +- tests/context-index-acceptance.test.ts | 2 +- tests/context-resolver-contracts.test.ts | 2 +- tests/execution.test.ts | 2 +- tests/fixtures/guidance-budget.json | 48 +- tests/helpers/execution-fixture.ts | 2 +- tests/helpers/manteam-plan.ts | 8 +- tests/helpers/plan-gate.ts | 148 +++ tests/man-delivery.test.ts | 8 +- tests/operation-definition-contracts.test.ts | 2 +- tests/plan-gate-contracts.test.ts | 962 ++++++++++++++++++ tests/plan-revision.test.ts | 111 +- ...rojection-eventual-consistency-e2e.test.ts | 2 +- tests/v3-adapter-contracts.test.ts | 7 +- tests/v3-claim-acquisition-contracts.test.ts | 4 +- tests/v3-claim-lifecycle-contracts.test.ts | 16 +- tests/v3-handoff-draft-contracts.test.ts | 22 +- tests/v3-reframe-contracts.test.ts | 2 +- ...v3-requirements-finalize-contracts.test.ts | 12 +- tests/v3-scope-change-contracts.test.ts | 18 +- tests/v3-solo-handoff-contracts.test.ts | 2 +- tests/v3-workflow-create-contracts.test.ts | 2 +- tests/v3-worktree-e2e.test.ts | 4 +- tests/workflow-metadata-contracts.test.ts | 4 +- 48 files changed, 2455 insertions(+), 160 deletions(-) create mode 100644 src/context/plan-gate.ts create mode 100644 src/context/plan-present.ts create mode 100644 tests/helpers/plan-gate.ts create mode 100644 tests/plan-gate-contracts.test.ts diff --git a/README.en.md b/README.en.md index 9091869..21038d8 100644 --- a/README.en.md +++ b/README.en.md @@ -857,7 +857,8 @@ mancode workflow show --json mancode context resume --session mancode workflow requirements finalize --file --expected-revision --session mancode workflow plan revise --file --scope-file --expected-revision --session -mancode workflow plan confirm --plan-decision --expected-revision --session +mancode workflow plan present --expected-revision --session # planning policy 4: show the plan to the operator, wait for their reply +mancode workflow plan confirm --plan-decision [--operator-reply "" | --unattended-waiver] --expected-revision --session mancode workflow scope change --file --expected-revision --session mancode workflow update --status --expected-revision --session mancode workflow review apply --file --expected-revision --session diff --git a/README.md b/README.md index beda0d6..52f484f 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,8 @@ mancode workflow show --json mancode context resume --session mancode workflow requirements finalize --file --expected-revision --session mancode workflow plan revise --file --scope-file --expected-revision --session -mancode workflow plan confirm --plan-decision --expected-revision --session +mancode workflow plan present --expected-revision --session # planning policy 4: show the plan to the operator, wait for their reply +mancode workflow plan confirm --plan-decision [--operator-reply "" | --unattended-waiver] --expected-revision --session mancode workflow scope change --file --expected-revision --session mancode workflow update --status --expected-revision --session mancode workflow review apply --file --expected-revision --session diff --git a/docs/platform-adapters.md b/docs/platform-adapters.md index ac06711..49e1ec3 100644 --- a/docs/platform-adapters.md +++ b/docs/platform-adapters.md @@ -31,6 +31,10 @@ DeepSeek Harness 的 mode entry 只安装到 `.dsh/skills/`,不复用 每个入口都标记为仅用户调用;在 host session 传播得到真实证据前,Continuity mutation 继续要求显式 `--session`,不会把环境中的 `DSH_SESSION_ID` 自动升级为可信身份。 +## 计划审批门禁与模式入口 + +man、manteam 和 mansolo 的模式入口描述 planning policy 4 的审批流程:先 `workflow plan present`,把计划展示给操作者并等待回复,再用 `confirm ... --operator-reply "<原话>"` 确认;Solo handoff 与 `plan_only` 续接同样需要 present 和回复。`--unattended-waiver` 只在本会话确实没有操作者(headless、CI)时使用,项目配置 `unattendedWaiver: allowed | forbidden` 决定是否允许(缺省:本地单人任务允许,manteam 与 shared 任务禁用)。入口文字不能替代 CLI 校验:门禁无法证明回复真的来自操作者,只能保证计划先完整展示、批准与计划版本绑定并留档,进度页会对 `unattended_waiver` 批准和“批准后范围变化”显示提示,详见 [workflows.md](workflows.md)。planning 1–3 的旧任务不受影响。升级入口后需要执行一次 `adapter upgrade` 以刷新受管文本。 + ## 简化更新入口(开发分支,尚未发布) `mancode upgrade` 提供数字菜单,统一编排 CLI 包更新与当前项目的 managed adapter 更新。 diff --git a/docs/workflows.md b/docs/workflows.md index b09ceb2..6da6431 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -102,9 +102,24 @@ policy 3 的每个必需验收项还要按证据槽位声明精确的期望观 mancode workflow create man "导出模块" --delivery --session --client --json # 按已有 requirements 协议完成澄清和 finalize 后: mancode workflow plan revise --file docs/export.md --scope-file scope.json --expected-revision --session --client --json -mancode workflow plan confirm --plan-decision governed_execution --expected-revision --session --client --json +mancode workflow plan present --expected-revision --session --client --json +# 把 present 输出的计划摘要展示给操作者,等待其回复,再确认: +mancode workflow plan confirm --plan-decision governed_execution --operator-reply "<操作者对该计划的回复>" --expected-revision --session --client --json ``` +#### 计划审批门禁(planning policy 4) + +`--delivery` 创建的新 man 任务和新建的 manteam 任务使用 planning policy 4。这类任务的 plan decision 必须先 present 再 confirm: + +- `plan present`:写入 journal 的变更(revision 加一)。记录一条 present,绑定当前 planVersion、计划 digest、实施范围 digest 和 requirements digest,并输出计划摘要(范围、验收标准、计划文件)和一句"展示给操作者并等待回复后再 confirm"。同一组内容重复 present 是幂等的:返回已有记录,不产生新 revision。只能用于 Step 4 尚未决定的任务,或 `plan_only` 续接前。 +- `plan confirm`:必须有与当前计划、范围、需求一致的 present;在 confirm 之前,任何 revise、范围或需求变化都会使它失效(`MANCODE_PLAN_PRESENTATION_STALE`)。同时必须且只能提供 `--operator-reply "<原话>"`(去空白后非空、至多 2000 字符;shared 任务还要通过隐私筛查)或 `--unattended-waiver`。回复不得与任务里人写的文字重复:只比较 goal、范围条目(inScope/outOfScope)、requirements 与验收标准的描述、技术决定与默认值的描述,以及已确认决定的标题和说明;枚举值、ID、digest 不参与比较,也不与上一次审批的回复比较(`plan_only` 之后续接可以再次回复“Approved.”)。比较前统一大小写、空白和结尾标点,所以 "." 也算重复(`MANCODE_PLAN_OPERATOR_REPLY_REUSED`)。每次 present 只能支撑一次批准,重复使用报 `MANCODE_PLAN_PRESENTATION_CONSUMED`。present 之前会校验已绑定的计划文档,文档漂移时直接失败且不写入。 +- 审批记录写入 `governance.planApproval`,含 `approvalSource`(`operator_reply` 或 `unattended_waiver`)、回复原文、presentedAt、confirmedAt 及绑定的版本和 digest。`delivery inspect`、`delivery check`、交付记录、context index 和进度页都会显示审批信息;`unattended_waiver` 会被醒目标出(`UNATTENDED_WAIVER`)。批准记录不会被事后失效:它只记录当时批准的各项 digest。批准之后如果实施范围变化(例如 manteam 的 `workflow scope change`),inspect/check、交付记录、context index 和进度页会提示 `PLAN_APPROVAL_SCOPE_CHANGED`;本轮没有给 scope change 增加新的审批门禁。 +- 堵上的绕过路径:`plan revise` 不能再带 `--plan-decision`(`MANCODE_PLAN_DECISION_ON_REVISE_REJECTED`);`handoff --to solo` 和 `plan_only` 转 `governed_execution` 需要同样的 present 与回复或豁免;reframe 会清空 decision、present 和审批记录,之后重新走一遍。 +- 团队任务:manteam 和 shared 任务只有任务 owner 可以 present 和 confirm(沿用 `shared_metadata_plan_mutation` 授权);present 先检查 owner,即使已有相同的 present 记录,参与者也会得到 `MANCODE_TASK_OWNER_REQUIRED`。 +- 无人值守豁免:`--unattended-waiver` 只能在本会话确实没有操作者时(如 headless、CI)使用,绝不能替代向在场操作者提问,也不要为了通过门禁编造回复。项目配置 `.mancode/shared/config.json` 的 `unattendedWaiver: "allowed" | "forbidden"` 控制是否允许:缺省时本地单人任务允许、manteam 和 shared 任务禁用;显式 `allowed` 可以对 shared 项目放开,显式 `forbidden` 可以对本地项目禁用。禁用时报 `MANCODE_UNATTENDED_WAIVER_FORBIDDEN`。模型自己也能改这个配置,所以它只能挡住"顺手使用",事后靠 `unattended_waiver` 标记发现。同类限制:手工把本地任务 metadata 的 planning 从 4 改成 3 就能绕过门禁;`plan_only` 续接批准时会覆盖第一次的审批记录(只保留最新一条)。 +- 兼容:planning 1–3 的任务保持原语义,不需要 present,`--operator-reply` / `--unattended-waiver` 会被拒绝(`MANCODE_PLAN_GATE_NOT_ENABLED`),`revise` 仍可带 `--plan-decision`;旧元数据逐字节保持不变。旧版 CLI 读取 policy 4 任务会报 `MANCODE_POLICY_VERSION_UNSUPPORTED`,团队成员需要同步升级。 +- 门禁能证明什么、不能证明什么:它不能证明回复确实来自操作者。present 与 confirm 只是两次 CLI 调用,模型可以自己 present 后立刻填一句"操作者原话"。它做到的是:计划必须先完整展示;计划、范围、需求一变,展示和审批都作废;审批依据(或无人值守豁免)与计划版本绑定留档,便于事后审核;并去掉了一次调用即可自批的几条路径。 + 每步使用上一结果的新 revision。`scope.json` 的 include 要覆盖计划文件及获授权的进度页面;exclude 仍优先。无 Git 仍能规划和绑定文档,但不能声称版本化交付完成。 #### 验证与总审 @@ -226,10 +241,14 @@ mancode workflow requirements local: finalize \ mancode workflow plan local: revise \ --file plan.md --scope-file scope.json \ --expected-revision 2 --session +mancode workflow plan local: present --expected-revision 3 --session mancode workflow plan local: confirm \ - --plan-decision governed_execution --expected-revision 3 --session + --plan-decision governed_execution --operator-reply "<操作者回复>" \ + --expected-revision 4 --session ``` +以上 `present` 与 `--operator-reply` 适用于 planning policy 4 的任务(见上文“计划审批门禁”);planning 1–3 的任务直接 `confirm`,不带这两项。 + `plan revise` 必须通过 `--file ` 读取 Markdown 计划。准备执行时同时通过 `--scope-file ` 绑定 `{ include, exclude, modules }`;`include` 是非空的 repo-relative 文件边界,`exclude` 优先,`modules` 不单独授权写文件。修订与确认是两个 diff --git a/src/cli.ts b/src/cli.ts index 3eb203f..495de91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -372,6 +372,14 @@ export function createCliProgram(): Command { '--plan-decision ', 'Plan gate choice: plan_only or governed_execution', ) + .option( + '--operator-reply ', + 'Operator reply to the presented plan (plan confirm or solo handoff on planning policy 4)', + ) + .option( + '--unattended-waiver', + 'Approve a presented plan with no operator present (recorded as unattended_waiver)', + ) .option('--to ', 'Workflow handoff target (solo)') .option('--complete', 'Complete an active solo handoff') .option( diff --git a/src/commands/man-delivery.ts b/src/commands/man-delivery.ts index 0d700f7..45d60e7 100644 --- a/src/commands/man-delivery.ts +++ b/src/commands/man-delivery.ts @@ -18,6 +18,10 @@ import { manInspectionFailureFinalization, syncManDeliveryRecord, } from '../context/man-delivery-runtime.js'; +import { + planApprovalSummary, + planApprovalWarnings, +} from '../context/plan-gate.js'; import { type ReviewLedgerV1, deriveReviewLedgerStatus, @@ -34,6 +38,7 @@ import { verificationLedgerDigest, } from '../context/verification-ledger.js'; import { recordV3Verification } from '../context/verification-record.js'; +import type { WorkflowMetadataV3 } from '../context/workflow-metadata.js'; import { assertSoloHandoffSession, isActiveSoloHandoff, @@ -99,6 +104,7 @@ export async function manDeliveryCommand( if (action === 'inspect') return printV3Result(options.json, { ...(await inspectManDelivery(project.projectRoot, task)), + ...planApprovalField(task.metadata), acceptanceCriteria: task.requirements.acceptanceCriteria, review: task.review, verification: task.verification, @@ -130,6 +136,7 @@ export async function manDeliveryCommand( ); return printV3Result(options.json, { delivery: 'ready_for_complete', + ...planApprovalField(task.metadata), publication: 'not_checked', note: 'complete rechecks operation authority; upstream publication requires an actual push result', }); @@ -524,3 +531,13 @@ function reviewInput( const current = { ...draft, status: deriveReviewLedgerStatus(draft) }; return { ...current, contentDigest: reviewLedgerDigest(current) }; } + +function planApprovalField(metadata: WorkflowMetadataV3) { + const planApproval = planApprovalSummary(metadata); + if (planApproval === undefined) return {}; + const warnings = planApprovalWarnings(metadata); + return { + planApproval, + ...(warnings.length === 0 ? {} : { planApprovalWarnings: warnings }), + }; +} diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 9376bb1..4618d65 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -5,6 +5,7 @@ import { parseExecutionPolicy } from '../context/execution-ledger.js'; import { type Ulid, assertUlid } from '../context/ids.js'; import { isManDelivery } from '../context/man-delivery-runtime.js'; import { parseSchemaManifest } from '../context/manifest.js'; +import { presentV3Plan } from '../context/plan-present.js'; import { reviseV3Plan } from '../context/plan-revision.js'; import { previewV3TaskPromotion, @@ -29,7 +30,10 @@ import { } 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 { + type WorkflowMetadataV3, + isPlanGated, +} from '../context/workflow-metadata.js'; import { updateV3Workflow } from '../context/workflow-update.js'; import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; import { @@ -151,6 +155,8 @@ export interface WorkflowOptions { resolved?: string; requirementsStatus?: string; planDecision?: string; + operatorReply?: string; + unattendedWaiver?: boolean; to?: string; complete?: boolean; file?: string; @@ -734,7 +740,7 @@ async function workflowSoloHandoffV3( return printV3Error( options.json, 'MANCODE_SOLO_HANDOFF_ARGUMENT_INVALID', - 'Use: workflow handoff --to solo --expected-revision , or workflow handoff --complete --expected-revision .', + 'Use: workflow handoff --to solo --expected-revision [--operator-reply "" | --unattended-waiver], or workflow handoff --complete --expected-revision .', EXIT_INVALID_ARG, ); } @@ -746,6 +752,17 @@ async function workflowSoloHandoffV3( EXIT_INVALID_ARG, ); } + if ( + options.complete === true && + (options.operatorReply !== undefined || options.unattendedWaiver === true) + ) { + return printV3Error( + options.json, + 'MANCODE_SOLO_HANDOFF_ARGUMENT_INVALID', + '--operator-reply and --unattended-waiver apply only to --to solo.', + EXIT_INVALID_ARG, + ); + } const expectedTaskRevision = parseExpectedTaskRevision(options); if (expectedTaskRevision === null) { return printV3Error( @@ -771,6 +788,8 @@ async function workflowSoloHandoffV3( taskRef: parseTaskRef(task), sessionId: session.sessionId, expectedTaskRevision, + operatorReply: options.operatorReply, + unattendedWaiver: options.unattendedWaiver, }); return printV3Result(options.json, { schemaVersion: 1, @@ -1267,11 +1286,14 @@ async function workflowPlanV3( ): Promise { const task = args[0]; const action = args[1]; - if (!task || (action !== 'revise' && action !== 'confirm')) { + if ( + !task || + (action !== 'revise' && action !== 'confirm' && action !== 'present') + ) { return printV3Error( options.json, 'MANCODE_PLAN_ARGUMENT_INVALID', - 'Use: workflow plan revise --expected-revision --file [--scope-file ], or confirm --expected-revision --plan-decision .', + 'Use: workflow plan revise --expected-revision --file [--scope-file ], present --expected-revision , or confirm --expected-revision --plan-decision [--operator-reply "" | --unattended-waiver].', EXIT_INVALID_ARG, ); } @@ -1284,6 +1306,9 @@ async function workflowPlanV3( EXIT_INVALID_ARG, ); } + if (action === 'present') { + return workflowPlanPresentV3(rootDir, task, expectedTaskRevision, options); + } if (action === 'revise' && !options.file) { return printV3Error( options.json, @@ -1308,6 +1333,17 @@ async function workflowPlanV3( EXIT_INVALID_ARG, ); } + if ( + action === 'revise' && + (options.operatorReply !== undefined || options.unattendedWaiver === true) + ) { + return printV3Error( + options.json, + 'MANCODE_PLAN_APPROVAL_INPUT_UNEXPECTED', + '--operator-reply and --unattended-waiver apply only to workflow plan confirm.', + EXIT_INVALID_ARG, + ); + } try { const project = await readV3CommandProject(rootDir); const taskRef = parseTaskRef(task); @@ -1317,13 +1353,23 @@ async function workflowPlanV3( options.sync, ); const session = await resolveV3CommandSession(project, options); + const current = await project.store.readTaskSnapshot(taskRef); + if ( + action === 'revise' && + options.planDecision !== undefined && + isPlanGated(current.metadata) + ) { + throw new Error( + 'MANCODE_PLAN_DECISION_ON_REVISE_REJECTED: plan revise cannot record a plan decision under planning policy 4; revise, run workflow plan present, then confirm with the operator reply', + ); + } const plan = action === 'revise' ? await readWorkflowInputFile( project.projectRoot, options.file as string, ) - : (await project.store.readTaskSnapshot(taskRef)).plan?.content; + : current.plan?.content; if (plan === undefined || plan === null) { throw new Error('MANCODE_PLAN_FILE_REQUIRED'); } @@ -1341,12 +1387,13 @@ async function workflowPlanV3( expectedTaskRevision, plan, planSource: - action === 'revise' && - isManDelivery((await project.store.readTaskSnapshot(taskRef)).metadata) + action === 'revise' && isManDelivery(current.metadata) ? options.file : undefined, implementationScope, planDecision: parseV3PlanDecision(options.planDecision), + operatorReply: options.operatorReply, + unattendedWaiver: options.unattendedWaiver, }); return printV3Result(options.json, { schemaVersion: 1, @@ -1368,6 +1415,64 @@ async function workflowPlanV3( } } +async function workflowPlanPresentV3( + rootDir: string, + task: string, + expectedTaskRevision: number, + options: WorkflowOptions, +): Promise { + if ( + options.planDecision !== undefined || + options.file !== undefined || + options.scopeFile !== undefined || + options.operatorReply !== undefined || + options.unattendedWaiver === true + ) { + return printV3Error( + options.json, + 'MANCODE_PLAN_ARGUMENT_INVALID', + 'workflow plan present takes only --expected-revision, --session and --client.', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(task); + assertDeferredGitRefSyncOption( + project.project.config.transport.mode, + taskRef.namespace === 'shared', + options.sync, + ); + const session = await resolveV3CommandSession(project, options); + const result = await presentV3Plan({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + revision: result.metadata.revision, + idempotent: result.idempotent, + presentation: result.presentation, + summary: result.summary, + instruction: result.instruction, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_PLAN_PRESENT_FAILED'), + error instanceof Error + ? error.message + : 'Unable to present the mancode plan.', + ); + } +} + async function workflowRequirementsV3( rootDir: string, args: string[], diff --git a/src/context/context-index.ts b/src/context/context-index.ts index 4d1c1f5..e38b66a 100644 --- a/src/context/context-index.ts +++ b/src/context/context-index.ts @@ -20,7 +20,8 @@ import { projectDecisions, } from './decision-record.js'; import { scanLegacyAuthority } from './layout.js'; -import { readBoundManPlan } from './man-delivery-runtime.js'; +import { isManDelivery, readBoundManPlan } from './man-delivery-runtime.js'; +import { planApprovalWarningCodes } from './plan-gate.js'; import { assertPrivacyValueAllowed } from './privacy-guard.js'; import type { PrivacyPolicySnapshot } from './privacy-policy.js'; import { redactSharedText } from './privacy.js'; @@ -57,6 +58,8 @@ export interface ContextIndexSnapshot { workflowMode: StoredTaskSnapshot['metadata']['workflowMode']; policyVersions: StoredTaskSnapshot['metadata']['governance']['policyVersions']; planDecision: string | null; + planApprovalSource?: string; + planApprovalWarnings?: string[]; } | null; gaps: string[]; absenceDisclosure?: boolean; @@ -208,6 +211,15 @@ export function projectContextIndex( workflowMode: metadata.workflowMode, policyVersions: metadata.governance.policyVersions, planDecision: metadata.governance.planDecision, + ...(metadata.governance.planApproval === undefined + ? {} + : { + planApprovalSource: + metadata.governance.planApproval.approvalSource, + ...(planApprovalWarningCodes(metadata).length === 0 + ? {} + : { planApprovalWarnings: planApprovalWarningCodes(metadata) }), + }), }; add( `${ref}/requirements`, @@ -369,9 +381,7 @@ export async function loadContextIndexSnapshot( const task = taskRef === null ? null : await store.readTaskSnapshot(taskRef); const boundPlan = - task?.plan && - task.metadata.workflowMode === 'man' && - task.metadata.governance.policyVersions.planning === 3 + task?.plan && isManDelivery(task.metadata) ? (await readBoundManPlan(store.projectRoot, task)).document : undefined; const documents = await loadContextDocuments(store.projectRoot, { diff --git a/src/context/man-delivery-runtime.ts b/src/context/man-delivery-runtime.ts index d4b1159..0e95396 100644 --- a/src/context/man-delivery-runtime.ts +++ b/src/context/man-delivery-runtime.ts @@ -24,6 +24,7 @@ import { replaceManDeliveryRecord, } from './man-delivery-plan.js'; import { type ManProgressStatus, syncManProgressPage } from './man-progress.js'; +import { planApprovalWarnings } from './plan-gate.js'; import { scanSharedText } from './privacy.js'; import type { ReviewLedgerV1 } from './review-ledger.js'; import type { StoredTaskSnapshot } from './store.js'; @@ -31,10 +32,10 @@ import type { VerificationLedger } from './verification-ledger.js'; import { type WorkflowMetadataV3, isActiveSoloHandoff, + isDeliveryPlanningPolicy, } from './workflow-metadata.js'; const execFile = promisify(execFileCallback); -export const MAN_DELIVERY_POLICY = 3; export type ManFinalizationBlockerCode = | 'plan_execution_required' @@ -61,7 +62,7 @@ export interface ManDeliveryFinalization { export function isManDelivery(metadata: WorkflowMetadataV3): boolean { return ( metadata.workflowMode === 'man' && - metadata.governance.policyVersions.planning === MAN_DELIVERY_POLICY + isDeliveryPlanningPolicy(metadata.governance.policyVersions.planning) ); } @@ -708,6 +709,14 @@ export function renderManDeliveryRecord( return [ `Task: ${task.metadata.taskRef.namespace}:${task.metadata.taskRef.taskId}`, `Plan version: ${task.metadata.governance.planVersion}`, + ...(task.metadata.governance.planApproval === undefined + ? [] + : [ + `Plan approval: ${task.metadata.governance.planApproval.approvalSource}`, + ...planApprovalWarnings(task.metadata).map( + (warning) => `Plan approval warning: ${warning}`, + ), + ]), `Review: ${reviewStale ? 'stale' : task.review.status}`, `Verification: ${verificationStatus}`, '', diff --git a/src/context/plan-gate.ts b/src/context/plan-gate.ts new file mode 100644 index 0000000..e42d1a8 --- /dev/null +++ b/src/context/plan-gate.ts @@ -0,0 +1,339 @@ +import type { UnattendedWaiverPolicy } from '../team/policy.js'; +import type { Ulid } from './ids.js'; +import { parseManDeliveryPlan } from './man-delivery-plan.js'; +import { assertSharedTextSafe } from './privacy.js'; +import type { RequirementsLedgerV1 } from './requirements-ledger.js'; +import type { StoredTaskSnapshot } from './store.js'; +import { + type PlanApprovalRecord, + type PlanApprovalSource, + type PlanPresentationRecord, + type WorkflowMetadataV3, + isPlanGated, +} from './workflow-metadata.js'; + +/** + * Planning policy 4 plan gate: a plan must be presented in full before it can + * be approved, and the approval is bound to the exact plan/scope/requirements + * it was given for. The CLI cannot prove that the operator wrote the reply; the + * gate leaves an auditable record and removes the one-call self-approval paths. + */ +export const PLAN_OPERATOR_REPLY_MAX_LENGTH = 2000; +export const PLAN_PRESENT_INSTRUCTION = + 'Show this plan to the operator and wait for their reply before running plan confirm.'; + +export type PlanApprovalDecision = PlanApprovalRecord['decision']; + +export interface PlanApprovalInput { + operatorReply?: string; + unattendedWaiver?: boolean; +} + +export interface PlanPresentationSummary { + planVersion: number; + planFile: string | null; + goal: string; + scope: { include: string[]; exclude: string[]; modules: string[] }; + acceptanceCriteria: Array<{ + id: string; + statement: string; + required: boolean; + }>; +} + +export function planGateTupleOf( + metadata: WorkflowMetadataV3, + planDigest: string, +): Omit< + PlanPresentationRecord, + 'presentedAt' | 'actorId' | 'sessionId' | 'operationId' +> { + return { + planVersion: metadata.governance.planVersion, + planDigest, + scopeDigest: metadata.implementationScope.digest, + requirementsDigest: metadata.governance.requirementsDigest, + }; +} + +export function planPresentationMatches( + presentation: PlanPresentationRecord, + metadata: WorkflowMetadataV3, + planDigest: string | null, +): boolean { + if (planDigest === null) return false; + const tuple = planGateTupleOf(metadata, planDigest); + return ( + presentation.planVersion === tuple.planVersion && + presentation.planDigest === tuple.planDigest && + presentation.scopeDigest === tuple.scopeDigest && + presentation.requirementsDigest === tuple.requirementsDigest + ); +} + +/** A presentation that already backed an approval cannot back a second one. */ +export function planPresentationConsumed( + metadata: WorkflowMetadataV3, + presentation: PlanPresentationRecord, +): boolean { + return ( + metadata.governance.planApproval?.presentationOperationId === + presentation.operationId + ); +} + +export function buildPlanPresentationSummary( + task: Pick, +): PlanPresentationSummary { + const { metadata, requirements, plan } = task; + let planFile: string | null = null; + if (plan !== null) { + try { + planFile = parseManDeliveryPlan(plan.content)?.source.path ?? null; + } catch { + planFile = null; + } + planFile ??= `.mancode/${metadata.taskRef.namespace}/workflows/${metadata.taskRef.taskId}/plan.md`; + } + return { + planVersion: metadata.governance.planVersion, + planFile, + goal: requirements.goal, + scope: { + include: metadata.implementationScope.include, + exclude: metadata.implementationScope.exclude, + modules: metadata.implementationScope.modules, + }, + acceptanceCriteria: requirements.acceptanceCriteria.map((item) => ({ + id: item.displayId, + statement: item.statement, + required: item.required, + })), + }; +} + +/** Rejects approval inputs where no plan decision is being recorded. */ +export function assertPlanApprovalInputAbsent( + metadata: WorkflowMetadataV3, + input: PlanApprovalInput, +): void { + if (input.operatorReply === undefined && input.unattendedWaiver !== true) { + return; + } + if (isPlanGated(metadata)) { + throw new Error( + 'MANCODE_PLAN_APPROVAL_INPUT_UNEXPECTED: --operator-reply and --unattended-waiver apply only when confirming a presented plan', + ); + } + throw new Error( + `MANCODE_PLAN_GATE_NOT_ENABLED: planning policy ${String(metadata.governance.policyVersions.planning)} tasks confirm without --operator-reply or --unattended-waiver`, + ); +} + +export function assertPlanGatePolicy(metadata: WorkflowMetadataV3): void { + if (!isPlanGated(metadata)) { + throw new Error( + `MANCODE_PLAN_GATE_NOT_ENABLED: planning policy ${String(metadata.governance.policyVersions.planning)} tasks have no plan presentation step; use plan confirm directly`, + ); + } +} + +/** Case, whitespace and trailing punctuation never make a copied text "new". */ +export function normalizeReplyText(value: string): string { + return value + .normalize('NFKC') + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[\s.!?,;:\u3002\uff01\uff1f\uff0c\uff1b\uff1a]+$/u, '') + .trim(); +} + +/** Only human-authored task text; never enums, ids or digests. */ +function recordedTexts(input: { + requirements: RequirementsLedgerV1; + decisionTexts: string[]; +}): Set { + const { requirements } = input; + const texts = [ + requirements.goal, + ...requirements.functionalScope.inScope, + ...requirements.functionalScope.outOfScope, + ...requirements.requirements.map((item) => item.statement), + ...requirements.acceptanceCriteria.map((item) => item.statement), + ...requirements.technicalDecisions.map((item) => item.statement), + ...requirements.defaults.map((item) => item.statement), + ...input.decisionTexts, + ]; + return new Set(texts.map(normalizeReplyText)); +} + +export interface ResolvePlanApprovalInput { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + planDigest: string | null; + decision: PlanApprovalDecision; + approval: PlanApprovalInput; + decisionTexts: string[]; + /** Project config `unattendedWaiver`; undefined applies the task-kind default. */ + unattendedWaiverPolicy?: UnattendedWaiverPolicy; + actorId: Ulid; + sessionId: Ulid; + confirmedAt: string; +} + +/** + * Verifies the present record and the operator-reply / waiver input for a + * planning policy 4 approval and returns the record to persist. + */ +export function resolvePlanApproval( + input: ResolvePlanApprovalInput, +): PlanApprovalRecord { + const { metadata, approval } = input; + const presentation = metadata.governance.planPresentation; + if (presentation === undefined) { + throw new Error( + 'MANCODE_PLAN_PRESENTATION_REQUIRED: run workflow plan present, show the plan to the operator, and wait for their reply before confirming', + ); + } + if (!planPresentationMatches(presentation, metadata, input.planDigest)) { + throw new Error( + 'MANCODE_PLAN_PRESENTATION_STALE: the plan, scope or requirements changed after the last present; run workflow plan present again', + ); + } + if (planPresentationConsumed(metadata, presentation)) { + throw new Error( + 'MANCODE_PLAN_PRESENTATION_CONSUMED: the last present already backed an approval; run workflow plan present again and wait for a new operator reply', + ); + } + const waiver = approval.unattendedWaiver === true; + if (waiver && approval.operatorReply !== undefined) { + throw new Error( + 'MANCODE_PLAN_APPROVAL_INPUT_CONFLICT: use either --operator-reply or --unattended-waiver, not both', + ); + } + let source: PlanApprovalSource; + let operatorReply: string | undefined; + if (waiver) { + assertUnattendedWaiverAllowed(metadata, input.unattendedWaiverPolicy); + source = 'unattended_waiver'; + } else { + operatorReply = parseOperatorReply(approval.operatorReply); + if (metadata.visibility === 'shared') { + assertSharedTextSafe(operatorReply, 'operatorReply'); + } + const known = recordedTexts(input); + if (known.has(normalizeReplyText(operatorReply))) { + throw new Error( + "MANCODE_PLAN_OPERATOR_REPLY_REUSED: the reply repeats text already recorded for this task (goal, scope, acceptance or decisions); pass the operator's actual reply to the presented plan", + ); + } + source = 'operator_reply'; + } + return { + approvalSource: source, + ...(operatorReply === undefined ? {} : { operatorReply }), + decision: input.decision, + planVersion: presentation.planVersion, + planDigest: presentation.planDigest, + scopeDigest: presentation.scopeDigest, + requirementsDigest: presentation.requirementsDigest, + presentedAt: presentation.presentedAt, + presentationOperationId: presentation.operationId, + confirmedAt: input.confirmedAt, + actorId: input.actorId, + sessionId: input.sessionId, + }; +} + +export const UNATTENDED_WAIVER_GUIDANCE = + 'Use it only when this session genuinely has no operator present (for example a headless or CI run), never in place of asking an available operator, and never by inventing an operator reply.'; + +function assertUnattendedWaiverAllowed( + metadata: WorkflowMetadataV3, + configured: UnattendedWaiverPolicy | undefined, +): void { + const localSingle = + metadata.visibility === 'local' && metadata.coordination === 'single'; + const effective = configured ?? (localSingle ? 'allowed' : 'forbidden'); + if (effective === 'forbidden') { + throw new Error( + `MANCODE_UNATTENDED_WAIVER_FORBIDDEN: the project ${configured === undefined ? 'forbids unattended waivers for shared and team tasks by default' : 'config sets unattendedWaiver to forbidden'}; ask the operator to review the presented plan and pass their reply with --operator-reply`, + ); + } +} + +function parseOperatorReply(value: string | undefined): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + 'MANCODE_PLAN_OPERATOR_REPLY_REQUIRED: pass --operator-reply "" (or --unattended-waiver only when this session has no operator at all, such as a headless or CI run)', + ); + } + const reply = value.trim(); + if (normalizeReplyText(reply) === '') { + throw new Error( + 'MANCODE_PLAN_OPERATOR_REPLY_REQUIRED: the reply must contain more than punctuation', + ); + } + if (reply.length > PLAN_OPERATOR_REPLY_MAX_LENGTH || reply.includes('\0')) { + throw new Error( + `MANCODE_PLAN_OPERATOR_REPLY_INVALID: the reply must be at most ${PLAN_OPERATOR_REPLY_MAX_LENGTH} characters without NUL`, + ); + } + return reply; +} + +export function decisionTextsOf( + decisions: ReadonlyArray<{ title: string; statement: string }>, +): string[] { + return decisions.flatMap((item) => [item.title, item.statement]); +} + +/** Compact audit view of the approval; absent for tasks below planning policy 4. */ +export function planApprovalSummary(metadata: WorkflowMetadataV3): + | { + approvalSource: PlanApprovalSource; + decision: PlanApprovalDecision; + planVersion: number; + presentedAt: string; + confirmedAt: string; + } + | undefined { + const approval = metadata.governance.planApproval; + if (approval === undefined) return undefined; + return { + approvalSource: approval.approvalSource, + decision: approval.decision, + planVersion: approval.planVersion, + presentedAt: approval.presentedAt, + confirmedAt: approval.confirmedAt, + }; +} + +const APPROVAL_WARNING_TEXT = { + UNATTENDED_WAIVER: + 'this plan was approved without an operator reply; confirm with the operator before relying on it', + PLAN_APPROVAL_SCOPE_CHANGED: + 'the implementation scope changed after this plan was approved; the approval covers the earlier scope', +} as const; + +/** Warning codes shown wherever the approval is surfaced. */ +export function planApprovalWarningCodes( + metadata: WorkflowMetadataV3, +): Array { + const approval = metadata.governance.planApproval; + if (approval === undefined) return []; + return [ + ...(approval.approvalSource === 'unattended_waiver' + ? (['UNATTENDED_WAIVER'] as const) + : []), + ...(approval.scopeDigest !== metadata.implementationScope.digest + ? (['PLAN_APPROVAL_SCOPE_CHANGED'] as const) + : []), + ]; +} + +export function planApprovalWarnings(metadata: WorkflowMetadataV3): string[] { + return planApprovalWarningCodes(metadata).map( + (code) => `${code}: ${APPROVAL_WARNING_TEXT[code]}`, + ); +} diff --git a/src/context/plan-present.ts b/src/context/plan-present.ts new file mode 100644 index 0000000..b6a8c9a --- /dev/null +++ b/src/context/plan-present.ts @@ -0,0 +1,256 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { isManDelivery, readBoundManPlan } from './man-delivery-runtime.js'; +import { + PLAN_PRESENT_INSTRUCTION, + type PlanPresentationSummary, + assertPlanGatePolicy, + buildPlanPresentationSummary, + planGateTupleOf, + planPresentationConsumed, + planPresentationMatches, +} from './plan-gate.js'; +import { requirementsAreReady } from './requirements-ledger.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { + type PlanPresentationRecord, + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface PresentV3PlanInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId?: Ulid; + now?: Date; +} + +export interface PresentedV3Plan { + metadata: WorkflowMetadataV3; + presentation: PlanPresentationRecord; + summary: PlanPresentationSummary; + instruction: string; + /** True when the unchanged tuple was already presented; nothing was written. */ + idempotent: boolean; + aggregate: TaskAggregateManifestV1 | null; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1 | null; +} + +/** + * Records that the current plan, scope and requirements were put in front of + * the operator. A later revise, scope or requirements change invalidates it. + * Repeating it for an unchanged tuple returns the existing record and does not + * advance the task revision. + */ +export async function presentV3Plan( + input: PresentV3PlanInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'plan present operationId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: + taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : [], + now, + }); + let journal: OperationJournalV1 | null = null; + try { + const { metadata: previous } = context.task; + assertPlanGatePolicy(previous); + const planDigest = assertPlanPresentEligible(context); + // A drifted bound plan document must fail before anything is written. + if (isManDelivery(previous)) { + await readBoundManPlan(input.projectRoot, context.task); + } + const summary = buildPlanPresentationSummary(context.task); + const existing = previous.governance.planPresentation; + if ( + existing !== undefined && + planPresentationMatches(existing, previous, planDigest) && + !planPresentationConsumed(previous, existing) + ) { + return { + metadata: previous, + presentation: existing, + summary, + instruction: PLAN_PRESENT_INSTRUCTION, + idempotent: true, + aggregate: null, + taskHeadFence: null, + operation: null, + }; + } + const tuple = planGateTupleOf(previous, planDigest); + const timestamp = context.now.toISOString(); + const presentation: PlanPresentationRecord = { + ...tuple, + presentedAt: timestamp, + actorId: context.session.actorId, + sessionId: context.session.sessionId, + operationId: context.operationId, + }; + const metadata = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: context.operationId, + governance: { ...previous.governance, planPresentation: presentation }, + updatedAt: timestamp, + }); + assertWorkflowMetadataTransition(previous, metadata, 'ordinary'); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + journal = await createTaskOperationJournal(context, { + type: 'plan_present', + action: + taskRef.namespace === 'shared' + ? 'shared_metadata_plan_mutation' + : 'local_workflow_mutation', + expectedRevisions: taskMutationExpectedRevisions(context, []), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(previous), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'write-metadata', + false, + ); + 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); + return { + metadata, + presentation, + summary, + instruction: PLAN_PRESENT_INSTRUCTION, + idempotent: false, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // The prepared journal blocks ordinary writes until recovery. + } + } + throw error; + } finally { + await context.release(); + } +} + +/** Returns the digest of the bound plan once the task is eligible. */ +function assertPlanPresentEligible( + context: Awaited>, +): string { + const { metadata, requirements, plan } = context.task; + // Owner first, so an existing identical presentation never lets a non-owner in. + if (metadata.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + const decision = metadata.governance.planDecision; + const confirmationStage = + decision === null && + metadata.status === 'in_progress' && + metadata.currentStep === 4; + const resumptionStage = + decision === 'plan_only' && + (metadata.status === 'planned' || metadata.status === 'in_progress') && + metadata.currentStep === 4 && + metadata.workflowMode === 'man' && + metadata.taskRef.namespace === 'local' && + metadata.coordination === 'single' && + metadata.soloExecution === null; + if ( + (!confirmationStage && !resumptionStage) || + plan === null || + metadata.governance.requirementsStatus !== 'ready' || + metadata.governance.requirementsDigest !== requirements.contentDigest || + requirements.status !== 'confirmed' || + !requirementsAreReady(requirements) + ) { + throw new Error( + 'MANCODE_PLAN_PRESENT_NOT_ELIGIBLE: present applies to a ready task with a bound plan at the undecided Step 4 plan gate, or to a plan_only plan before an explicit execution decision', + ); + } + return plan.digest; +} diff --git a/src/context/plan-revision.ts b/src/context/plan-revision.ts index 1ae55a6..1e07df7 100644 --- a/src/context/plan-revision.ts +++ b/src/context/plan-revision.ts @@ -33,6 +33,11 @@ import { readBoundManPlan, } from './man-delivery-runtime.js'; import { assertManteamPlanContent } from './manteam-plan.js'; +import { + assertPlanApprovalInputAbsent, + decisionTextsOf, + resolvePlanApproval, +} from './plan-gate.js'; import { assertSharedTextSafe } from './privacy.js'; import { type RequirementsLedgerV1, @@ -51,11 +56,13 @@ import { import { type TaskRef, parseTaskRefValue } from './task-ref.js'; import type { VerificationLedger } from './verification-ledger.js'; import { + type PlanApprovalRecord, type PlanDecision, type WorkflowMetadataV3, assertExecutableImplementationScope, assertWorkflowMetadataTransition, implementationScopeIsExecutable, + isPlanGated, parseWorkflowMetadata, } from './workflow-metadata.js'; @@ -72,6 +79,12 @@ export interface ReviseV3PlanInput { implementationScope?: unknown; /** Omitting the decision leaves the workflow at the step-four plan gate. */ planDecision?: V3PlanDecision; + /** + * Planning policy 4 only: the operator's reply to the presented plan. + * Exactly one of this or `unattendedWaiver` must accompany a plan decision. + */ + operatorReply?: string; + unattendedWaiver?: boolean; operationId?: Ulid; now?: Date; } @@ -156,6 +169,18 @@ export async function reviseV3Plan( context.task.requirements, executionScopeBinding || executionResumption, ); + if ( + isPlanGated(context.task.metadata) && + !executionScopeBinding && + planDecision !== null && + (planChanged || scopeChanged) + ) { + // Revise and confirm are separate steps under the plan gate: a decision + // may only be recorded against the exact plan that was presented. + throw new Error( + 'MANCODE_PLAN_DECISION_ON_REVISE_REJECTED: revise the plan or scope first, run workflow plan present, then confirm with the operator reply', + ); + } if (planDecision === 'governed_execution') { assertExecutableImplementationScope(implementationScope); if (isManDelivery(context.task.metadata)) { @@ -179,6 +204,34 @@ export async function reviseV3Plan( ) { assertManteamPlanContent(plan); } + // Gate evaluation follows every structural check so those errors surface first. + let planApproval: PlanApprovalRecord | undefined; + if ( + isPlanGated(context.task.metadata) && + !executionScopeBinding && + planDecision !== null + ) { + planApproval = resolvePlanApproval({ + metadata: context.task.metadata, + requirements: context.task.requirements, + planDigest: context.task.plan?.digest ?? null, + decision: planDecision, + approval: { + operatorReply: input.operatorReply, + unattendedWaiver: input.unattendedWaiver, + }, + decisionTexts: decisionTextsOf(context.project.confirmedDecisions), + unattendedWaiverPolicy: context.project.config.unattendedWaiver, + actorId: context.session.actorId, + sessionId: context.session.sessionId, + confirmedAt: context.now.toISOString(), + }); + } else { + assertPlanApprovalInputAbsent(context.task.metadata, { + operatorReply: input.operatorReply, + unattendedWaiver: input.unattendedWaiver, + }); + } if ( context.task.plan?.content === plan && planDecision === null && @@ -216,6 +269,7 @@ export async function reviseV3Plan( authorityChanged, planDecision, executionScopeBinding, + planApproval, context.operationId, timestamp, ); @@ -543,6 +597,7 @@ function updateMetadata( authorityChanged: boolean, planDecision: V3PlanDecision | null, executionScopeBinding: boolean, + planApproval: PlanApprovalRecord | undefined, operationId: Ulid, updatedAt: string, ): WorkflowMetadataV3 { @@ -574,6 +629,7 @@ function updateMetadata( planDecision: executionScopeBinding ? previous.governance.planDecision : planDecision, + ...(planApproval === undefined ? {} : { planApproval }), reviewStatus: authorityChanged ? 'stale' : previous.governance.reviewStatus, diff --git a/src/context/project-progress.ts b/src/context/project-progress.ts index 0d918c3..863a91c 100644 --- a/src/context/project-progress.ts +++ b/src/context/project-progress.ts @@ -1,6 +1,7 @@ import { digestCanonicalJson } from './canonical.js'; import { decisionContextValue, projectDecisions } from './decision-record.js'; import type { ManEvidenceSubject } from './man-delivery-evidence.js'; +import { planApprovalWarningCodes } from './plan-gate.js'; import { assertPrivacyValueAllowed } from './privacy-guard.js'; import { redactSharedText } from './privacy.js'; import { @@ -50,6 +51,8 @@ export interface ProgressTask { next: string | null; /** Absent in older disposable caches; absence also means unobserved. */ publication?: 'unobserved'; + /** Plan-gate warning codes (UNATTENDED_WAIVER, PLAN_APPROVAL_SCOPE_CHANGED). */ + approvalWarnings?: string[]; updatedAt: string; source: string; acceptance: Array<{ id: string; text: string; state: string }>; @@ -220,6 +223,9 @@ export function projectProgressTask( issue, next: checkpointCurrent ? safe(checkpoint.nextAction) : null, publication: 'unobserved', + ...(planApprovalWarningCodes(m).length === 0 + ? {} + : { approvalWarnings: planApprovalWarningCodes(m) }), updatedAt: m.updatedAt, source: formatTaskRef(m.taskRef), acceptance: task.requirements.acceptanceCriteria.map((criterion) => { diff --git a/src/context/reframe.ts b/src/context/reframe.ts index 2a31496..84fd0a3 100644 --- a/src/context/reframe.ts +++ b/src/context/reframe.ts @@ -728,6 +728,9 @@ function completeReframeMetadata( requirementsStatus: 'needs_clarification', requirementsDigest: requirements.contentDigest, planDecision: null, + // The reframed task needs a fresh present and approval (planning policy 4). + planPresentation: undefined, + planApproval: undefined, reviewStatus: review.status, reviewLedgerDigest: review.contentDigest, verificationStatus: verification.status, diff --git a/src/context/requirements-finalize.ts b/src/context/requirements-finalize.ts index ae64ed8..03a12f2 100644 --- a/src/context/requirements-finalize.ts +++ b/src/context/requirements-finalize.ts @@ -20,6 +20,7 @@ import { buildTaskAggregateManifest, } from './aggregate.js'; import type { Ulid } from './ids.js'; +import { isManDelivery } from './man-delivery-runtime.js'; import { type RequirementsLedgerV1, assertManDeliveryVerificationSurfaces, @@ -130,8 +131,7 @@ async function writeV3Requirements( assertRequirementsFinalizeEligible(context.task.metadata); if ( action === 'finalize' && - context.task.metadata.workflowMode === 'man' && - context.task.metadata.governance.policyVersions.planning === 3 && + isManDelivery(context.task.metadata) && context.task.metadata.governance.planDecision !== 'solo_handoff' ) { assertManDeliveryVerificationSurfaces(submitted); diff --git a/src/context/resolver.ts b/src/context/resolver.ts index c756149..a29149d 100644 --- a/src/context/resolver.ts +++ b/src/context/resolver.ts @@ -33,6 +33,7 @@ import type { import { decisionContextValue, projectDecisions } from './decision-record.js'; import { type LegacyAuthorityScan, scanLegacyAuthority } from './layout.js'; import type { ManagedAdapter } from './manifest.js'; +import { planApprovalWarningCodes } from './plan-gate.js'; import { type PendingOperationRecord, type StoredCoordinationSnapshot, @@ -737,6 +738,12 @@ function activeTaskProjection(task: StoredTaskSnapshot) { requirementsStatus: governance.requirementsStatus, planVersion: governance.planVersion, planDecision: governance.planDecision, + ...(governance.planApproval === undefined + ? {} + : { + planApprovalSource: governance.planApproval.approvalSource, + planApprovalWarnings: planApprovalWarningCodes(task.metadata), + }), policyVersions: governance.policyVersions, reviewStatus: governance.reviewStatus, verificationStatus: governance.verificationStatus, diff --git a/src/context/solo-handoff.ts b/src/context/solo-handoff.ts index bf4a2ec..6c08b20 100644 --- a/src/context/solo-handoff.ts +++ b/src/context/solo-handoff.ts @@ -27,15 +27,23 @@ import { assertManDeliveryReady, captureManSubject, } from './man-delivery-runtime.js'; +import { + type PlanApprovalInput, + assertPlanApprovalInputAbsent, + decisionTextsOf, + resolvePlanApproval, +} from './plan-gate.js'; import { assertRequirementsScopeConsistent, requirementsAreReady, } from './requirements-ledger.js'; import { type TaskRef, parseTaskRefValue } from './task-ref.js'; import { + type PlanApprovalRecord, type WorkflowMetadataV3, assertExecutableImplementationScope, assertWorkflowMetadataTransition, + isPlanGated, parseWorkflowMetadata, } from './workflow-metadata.js'; @@ -44,6 +52,9 @@ export interface StartV3SoloHandoffInput { taskRef: TaskRef; sessionId: Ulid; expectedTaskRevision: number; + /** Planning policy 4 only: same present-then-approve gate as plan confirm. */ + operatorReply?: string; + unattendedWaiver?: boolean; operationId?: Ulid; now?: Date; } @@ -89,8 +100,26 @@ export async function startV3SoloHandoff( try { assertSoloStartEligible(context); const timestamp = context.now.toISOString(); + const planApproval = isPlanGated(context.task.metadata) + ? resolvePlanApproval({ + metadata: context.task.metadata, + requirements: context.task.requirements, + planDigest: context.task.plan?.digest ?? null, + decision: 'solo_handoff', + approval: { + operatorReply: input.operatorReply, + unattendedWaiver: input.unattendedWaiver, + }, + decisionTexts: decisionTextsOf(context.project.confirmedDecisions), + unattendedWaiverPolicy: context.project.config.unattendedWaiver, + actorId: context.session.actorId, + sessionId: context.session.sessionId, + confirmedAt: timestamp, + }) + : assertNoApprovalInput(context.task.metadata, input); const metadata = activeSoloMetadata( context.task.metadata, + planApproval, context.session.sessionId, context.operationId, timestamp, @@ -392,8 +421,17 @@ function assertSoloCompletionEligible( } } +function assertNoApprovalInput( + metadata: WorkflowMetadataV3, + input: PlanApprovalInput, +): undefined { + assertPlanApprovalInputAbsent(metadata, input); + return undefined; +} + function activeSoloMetadata( previous: WorkflowMetadataV3, + planApproval: PlanApprovalRecord | undefined, sessionId: Ulid, operationId: Ulid, updatedAt: string, @@ -407,6 +445,7 @@ function activeSoloMetadata( governance: { ...previous.governance, planDecision: 'solo_handoff', + ...(planApproval === undefined ? {} : { planApproval }), }, soloExecution: { state: 'active', diff --git a/src/context/workflow-create.ts b/src/context/workflow-create.ts index 83bfb47..087d6c7 100644 --- a/src/context/workflow-create.ts +++ b/src/context/workflow-create.ts @@ -116,7 +116,9 @@ import { verificationLedgerDigest, } from './verification-ledger.js'; import { + PLAN_GATE_POLICY_VERSION, type WorkflowMetadataV3, + isDeliveryPlanningPolicy, parseWorkflowMetadata, } from './workflow-metadata.js'; @@ -291,14 +293,14 @@ export async function createV3Workflow( : 1, timestamp, planningPolicyVersion: input.delivery - ? 3 + ? PLAN_GATE_POLICY_VERSION : parent === null - ? project.manifest.manifestVersion !== 1 && workflowMode === 'man' - ? project.manifest.workflowPolicyDefaults.planning - : 1 - : parent.metadata.governance.policyVersions.planning === 3 - ? 2 - : parent.metadata.governance.policyVersions.planning, + ? project.manifest.manifestVersion !== 1 && workflowMode === 'manteam' + ? PLAN_GATE_POLICY_VERSION + : project.manifest.manifestVersion !== 1 && workflowMode === 'man' + ? project.manifest.workflowPolicyDefaults.planning + : 1 + : childPlanningPolicyVersion(parent.metadata), }); const homeStore = resolveTaskEntityHomeStore( runtime.entityHomeStoreContext, @@ -716,7 +718,8 @@ function buildInitialEntities(input: { throw new Error('MANCODE_EXECUTION_SHARED_UNSUPPORTED'); if ( executionPolicy !== null && - (input.workflowMode !== 'man' || input.planningPolicyVersion !== 3) + (input.workflowMode !== 'man' || + !isDeliveryPlanningPolicy(input.planningPolicyVersion)) ) throw new Error('MANCODE_EXECUTION_DELIVERY_REQUIRED'); assertExecutionWriterCapability( @@ -1277,3 +1280,12 @@ function isNotFound(error: unknown): error is NodeJS.ErrnoException { (error as NodeJS.ErrnoException).code === 'ENOENT' ); } + +/** A manba child never inherits delivery or plan-gate semantics from its parent. */ +function childPlanningPolicyVersion(parent: WorkflowMetadataV3): number | null { + const version = parent.governance.policyVersions.planning; + if (isDeliveryPlanningPolicy(version)) { + return parent.workflowMode === 'manteam' ? 1 : 2; + } + return version; +} diff --git a/src/context/workflow-metadata.ts b/src/context/workflow-metadata.ts index e73ede8..32a997c 100644 --- a/src/context/workflow-metadata.ts +++ b/src/context/workflow-metadata.ts @@ -49,10 +49,43 @@ export type PlanDecision = | 'governed_execution' | null; +export type PlanApprovalSource = 'operator_reply' | 'unattended_waiver'; + +/** Binds one `plan present` to the exact plan, scope and requirements shown. */ +export interface PlanPresentationRecord { + planVersion: number; + planDigest: string; + scopeDigest: string; + requirementsDigest: string; + presentedAt: string; + actorId: Ulid; + sessionId: Ulid; + operationId: Ulid; +} + +/** Audit record of the approval that set `planDecision` under the plan gate. */ +export interface PlanApprovalRecord { + approvalSource: PlanApprovalSource; + operatorReply?: string; + decision: Exclude; + planVersion: number; + planDigest: string; + scopeDigest: string; + requirementsDigest: string; + presentedAt: string; + presentationOperationId: Ulid; + confirmedAt: string; + actorId: Ulid; + sessionId: Ulid; +} + +/** Planning policy 4 requires present-then-confirm for plan decisions. */ +export const PLAN_GATE_POLICY_VERSION = 4; + export type WorkflowPolicyComponent = 'planning' | 'review' | 'verification'; export const SUPPORTED_WORKFLOW_POLICY_VERSIONS = { - planning: [1, 2, 3], + planning: [1, 2, 3, 4], review: [1, 2], verification: [1, 2], } as const; @@ -114,6 +147,9 @@ export interface WorkflowMetadataV3 { requirementsDigest: string; planVersion: number; planDecision: PlanDecision; + /** Planning policy 4 only; absent on older tasks so they round-trip unchanged. */ + planPresentation?: PlanPresentationRecord; + planApproval?: PlanApprovalRecord; policyVersions: { planning: number | null; review: number | null; @@ -143,6 +179,18 @@ export interface WorkflowMetadataV3 { updatedAt: string; } +/** True when plan decisions must go through present and an operator reply. */ +export function isPlanGated(metadata: WorkflowMetadataV3): boolean { + return ( + metadata.governance.policyVersions.planning === PLAN_GATE_POLICY_VERSION + ); +} + +/** Policy 3 and 4 both mean document-bound man module delivery. */ +export function isDeliveryPlanningPolicy(version: number | null): boolean { + return version === 3 || version === PLAN_GATE_POLICY_VERSION; +} + /** * Execution needs a user-visible, non-empty file boundary. `modules` are * descriptive ownership labels and cannot authorize file writes on their own. @@ -382,8 +430,8 @@ export function assertWorkflowMetadataTransition( ); } if ( - (previous.governance.policyVersions.planning === 3 || - next.governance.policyVersions.planning === 3) && + (isDeliveryPlanningPolicy(previous.governance.policyVersions.planning) || + isDeliveryPlanningPolicy(next.governance.policyVersions.planning)) && previous.governance.policyVersions.planning !== next.governance.policyVersions.planning ) { @@ -579,6 +627,8 @@ function parseGovernance(value: unknown): WorkflowMetadataV3['governance'] { 'requirementsDigest', 'planVersion', 'planDecision', + 'planPresentation', + 'planApproval', 'policyVersions', 'reviewStatus', 'reviewLedgerDigest', @@ -620,6 +670,12 @@ function parseGovernance(value: unknown): WorkflowMetadataV3['governance'] { 'workflow metadata governance planVersion', ), planDecision: parsePlanDecision(value.planDecision), + ...(value.planPresentation === undefined + ? {} + : { planPresentation: parsePlanPresentation(value.planPresentation) }), + ...(value.planApproval === undefined + ? {} + : { planApproval: parsePlanApproval(value.planApproval) }), policyVersions: parsePolicyVersions(value.policyVersions), reviewStatus: value.reviewStatus as ReviewStatus, reviewLedgerDigest: parseDigest( @@ -634,6 +690,140 @@ function parseGovernance(value: unknown): WorkflowMetadataV3['governance'] { }; } +function parsePlanPresentation(value: unknown): PlanPresentationRecord { + assertRecord(value, 'workflow metadata planPresentation'); + assertKnownKeys( + value, + [ + 'planVersion', + 'planDigest', + 'scopeDigest', + 'requirementsDigest', + 'presentedAt', + 'actorId', + 'sessionId', + 'operationId', + ], + 'workflow metadata planPresentation', + ); + return { + planVersion: parsePositiveInteger( + value.planVersion, + 'workflow metadata planPresentation planVersion', + ), + planDigest: parseDigest( + value.planDigest, + 'workflow metadata planPresentation planDigest', + ), + scopeDigest: parseDigest( + value.scopeDigest, + 'workflow metadata planPresentation scopeDigest', + ), + requirementsDigest: parseDigest( + value.requirementsDigest, + 'workflow metadata planPresentation requirementsDigest', + ), + presentedAt: parseTimestamp( + value.presentedAt, + 'workflow metadata planPresentation presentedAt', + ), + actorId: parseUlid( + value.actorId, + 'workflow metadata planPresentation actorId', + ), + sessionId: parseUlid( + value.sessionId, + 'workflow metadata planPresentation sessionId', + ), + operationId: parseUlid( + value.operationId, + 'workflow metadata planPresentation operationId', + ), + }; +} + +function parsePlanApproval(value: unknown): PlanApprovalRecord { + assertRecord(value, 'workflow metadata planApproval'); + assertKnownKeys( + value, + [ + 'approvalSource', + 'operatorReply', + 'decision', + 'planVersion', + 'planDigest', + 'scopeDigest', + 'requirementsDigest', + 'presentedAt', + 'presentationOperationId', + 'confirmedAt', + 'actorId', + 'sessionId', + ], + 'workflow metadata planApproval', + ); + if ( + value.approvalSource !== 'operator_reply' && + value.approvalSource !== 'unattended_waiver' + ) { + throw new Error('workflow metadata planApproval approvalSource is invalid'); + } + const decision = parsePlanDecision(value.decision); + if (decision === null) { + throw new Error('workflow metadata planApproval decision is invalid'); + } + if (value.approvalSource === 'operator_reply') { + parseNonEmptyString( + value.operatorReply, + 'workflow metadata planApproval operatorReply', + ); + } else if (value.operatorReply !== undefined) { + throw new Error( + 'workflow metadata planApproval waiver cannot carry an operatorReply', + ); + } + return { + approvalSource: value.approvalSource, + ...(value.operatorReply === undefined + ? {} + : { operatorReply: value.operatorReply as string }), + decision, + planVersion: parsePositiveInteger( + value.planVersion, + 'workflow metadata planApproval planVersion', + ), + planDigest: parseDigest( + value.planDigest, + 'workflow metadata planApproval planDigest', + ), + scopeDigest: parseDigest( + value.scopeDigest, + 'workflow metadata planApproval scopeDigest', + ), + requirementsDigest: parseDigest( + value.requirementsDigest, + 'workflow metadata planApproval requirementsDigest', + ), + presentedAt: parseTimestamp( + value.presentedAt, + 'workflow metadata planApproval presentedAt', + ), + presentationOperationId: parseUlid( + value.presentationOperationId, + 'workflow metadata planApproval presentationOperationId', + ), + confirmedAt: parseTimestamp( + value.confirmedAt, + 'workflow metadata planApproval confirmedAt', + ), + actorId: parseUlid(value.actorId, 'workflow metadata planApproval actorId'), + sessionId: parseUlid( + value.sessionId, + 'workflow metadata planApproval sessionId', + ), + }; +} + function parsePolicyVersions( value: unknown, ): WorkflowMetadataV3['governance']['policyVersions'] { @@ -753,12 +943,38 @@ function parseLegacyCompatibility( } function assertWorkflowMetadataShape(metadata: WorkflowMetadataV3): void { + const planning = metadata.governance.policyVersions.planning; if ( - metadata.governance.policyVersions.planning === 3 && - metadata.workflowMode !== 'man' + (planning === 3 && metadata.workflowMode !== 'man') || + (planning === PLAN_GATE_POLICY_VERSION && + metadata.workflowMode !== 'man' && + metadata.workflowMode !== 'manteam') ) { throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); } + if ( + planning !== PLAN_GATE_POLICY_VERSION && + (metadata.governance.planPresentation !== undefined || + metadata.governance.planApproval !== undefined) + ) { + throw new Error('MANCODE_PLAN_GATE_POLICY_REQUIRED'); + } + const approval = metadata.governance.planApproval; + if ( + planning === PLAN_GATE_POLICY_VERSION && + metadata.governance.planDecision !== null && + approval === undefined + ) { + throw new Error('MANCODE_PLAN_APPROVAL_RECORD_REQUIRED'); + } + if ( + approval !== undefined && + approval.decision !== metadata.governance.planDecision + ) { + throw new Error( + 'workflow metadata governance planApproval decision mismatch', + ); + } if (metadata.workflowMode === 'man') { if (metadata.coordination !== 'single' || metadata.parent !== null) { throw new Error( @@ -942,6 +1158,11 @@ function parseNonNegativeInteger(value: unknown, label: string): number { return value; } +function parseUlid(value: unknown, label: string): Ulid { + assertUlid(value, label); + return value; +} + function parseUlidOrNull(value: unknown, label: string): Ulid | null { if (value === null) return null; assertUlid(value, label); diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts index 840b807..d19638b 100644 --- a/src/installers/v3-adapter.ts +++ b/src/installers/v3-adapter.ts @@ -136,7 +136,7 @@ const AUTHORIZED_ACTION_GUIDANCE = const ENGINEERING_EXECUTION_QUALITY_POLICY = [ '', '# man 工程执行与效率准则', - '以下补充准则仅用于显式启用模块交付策略的新 `/man` 任务(planning policy 3),不改变普通 Solo、其他模式和旧策略任务;这些任务的 Solo handoff 继承相同的交付标准。', + '以下补充准则仅用于显式启用模块交付策略的新 `/man` 任务(planning policy 3 或 4),不改变普通 Solo、其他模式和旧策略任务;这些任务的 Solo handoff 继承相同的交付标准。', '- 验证应与变更风险和验收目标相称;不滥用校验,不为低风险、可直接观察的事实叠加重复检查。', '- 不得以 `catch` 吞掉、伪装或笼统改写错误来遮掩根因;仅在能够恢复、补充上下文或完成必要清理时处理异常,并保留可诊断的原始错误信息与因果链。', '- 优先定位并修复根因,避免治标不治本;不要以无依据的增量分支、补丁或层层兜底代替正确的设计与实现。', @@ -1725,7 +1725,7 @@ const V3_MODE_DEFINITIONS: Record< ...REVIEW_GUIDANCE, '- For a read-only project orientation, inspect and answer directly; do not create governance records.', '- Governed man work retains evidence-based discovery, persistent requirements and decisions, confirmed planning, stage continuity, implementation, verification, review and repair through the original completion gates. Choose the investigation, tools and implementation within that boundary; do not omit a required stage or silently switch to ordinary Solo.', - '- For a new task, run `mancode workflow create man "" --delivery --session `. This explicitly enables document-bound module delivery (planning policy 3); never silently upgrade an existing task. Other modes and Solo handoff retain their contracts.', + '- For a new task, run `mancode workflow create man "" --delivery --session `. This explicitly enables document-bound module delivery (planning policy 4); never silently upgrade an existing task. Other modes and Solo handoff retain their contracts.', '- Read `.mancode/shared/context/glossary.json` when it exists and prefer its confirmed terms in clarification, requirements, plans, reports, and naming.', "- Before requirements, run a bounded read-only decision-impact discovery: test the operator's factual premise against repository evidence, inspect the end-to-end user goal and common domain failure/edge paths, and retain at most three findings with stable IDs F-1 through F-3 (zero findings is valid) and type `premise`, `scope`, `technical`, `risk`, or `acceptance`. Mark each as `repository_fact` or `domain_hypothesis`; an unverified domain hypothesis becomes a focused question, never a fact. Discovery produces evidence and recommendations, never execution authority.", '- 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.', @@ -1740,10 +1740,10 @@ const V3_MODE_DEFINITIONS: Record< '- Finalize requirements with `mancode workflow requirements finalize --file --expected-revision --session `.', '- Let mancode assign internal IDs and digests; do not invent canonical IDs or digests in the semantic input.', '- For delivery tasks, when one real verification command covers several acceptance criteria, use `--acceptance AC-1,AC-2` to record that single run for those criteria; do not rerun the same suite only to fill separate slots. After an authorized upstream push, `mancode workflow delivery publication --json` can query the actual remote ref without fetching or changing it; unavailable evidence remains unverified.', - '- Write one module plan in the explicitly selected project plan directory, otherwise the established convention (including an existing `docs/`), otherwise `doc/`; bind that actual path without making a duplicate. For delivery policy 3 only, keep approved goals, scope, stages, dependencies, architecture references, acceptance IDs, cross-layer integration checks and unresolved decisions between standalone `` / `` markers; put actual delivery only between `` / ``. Examples inside code fences are not markers. The bound plan must not exist only in chat, AGENTS.md or CLAUDE.md. Planning permission is not implementation permission. For delivery policy 3, completed planning also requires proportionate verification and a task-owned plan commit on the current task branch; apply the same publication limits below. Planning without Git may continue but is not a completed versioned delivery.', - '- For delivery policy 3 only, read the relevant design documents in `架构/` before implementation. Distinguish absent, ignored and unreadable paths: 被忽略不代表本地不可读. When only the plan is available, continue what its confirmed requirements and existing contracts establish; pause only the affected part for missing architecture that changes the implementation decision. Do not invent the architecture or copy credentials, accounts or secrets from `架构/` or `项目接口/`.', + '- Write one module plan in the explicitly selected project plan directory, otherwise the established convention (including an existing `docs/`), otherwise `doc/`; bind that actual path without making a duplicate. For delivery policy 3 or 4 only, keep approved goals, scope, stages, dependencies, architecture references, acceptance IDs, cross-layer integration checks and unresolved decisions between standalone `` / `` markers; put actual delivery only between `` / ``. Examples inside code fences are not markers. The bound plan must not exist only in chat, AGENTS.md or CLAUDE.md. Planning permission is not implementation permission. For delivery policy 3 or 4, completed planning also requires proportionate verification and a task-owned plan commit on the current task branch; apply the same publication limits below. Planning without Git may continue but is not a completed versioned delivery.', + '- For delivery policy 3 or 4 only, read the relevant design documents in `架构/` before implementation. Distinguish absent, ignored and unreadable paths: 被忽略不代表本地不可读. When only the plan is available, continue what its confirmed requirements and existing contracts establish; pause only the affected part for missing architecture that changes the implementation decision. Do not invent the architecture or copy credentials, accounts or secrets from `架构/` or `项目接口/`.', '- Make the plan name a user-visible `implementationScope` with non-empty repo-relative `include`, plus `exclude` and `modules`; include/exclude entries accept repo-relative path or glob only, while semantic scope belongs in requirements. Include is the file-write upper bound, exclude wins, and modules never authorize files alone. Include the plan and any authorized progress page. Bind plan and scope atomically with `mancode workflow plan revise --expected-revision --file --scope-file --session `. A progress-only edit must not become a plan revision; changed approved goals require realignment, not rewriting history to fit the code.', - "- Record the operator's current plan decision with `mancode workflow plan confirm --expected-revision --plan-decision --session `.", + '- Record the operator\'s current plan decision with `mancode workflow plan confirm --expected-revision --plan-decision --session `. Policy 4 tasks (new delivery tasks) first run `workflow plan present --expected-revision --session `, show the plan to the operator, wait, then confirm with `--operator-reply ""`; any revise, scope or requirements change needs a new present, and revise takes no decision. Solo handoff and plan_only resumption follow the same rule. `--unattended-waiver` is only for sessions with no operator present (headless or CI), never for skipping an available operator.', '- Before editing in governed execution, read the confirmed plan and `activeTask.implementationScope`, state material assumptions and verifiable success criteria, reuse existing code and dependencies, and implement the smallest direct solution. Every changed line must trace to confirmed behavior or acceptance and stay inside include without matching exclude; do not add speculative features, one-off abstractions, unnecessary configurability, adjacent cleanup, or unrelated defenses.', '- At material stage boundaries, waits and handoffs, preserve actual progress, remaining work, decisions and evidence in the existing task records and approved plan delivery area where supported; use existing checkpoints when applicable, not a new per-tool ledger. On resume, read back those records and the original TaskRef, then continue unfinished work. Reuse still-applicable evidence across sessions; source, dependency or relevant environment changes require an applicability check and renewed affected evidence.', '- Missing optional Skills do not erase authorization or valid evidence when the available project facts and an equivalent method suffice. Preserve user-required tools, exact acceptance methods and observation surfaces, and the behavior being verified; unavailable required verification remains unverified, never silently downgraded.', @@ -1753,7 +1753,7 @@ const V3_MODE_DEFINITIONS: Record< '- 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`.', '- If an existing reframe operation is already `repair_required` only because its checkpoint ID is occupied by a checkpoint from another operation, preserve every journal and checkpoint, generate a fresh canonical ULID, and run `mancode operation repair --replacement-checkpoint-id --session `. This exception is limited to that proven reframe checkpoint conflict. If recovery stops after rebinding, retry only with the exact same replacement ID; never request a second replacement. Use ordinary operation repair for every other interruption and never delete authority files to unblock an adapter upgrade.', - '- For existing tasks without delivery policy 3, keep their current verification/review apply and completion protocol. The following module actions apply only to new delivery tasks. Keep transient JSON inputs in `.mancode/local/drafts/`, not among the source files being verified. Use `mancode workflow delivery inspect --json` for the current subject, acceptance slots and evidence. Content identity is not proof of correct behavior; external-service or environment changes require renewed evidence.', + '- For existing tasks without delivery policy 3 or 4, keep their current verification/review apply and completion protocol. The following module actions apply only to new delivery tasks. Keep transient JSON inputs in `.mancode/local/drafts/`, not among the source files being verified. Use `mancode workflow delivery inspect --json` for the current subject, acceptance slots and evidence. Content identity is not proof of correct behavior; external-service or environment changes require renewed evidence.', '- Implement the authorized module and run project-required, proportionate checks covering accepted user behavior, affected call chains and necessary negative, permission, compatibility and cross-layer integration paths. Passing local unit tests does not establish project-level acceptance. To capture an automated result, write `{ "argv": ["npm", "test"], "surface": "component" }` with the actual relevant command and run `mancode workflow delivery verify --acceptance --file --expected-revision --session `. `surface` is the actual observation layer and must exactly match that acceptance slot\'s `verificationSurfaces` entry: `unit|component|handler|real_http|browser|device|external_service|manual_observation`; for a true HTTP acceptance the requirement uses `real_http` and the evidence uses `"surface": "real_http"`. It must never be inferred from the command name. The command runs without a shell; inspect its captured output and exit code. Do not substitute a trivial successful command for the acceptance behavior. Manual/hybrid slots require a real observation or explicit user confirmation: record `{ "confirmed": true, "surface": "manual_observation", "summary": "..." }` via the same command with `confirm` instead of `verify`; never fabricate confirmation. Missing or downgraded evidence remains unverified.', '- Run `mancode workflow delivery sync --expected-revision --session ` at implementation start or before module review. Verification/review commands also project results into the delivery block. If root `项目进度.html` exists, update it only through its unique `mancode-progress-data` JSON contract. Optional progress uses the full TaskRef as stable taskId by default, or an explicit baseline marker ``; missing/invalid contracts, mappings or write permission request manual sync without blocking development or guessing HTML. Reflect actual start, review, accepted states and blocking external business decisions; undeveloped work remains incomplete, and ordinary repair, test or push failures are not business-blocked. The page never replaces the plan or runtime authority.', '- After the whole module is implemented, perform one total review (not one per snippet, nor an extra review after existing quality/security review). Prefer one independent reviewer when available and authorized; otherwise label self-review honestly, unless independent review is explicitly required, in which case preserve that requirement. One total review does not forbid necessary checks after a concrete repair or newly discovered risk. The `reviewer` field is self-declared audit metadata, not authenticated actor/session proof, so do not claim independently verified identity from that field alone. Read the approved baseline, relevant architecture, the complete module diff since its bound baseHead, actual entry/call chains, and verification evidence. Check goal → implementation for omissions and diff → goal for scope drift, plus concrete correctness/security defects and unjustified abstraction, fallback or defensive code. Respect intentional phasing and necessary boundaries; zero findings is valid, optional suggestions never block. A reviewer process exit code 0 or a natural-language summary is not proof that the review was applied: re-run `delivery inspect --json` after the reviewer returns, and if the ledger is still `pending`, `in_review`, `stale`, or `blocked`, report `review_incomplete` and continue the required review or repair instead of claiming success.', @@ -1798,6 +1798,7 @@ const V3_MODE_DEFINITIONS: Record< '- 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.', '- Bind the confirmed team plan to the user-visible `implementationScope` through plan revise `--scope-file`; claims, edits, and review must stay inside include and outside exclude. Before editing, state material assumptions and verifiable success criteria, reuse existing code and dependencies, and make the smallest direct plan-traceable change; newly proposed behavior outside confirmed requirements requires read-only `NEEDS_REALIGNMENT` and operator-approved reframe.', '- If the operator explicitly approves a file-boundary-only adjustment that leaves confirmed behavior and acceptance unchanged, use `mancode workflow scope change --expected-revision --file --session `. It versions the plan authority, stales prior review/verification, and reissues compatible claims. Behavior or acceptance changes still require reframe.', + '- Plan approval follows `man`: only the task owner runs `workflow plan present` then `confirm ... --operator-reply ""`. The unattended waiver is forbidden for team tasks by default and only for sessions with no operator present.', '- Use claims, checkpoints, sync, and handoffs through `mancode team`; never infer ownership from an adapter prompt.', '- With git-ref transport, workflow creation plus requirements, plan, review, and verification mutations use an explicit deferred publication boundary: run the workflow command without `--sync`, commit the resulting `.mancode/shared` authority changes together with the matching code head, then run `mancode team sync push --expected-task-revision `. Never report cross-clone synchronization before the push returns a receipt. Use `--sync` only for a command whose contract performs an atomic git-ref mutation. If that atomic mutation leaves tracked `.mancode/shared` projection changes for a resumable in-progress or blocked task, commit them, then run the same `team sync push` with the unchanged task revision to rebind the remote code head before another clone resumes the task.', '- Before any handoff, commit, or PR, read the current authoritative task and handoff state plus the task-owned diff, then derive the user-visible narrative from accepted requirements, the observed final state after available readback, and those owned changes as if the receiver never saw the working session. Rejected session-only proposals and wording corrections must not define the handoff or delivery identity. Preserve failures, blockers, incomplete work, compatibility and migration facts, audit evidence, synchronization or publication failures, and every formal handoff status and resolution reason; if an external surface cannot be read back, mark it unverified.', @@ -1824,9 +1825,9 @@ const V3_MODE_DEFINITIONS: Record< '- 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.', '- Detect those governance triggers after the smallest fact check needed to establish them. Stop before exhaustive discovery or proposing a concrete topology, migration layout, or implementation plan that depends on unanswered decisions.', '- For ordinary Solo, choose the implementation steps and tools, run proportionate relevant verification and inspect the task-owned diff. Do not require formal planning, F-IDs or an independent reviewer unless requested. These exemptions do not apply to commitments inherited through a governed handoff.', - '- For an operator-selected, eligible governed-to-solo transition, use `mancode workflow handoff --to solo --expected-revision --session `. Preserve the original requirements, approved plan, implementationScope, required acceptance and review commitments. An ineligible transition does not authorize another mode or a new ordinary Solo task. Complete only after those commitments are met, using `mancode workflow handoff --complete --expected-revision --session ` and the existing handoff completion protocol.', + '- For an operator-selected, eligible governed-to-solo transition, use `mancode workflow handoff --to solo --expected-revision --session ` (policy 4 tasks need a present and `--operator-reply` first, as for plan confirm). Preserve the original requirements, approved plan, implementationScope, required acceptance and review commitments. An ineligible transition does not authorize another mode or a new ordinary Solo task. Complete only after those commitments are met, using `mancode workflow handoff --complete --expected-revision --session ` and the existing handoff completion protocol.', '- For an active governed Solo assignment, use its original assigned session and record formal verification and review against the current requirements and plan. Missing, failed or stale evidence blocks completion. Ordinary Solo exemptions never bypass these ledgers, and a new host session does not authorize reassignment.', - '- Inherited planning policy 3 also retains the Man module delivery instructions: read that mode entry for the verify, confirm, review, sync and check input formats while preserving solo_handoff. Use `mancode workflow delivery inspect --json`, complete the applicable evidence and review, sync the delivery record and commit authorized task changes, then pass `mancode workflow delivery check --json` before handoff --complete. Older policies use the existing workflow verify/review apply commands without upgrading policy. Never rewrite an already completed historical handoff.', + '- Inherited planning policy 3 or 4 also retains the Man module delivery instructions: read that mode entry for the verify, confirm, review, sync and check input formats while preserving solo_handoff. Use `mancode workflow delivery inspect --json`, complete the applicable evidence and review, sync the delivery record and commit authorized task changes, then pass `mancode workflow delivery check --json` before handoff --complete. Older policies use the existing workflow verify/review apply commands without upgrading policy. Never rewrite an already completed historical handoff.', ], }, }; diff --git a/src/runtime/operation-definition.ts b/src/runtime/operation-definition.ts index 0cf18af..bf2c27f 100644 --- a/src/runtime/operation-definition.ts +++ b/src/runtime/operation-definition.ts @@ -52,6 +52,7 @@ export const OPERATION_AUTHORIZATION_ACTIONS: Record< 'shared_metadata_plan_mutation', ], plan_revision: ['local_workflow_mutation', 'shared_metadata_plan_mutation'], + plan_present: ['local_workflow_mutation', 'shared_metadata_plan_mutation'], review_remediation: [ 'local_workflow_mutation', 'shared_ledger_evidence', @@ -231,6 +232,12 @@ export const OPERATION_DEFINITIONS: Record< write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), commit('commit', ['task_head:', 'task:'], ['task:']), ]), + plan_present: definition('plan_present', 'write-metadata', true, false, [ + prepare('validate', ['task:'], ['task:']), + write('write-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ]), review_remediation: definition( 'review_remediation', 'update-metadata', diff --git a/src/runtime/operation-journal.ts b/src/runtime/operation-journal.ts index 55e4f2a..038f8f2 100644 --- a/src/runtime/operation-journal.ts +++ b/src/runtime/operation-journal.ts @@ -15,6 +15,7 @@ export type OperationType = | 'requirements_draft' | 'requirements_finalize' | 'plan_revision' + | 'plan_present' | 'review_remediation' | 'verification_record' | 'task_complete' @@ -95,6 +96,7 @@ const OPERATION_TYPES = new Set([ 'requirements_draft', 'requirements_finalize', 'plan_revision', + 'plan_present', 'review_remediation', 'verification_record', 'task_complete', diff --git a/src/team/policy.ts b/src/team/policy.ts index 118fe59..7ed305f 100644 --- a/src/team/policy.ts +++ b/src/team/policy.ts @@ -15,10 +15,17 @@ export interface ProjectConfigV1 { /** Monotonic authority generation; every transport switch creates a new domain. */ epoch: number; }; + /** + * Planning policy 4: whether `--unattended-waiver` may approve a plan. Absent + * means allowed for local single tasks and forbidden for shared/team tasks. + */ + unattendedWaiver?: UnattendedWaiverPolicy; lastOperationId: Ulid | null; updatedAt: string; } +export type UnattendedWaiverPolicy = 'allowed' | 'forbidden'; + export interface TeamPolicyV1 { schemaVersion: 1; revision: number; @@ -53,6 +60,7 @@ export function parseProjectConfig(value: unknown): ProjectConfigV1 { 'revision', 'workspaceId', 'transport', + 'unattendedWaiver', 'lastOperationId', 'updatedAt', ], @@ -62,11 +70,21 @@ export function parseProjectConfig(value: unknown): ProjectConfigV1 { throw new Error('project config schemaVersion must be 1'); } assertUlid(value.workspaceId, 'project config workspaceId'); + if ( + value.unattendedWaiver !== undefined && + value.unattendedWaiver !== 'allowed' && + value.unattendedWaiver !== 'forbidden' + ) { + throw new Error('project config unattendedWaiver is invalid'); + } return { schemaVersion: 1, revision: parsePositiveInteger(value.revision, 'project config revision'), workspaceId: value.workspaceId, transport: parseTransport(value.transport), + ...(value.unattendedWaiver === undefined + ? {} + : { unattendedWaiver: value.unattendedWaiver }), lastOperationId: parseUlidOrNull( value.lastOperationId, 'project config lastOperationId', diff --git a/src/templates/project-progress.ts b/src/templates/project-progress.ts index 17f6aba..dfddf31 100644 --- a/src/templates/project-progress.ts +++ b/src/templates/project-progress.ts @@ -17,7 +17,7 @@ export function renderProjectProgressHtml( :root{color-scheme:light;--ink:#233e35;--muted:#65766d;--paper:#f4f6f0;--line:#d9e2d5;--green:#1d6947;--wash:#e7f0df;--warn:#986526}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.65}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid #699769;outline-offset:3px}main{max-width:1240px;margin:auto;padding:44px 32px 64px}header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{font-family:Georgia,serif;font-size:17px;letter-spacing:.04em}.eyebrow{color:var(--green);font-size:11px;letter-spacing:.18em;margin:0 0 6px}h1{font-size:32px;font-weight:600;letter-spacing:-.04em;margin:3px 0 10px}h2{font-size:20px;margin:0 0 12px}h3{font-size:15px;margin:0}.muted{color:var(--muted)}.meta{font-size:12px;max-width:720px}.connection{text-align:right;font-size:12px}.badge{display:inline-block;padding:3px 10px;border:1px solid var(--line);border-radius:30px;white-space:nowrap;background:white}.badge.good{background:var(--wash);color:var(--green)}.badge.warn{color:var(--warn);background:#faf0da}.overview{display:grid;grid-template-columns:2fr repeat(3,1fr);gap:12px;margin:26px 0}.metric{padding:18px 20px;border:1px solid var(--line);background:#fff;border-radius:10px}.metric strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:400;line-height:1.3}.metric small{color:var(--muted)}.focus{background:var(--ink);color:#f3f8ed}.focus strong{font:600 17px "PingFang SC",sans-serif;margin-bottom:8px}.focus small{color:#cad9c8}.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin-bottom:24px;overflow:auto}.tabs button{border:0;border-bottom:3px solid transparent;background:transparent;padding:13px 17px;color:var(--muted);white-space:nowrap}.tabs button[aria-selected=true]{color:var(--green);border-color:var(--green);font-weight:600}.toolbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}.toolbar input{padding:9px 13px;border:1px solid var(--line);border-radius:6px;background:white;min-width:220px}.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:20px;overflow-wrap:anywhere}.card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:10px}.card p{margin:7px 0}.small{font-size:12px}.row{display:flex;justify-content:space-between;gap:15px}.reason{border-left:3px solid #c39b5f;padding:5px 10px;background:#fbf7ed;margin-top:10px}.next{margin-top:12px!important;padding-top:10px;border-top:1px solid var(--line)}details{margin-top:13px}summary{cursor:pointer;color:var(--green)}.source{font-family:monospace;font-size:11px;color:var(--muted);overflow-wrap:anywhere}.empty{padding:54px 20px;text-align:center;border:1px dashed #bacbb8;border-radius:10px;color:var(--muted)}.timeline{border-left:1px solid #b6c9b1;margin:12px 0 12px 10px;padding-left:24px}.event{position:relative;padding:0 0 24px}.event:before{content:"";position:absolute;left:-29px;top:7px;width:9px;height:9px;border-radius:50%;background:var(--green);border:2px solid var(--paper)}.load{margin-top:20px;border:1px solid var(--line);background:white;border-radius:6px;padding:9px 18px;color:var(--green)}footer{border-top:1px solid var(--line);margin-top:40px;padding-top:18px;display:flex;justify-content:space-between;gap:16px;font-size:11px;color:var(--muted)}[hidden]{display:none!important}ul{padding-left:20px}.notice{border-left:3px solid #b69c55;background:#fbf5df;padding:10px 15px;margin:16px 0}.module-count{font:24px Georgia,serif}.panel-head{margin-bottom:18px} .board{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;align-items:start}.column{min-width:0;border:1px solid var(--line);border-radius:10px;background:#edf1e8;padding:12px}.column-head{display:flex;align-items:center;gap:8px;padding:3px 2px 13px}.column-head h3{flex:1}.column-count{font:13px Georgia,serif;color:var(--muted);border:1px solid var(--line);border-radius:20px;padding:1px 8px;background:#fff}.state-dot{width:7px;height:7px;border-radius:50%;background:#98a799;flex:none}.column-doing .state-dot{background:#648dac}.column-review .state-dot{background:#b99848}.column-blocked .state-dot{background:#bd805b}.column-blocked{background:#f5eee5;border-color:#e3d4c3}.column-body{display:grid;gap:10px}.board .card{padding:14px}.board .card-head{display:block}.board .card-head .badge{margin-top:8px;font-size:11px;padding:1px 8px}.board .empty{font-size:12px;padding:27px 10px}.task-archive{border:1px solid var(--line);border-radius:10px;background:#fff;margin-top:16px}.task-archive>summary{padding:13px 18px}.task-archive .cards{padding:0 14px 14px;grid-template-columns:repeat(3,minmax(0,1fr))}.task-archive .empty{margin:0 14px 14px}.filter-count{display:block;margin-top:5px}.focus{overflow-wrap:anywhere}@media(max-width:1050px){.board{grid-template-columns:repeat(2,minmax(0,1fr))}.task-archive .cards{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.board,.task-archive .cards{grid-template-columns:1fr}}@media(max-width:720px){main{padding:24px 16px}header{display:block}.connection{text-align:left;margin-top:12px}h1{font-size:26px}.overview{grid-template-columns:repeat(3,1fr)}.focus{grid-column:1/-1}.metric{padding:13px}.metric strong{font-size:25px}.cards{grid-template-columns:1fr}.toolbar{display:block}.toolbar input{width:100%;margin-top:10px}.tabs button{padding:12px 11px}footer{display:block}}

PROJECT CONTINUITY

mancode

项目进度

离线快照
当前重点 / 下一步
已完成 / 已登记按项数统计
已验收与完成分别记录
待处理阻塞已登记外部阻塞

任务看板

按执行状态分列;未开始不等于阻塞。展开任务查看验收与来源。
只读投影 · 状态更新不调用模型