@guanlin4924/devflow-pi 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +199 -9
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +15 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +49 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_adapter.rb +83 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +19 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checkpoint_meta.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +10 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +32 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +44 -41
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +5 -4
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +15 -4
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +15 -9
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_preflight.py +238 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +234 -32
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +63 -11
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +5 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +40 -31
  20. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +11 -5
  21. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +6 -6
  22. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +16 -1
  23. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +3 -1
  24. package/plugins/devflow-quality/agents/code-reviewer.md +7 -1
  25. package/plugins/devflow-quality/skills/code-review/SKILL.md +10 -0
  26. package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +37 -0
  27. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/SKILL.md +327 -0
  28. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/assets/output-template.md +213 -0
  29. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/bug-rules.md +9 -0
  30. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/coverage-checklist.md +63 -0
  31. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/exception-checklist.md +37 -0
  32. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/api-test.md +58 -0
  33. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/boundary-test.md +151 -0
  34. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/common-test.md +49 -0
  35. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/compatibility-test.md +105 -0
  36. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/data-test.md +67 -0
  37. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/exception-test.md +109 -0
  38. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/function-test.md +65 -0
  39. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/integration-test.md +36 -0
  40. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/performance-test.md +100 -0
  41. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/scenario-test.md +63 -0
  42. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/security-test.md +55 -0
  43. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/ui-test.md +49 -0
  44. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/usability-test.md +46 -0
  45. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/writing-guide.md +111 -0
  46. package/src/constants.ts +1 -1
package/README.md CHANGED
@@ -117,6 +117,10 @@ Devflow 会在输入框上方显示“当前 Feature 交付链路”:它基于
117
117
 
118
118
  正常使用不需要记忆底层命令:在 Devflow 工作流中按步骤继续即可。需要排查时,可用 `devflow_acceptance_tests` 查看冻结套件,或由工作流调用 `devflow_tdd_evidence` 与 `devflow_run_acceptance_tests` 生成 Core-owned 证据;不要手写“已测试”来替代结果文件。
119
119
 
120
+ ### Core 软门禁模式
121
+
122
+ 默认情况下,Core 生成的测试绑定、追溯、merge gate、产物有效性和图谱新鲜度问题会记录为 advisory,交给 AI 审查决定继续、修改或阻塞;只有控制面损坏、越权路径、并发冲突等安全问题仍会硬阻断。需要临时恢复旧的严格门禁时,启动前设置 `DEVFLOW_SOFT_GATES=0`。
123
+
120
124
  ## 更新
121
125
 
122
126
  升级 npm 发行版:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
@@ -183,6 +183,7 @@ const READ_ONLY_TOOLS = new Set([
183
183
  "devflow_usage_stats",
184
184
  "usage_stats",
185
185
  "devflow_project_context",
186
+ "devflow_review_preflight",
186
187
  ]);
187
188
  const REQUIREMENT_DRAFT_TOOLS = new Set([...READ_ONLY_TOOLS, "ask"]);
188
189
  // A Todo update changes only the client-visible task list, never the project
@@ -526,7 +527,7 @@ const CONTROL_PATHS = [
526
527
  ".planning/artifacts/index.yaml",
527
528
  ];
528
529
 
529
- const CONTROL_PREFIXES = [".planning/workflows/", ".planning/contracts/", ".planning/evidence/"];
530
+ const CONTROL_PREFIXES = [".planning/workflows/", ".planning/contracts/", ".planning/evidence/", ".planning/review-runs/"];
530
531
 
531
532
  const REQUIRED_EXTENSION_TOOLS = [
532
533
  "devflow_doctor",
@@ -570,6 +571,7 @@ const REQUIRED_SKILLS = [
570
571
  "workflow-execute-phase",
571
572
  "workflow-feature-breakdown",
572
573
  "workflow-frontend-tech-spec",
574
+ "workflow-gen-test-guide",
573
575
  "workflow-generate-mainflow-cases",
574
576
  "workflow-integration-verify",
575
577
  "workflow-orchestrator",
@@ -621,6 +623,7 @@ const REQUIRED_SCRIPTS = [
621
623
  "workflow-orchestrator/scripts/tdd_evidence.py",
622
624
  "workflow-orchestrator/scripts/acceptance_test_runner.py",
623
625
  "workflow-orchestrator/scripts/machine_evidence.rb",
626
+ "workflow-orchestrator/scripts/review_preflight.py",
624
627
  "workflow-orchestrator/scripts/cross_track_consistency_check.rb",
625
628
  "workflow-orchestrator/scripts/execution_contract.rb",
626
629
  "workflow-code-index/scripts/code_index.py",
@@ -699,6 +702,7 @@ function assertRuntimeProjectBoundary(cwd: string): void {
699
702
  ".planning/.devflow.lock",
700
703
  ".planning/workflows",
701
704
  ".planning/artifacts",
705
+ ".planning/review-runs",
702
706
  ]) {
703
707
  const target = path.join(resolvedCwd, relative);
704
708
  try {
@@ -746,7 +750,9 @@ function isCoreOwnedGovernancePath(filePath: string): boolean {
746
750
  // same ownership extends to its views, machine evidence, and audit records.
747
751
  return isCanonicalRequirementGraphPath(filePath)
748
752
  || /^\.planning\/evidence\/[^/\\\x00-\x1F\x7F]+\/[^/\\]+\.machine\.json$/u.test(filePath)
749
- || /^\.planning\/contracts\/[^/\\\x00-\x1F\x7F]+\/(?:EC-[A-Fa-f0-9]+\.json|control-events\.jsonl)$/u.test(filePath);
753
+ || /^\.planning\/contracts\/[^/\\\x00-\x1F\x7F]+\/(?:EC-[A-Fa-f0-9]+\.json|control-events\.jsonl)$/u.test(filePath)
754
+ || filePath.startsWith(".planning/review-runs/")
755
+ || /^\.planning\/phases\/[^/\\\x00-\x1F\x7F]+\/REVIEW\.md$/u.test(filePath);
750
756
  }
751
757
 
752
758
  function planningFeatureFromPath(filePath: string): string | null {
@@ -892,6 +898,17 @@ function parseJsonResult(result: ProcessResult): Record<string, unknown> | null
892
898
  }
893
899
  }
894
900
 
901
+ export function canonicalReviewJson(value: unknown): string {
902
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
903
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
904
+ if (Array.isArray(value)) return `[${value.map(canonicalReviewJson).join(",")}]`;
905
+ if (value && typeof value === "object") {
906
+ const record = value as Record<string, unknown>;
907
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalReviewJson(record[key])}`).join(",")}}`;
908
+ }
909
+ throw new Error("Review JSON contains a non-JSON value");
910
+ }
911
+
895
912
  /** Ruby control-plane scripts report expected validation failures as JSON with
896
913
  * `ok: false` and intentionally keep a zero process exit code. Never mistake
897
914
  * that structured failure for a successful state mutation. */
@@ -917,6 +934,7 @@ function compactStep(value: unknown): Record<string, unknown> | null {
917
934
  required: record.required,
918
935
  exists: record.exists,
919
936
  artifact_status: record.artifact_status,
937
+ content_valid: record.content_valid,
920
938
  };
921
939
  })
922
940
  : undefined,
@@ -943,6 +961,10 @@ function isReviewableWorkflowSnapshot(snapshot: Record<string, unknown>): boolea
943
961
  if (!step || typeof step !== "object") return false;
944
962
  const stepRecord = step as Record<string, unknown>;
945
963
  if (typeof stepRecord.id !== "string" || !stepRecord.id) return false;
964
+ // Core-generated quality findings are advisory by default. Re-arm the AI
965
+ // review even when an output is stale or its generated validity metadata is
966
+ // incomplete; only the control-plane shape itself remains a hard boundary.
967
+ if (process.env.DEVFLOW_SOFT_GATES !== "0" && snapshot.action === "await_user_confirmation") return true;
946
968
  const outputs = Array.isArray(stepRecord.outputs) ? stepRecord.outputs : [];
947
969
  const requiredOutputs = outputs.filter(output => {
948
970
  if (!output || typeof output !== "object") return false;
@@ -951,7 +973,13 @@ function isReviewableWorkflowSnapshot(snapshot: Record<string, unknown>): boolea
951
973
  return requiredOutputs.length > 0 && requiredOutputs.every(output => {
952
974
  if (!output || typeof output !== "object") return false;
953
975
  const record = output as Record<string, unknown>;
954
- return record.exists === true && !["failed", "blocked"].includes(String(record.artifact_status ?? ""));
976
+ // Older/current inspect_state payloads may omit content_valid even when
977
+ // Core has already validated and registered the artifact. Treat an
978
+ // explicit false as invalid, but use exists + artifact_status as the
979
+ // durable fallback when the field is absent.
980
+ return record.exists === true
981
+ && record.content_valid !== false
982
+ && !["failed", "blocked", "planned"].includes(String(record.artifact_status ?? ""));
955
983
  });
956
984
  }
957
985
 
@@ -992,6 +1020,16 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
992
1020
 
993
1021
  function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string): string {
994
1022
  const selectedStep = selectedWorkflowStep(snapshot);
1023
+ const reviewInstructions = selectedStep?.id === "code-review"
1024
+ ? [
1025
+ "派发审查 task 前先调用 devflow_review_preflight,使用当前 Core 冻结契约和审查者 devflow-code-reviewer;只有 ready 才派发,human_confirmation 与 core_contract_conflict 均停止并上报。ready 只是可执行性预检,不是审查通过。",
1026
+ "本步骤由主 Agent 使用 task 显式指定 devflow-code-reviewer 执行独立只读审查;主 Agent 不得代替审查者给出 clean 结论。",
1027
+ "任务包只能引用已确认的 Core 需求/契约、固定版本源码和测试证据;主 Agent 的摘要不是验收事实。审查者须独立回读事实与源码,返回发现、证据、未决人工问题和改进建议。",
1028
+ "若需第二或第三轮审查,任务包必须携带前轮 chain_id,审查者须在 JSON 中原样返回;每轮限一个独立审查 task,最多三轮。P0/P1 影响和触发条件必须引用预先冻结的 Core 测试用例事实,缺失则转人工确认。",
1029
+ "审查 task 返回严格 JSON 后,主 Agent 调用 devflow_review_submit({}) 提交该 task 的原始结果;不得重写观察项或自报 clean。随后将 Core 返回的裁决、证据链、开放 finding 和下一步动作完整交给用户。",
1030
+ "审查报告及 task 返回文本不构成 Core 门禁通过;证据不足或审查服务返回 awaiting_human_confirmation 时必须保留阻断并报告给主 Agent。",
1031
+ ]
1032
+ : [];
995
1033
  const envelope = {
996
1034
  source,
997
1035
  action: snapshot.action,
@@ -1021,6 +1059,7 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
1021
1059
  "Devflow 已通过启动预检。读取 skill://workflow-pipeline,并只执行当前项目的一个工作流步骤。",
1022
1060
  "先调用 devflow_state(action=\"inspect\") 重新确认 revision,再调用 devflow_contract(action=\"status\");若没有匹配当前 feature/current_step 的 fresh contract,立即调用 devflow_contract(action=\"activate\", feature=<current_feature>, step=<current_step>),激活成功后才能执行或写入。",
1023
1061
  "只按 inspector 返回的当前 step 加载所需 Skill:step.skill 是精确 ID,必须读取 skill://<step.skill>,不得自行添加 workflow- 前缀或改写名称;本回合严禁读取或写入 xd://devflow_transition,严禁调用 devflow_transition,严禁执行 Skill 中的 complete-step/confirm-checkpoint,不得推进下一步骤;状态写回和会话切换由 Runtime 在用户确认后独占处理。",
1062
+ ...reviewInstructions,
1024
1063
  ...(parallelInstructions.length > 0 ? parallelInstructions : [
1025
1064
  "完成本步骤的真实产物后,报告证据、未决项和验证结果并停止。请用户审查后明确回复“批准执行”才可推进;回复修改意见或“拒绝执行”则不得推进。",
1026
1065
  ]),
@@ -1135,11 +1174,11 @@ export function isShellControlMutation(toolName: string, input: unknown): boolea
1135
1174
  const looksLikeWrite = /(?:^|[;&|]\s*)(?:rm|mv|cp|install|truncate)\b|(?:^|\s)(?:tee|sed\s+-i|perl\s+-i)\b|(?:^|[\s;&|])(?:1)?>{1,2}(?![>=&])\s*\S/.test(
1136
1175
  command,
1137
1176
  );
1138
- return invokesWriter || (namesControlPath && looksLikeWrite);
1177
+ return invokesWriter || (namesControlPath && (looksLikeWrite || shellMutationPaths(command, ".").mutating));
1139
1178
  }
1140
1179
 
1141
1180
  function shellMutationPaths(command: string, cwd: string): { mutating: boolean; paths: string[] } {
1142
- const mutating = /(?:\bsed\s+-i\b|\bperl\s+-i\b|\b(?:cp|mv|rm|install|truncate|tee)\b|(?:^|\s)>{1,2}\s*\S|\b(?:python3?|node|ruby)\b[^\n]*(?:writeFile|write_text|open\([^\n]*['\"]w|unlink|rename)\b)/i.test(command);
1181
+ const mutating = /(?:\bsed\s+-i\b|\bperl\s+-i\b|\b(?:cp|mv|rm|install|truncate|tee)\b|(?:^|\s)>{1,2}(?![>=&])\s*\S|\b(?:python3?|node|ruby)\b[^\n]*(?:writeFile|write_text|open\([^\n]*['\"]w|unlink|rename)\b)/i.test(command);
1143
1182
  if (!mutating) return { mutating: false, paths: [] };
1144
1183
  const candidates = command.match(/(?:^|\s|>)([A-Za-z0-9_./~-]+)(?=\s|$|2?>)/g) ?? [];
1145
1184
  const paths = candidates
@@ -1164,6 +1203,7 @@ const CONTRACT_READ_TOOLS = new Set([
1164
1203
  "devflow_acceptance_tests",
1165
1204
  "devflow_doctor",
1166
1205
  "devflow_state",
1206
+ "devflow_review_preflight",
1167
1207
  "devflow_usage_stats",
1168
1208
  "usage_stats",
1169
1209
  ]);
@@ -1207,6 +1247,7 @@ function stepScopedMutationOwner(toolName: string, input: unknown): string | nul
1207
1247
  const step = input && typeof input === "object" ? (input as Record<string, unknown>).step : undefined;
1208
1248
  return typeof step === "string" && step ? step : null;
1209
1249
  }
1250
+ if (normalized === "devflow_review_submit" || normalized === "devflow_review_render") return "code-review";
1210
1251
  // New mutating Devflow tools must declare a step above or be explicitly
1211
1252
  // classified read/control-only; they never inherit a broad devflow_* bypass.
1212
1253
  return null;
@@ -1362,6 +1403,10 @@ export default function devflowCore(pi: ExtensionAPI) {
1362
1403
  let controlState: SessionControlState = initialControlState();
1363
1404
  let activeContract: ActiveExecutionContract | undefined;
1364
1405
  let contractAgentCalls = 0;
1406
+ const reviewBindingKey = randomBytes(32).toString("hex");
1407
+ const pendingReviewTasks = new Map<string, { feature: string; contractId: string }>();
1408
+ const completedReviewTasks = new Map<string, { feature: string; contractId: string; report: Record<string, unknown> }>();
1409
+ let latestReviewTaskCallId: string | undefined;
1365
1410
  let projectRoot: string | undefined;
1366
1411
 
1367
1412
  async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
@@ -2773,10 +2818,36 @@ export default function devflowCore(pi: ExtensionAPI) {
2773
2818
  ) {
2774
2819
  return { block: true, reason: `Active contract ${activeContract.contractId} only allows agents: ${activeContract.allowedAgents.join(", ")}` };
2775
2820
  }
2776
- if (activeContract && controlState.phase === "executing" && contractAgentCalls >= activeContract.toolBudget) {
2821
+ if (activeContract && controlState.phase === "executing" && contractAgentCalls + requested.names.length > activeContract.toolBudget) {
2777
2822
  return { block: true, reason: `Active contract ${activeContract.contractId} exhausted its fixed agent budget (${activeContract.toolBudget}).` };
2778
2823
  }
2779
- if (activeContract && controlState.phase === "executing") contractAgentCalls += 1;
2824
+ if (activeContract && controlState.phase === "executing" && activeContract.step === "code-review" && requested.names.length !== 1) {
2825
+ return { block: true, reason: "Code review requires exactly one independent reviewer task." };
2826
+ }
2827
+ if (activeContract && controlState.phase === "executing" && activeContract.step === "code-review") {
2828
+ const preflight = await runProcess([
2829
+ "python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_preflight.py"),
2830
+ "--root", ctx.cwd,
2831
+ "--feature", activeContract.feature,
2832
+ "--step", activeContract.step,
2833
+ "--contract", `.planning/contracts/${activeContract.feature}/${activeContract.contractId}.json`,
2834
+ "--obligations", "[]",
2835
+ "--review-agent", "devflow-code-reviewer",
2836
+ "--stage", "entry",
2837
+ ], ctx.cwd);
2838
+ let result: Record<string, unknown> | null = null;
2839
+ try {
2840
+ result = JSON.parse(preflight.stdout) as Record<string, unknown>;
2841
+ } catch {
2842
+ // An unparseable Core preflight result cannot authorize a reviewer task.
2843
+ }
2844
+ if (!preflight.ok || result?.status !== "ready") {
2845
+ return { block: true, reason: `Code review task preflight did not pass: ${preflight.stdout || preflight.stderr || "unavailable"}` };
2846
+ }
2847
+ if (!toolCallId) return { block: true, reason: "Code review task needs a runtime tool-call identity." };
2848
+ pendingReviewTasks.set(toolCallId, { feature: activeContract.feature, contractId: activeContract.contractId });
2849
+ }
2850
+ if (activeContract && controlState.phase === "executing") contractAgentCalls += requested.names.length;
2780
2851
  } else if (
2781
2852
  controlState.phase === "planning" &&
2782
2853
  toolName !== "devflow_contract" &&
@@ -2924,6 +2995,30 @@ export default function devflowCore(pi: ExtensionAPI) {
2924
2995
  });
2925
2996
 
2926
2997
  pi.on("tool_result", async (event, ctx) => {
2998
+ if (event.toolName === "task") {
2999
+ const binding = pendingReviewTasks.get(event.toolCallId);
3000
+ if (binding) {
3001
+ pendingReviewTasks.delete(event.toolCallId);
3002
+ const details = event.details && typeof event.details === "object" ? event.details as Record<string, unknown> : {};
3003
+ const results = Array.isArray(details.results) ? details.results : [];
3004
+ const child = results.length === 1 && results[0] && typeof results[0] === "object"
3005
+ ? results[0] as Record<string, unknown> : null;
3006
+ if (!event.isError && child?.agent === "devflow-code-reviewer" && child.exitCode === 0 && child.truncated === false && typeof child.output === "string") {
3007
+ try {
3008
+ const report = JSON.parse(child.output) as unknown;
3009
+ if (report && typeof report === "object" && !Array.isArray(report)
3010
+ && !("core_review_binding" in report)
3011
+ && (report as Record<string, unknown>).target && typeof (report as Record<string, unknown>).target === "object"
3012
+ && ((report as Record<string, unknown>).target as Record<string, unknown>).feature === binding.feature) {
3013
+ completedReviewTasks.set(event.toolCallId, { ...binding, report: report as Record<string, unknown> });
3014
+ latestReviewTaskCallId = event.toolCallId;
3015
+ }
3016
+ } catch {
3017
+ // A prose or malformed child response is not an attested review.
3018
+ }
3019
+ }
3020
+ }
3021
+ }
2927
3022
  const taskContext = parallelTaskContexts.get(event.toolCallId);
2928
3023
  if (parallelRunActive && event.toolName === "task") {
2929
3024
  if (taskResultHasSuccessfulDispatch(event.details)) {
@@ -3669,7 +3764,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3669
3764
  parameters: z.object({
3670
3765
  feature: z.string().min(1),
3671
3766
  phase: z.string().min(1).optional(),
3672
- flow: z.string().min(1).default("feature-development"),
3767
+ flow: z.enum(["feature-development", "acceptance-remediation"]).default("feature-development"),
3673
3768
  force: z.boolean().default(false),
3674
3769
  }),
3675
3770
  approval: "write",
@@ -3824,6 +3919,101 @@ export default function devflowCore(pi: ExtensionAPI) {
3824
3919
  },
3825
3920
  });
3826
3921
 
3922
+ pi.registerTool({
3923
+ name: "devflow_review_preflight",
3924
+ label: "Devflow Review Preflight",
3925
+ description: "Read-only Core preflight for current step facts, contract revisions, production obligations, and reviewer task reachability. Ready is not gate approval.",
3926
+ parameters: z.object({
3927
+ feature: z.string().min(1),
3928
+ step: z.string().min(1),
3929
+ contract: z.string().min(1),
3930
+ obligations: z.array(z.record(z.string(), z.unknown())).default([]),
3931
+ reviewAgent: z.string().optional(),
3932
+ stage: z.enum(["entry", "completion"]).default("entry"),
3933
+ }),
3934
+ approval: "read",
3935
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3936
+ assertRuntimeProjectBoundary(ctx.cwd);
3937
+ return renderResult(await runProcess([
3938
+ "python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_preflight.py"),
3939
+ "--root", ctx.cwd,
3940
+ "--feature", params.feature,
3941
+ "--step", params.step,
3942
+ "--contract", params.contract,
3943
+ "--obligations", JSON.stringify(params.obligations),
3944
+ "--stage", params.stage,
3945
+ ...(params.reviewAgent ? ["--review-agent", params.reviewAgent] : []),
3946
+ ], ctx.cwd));
3947
+ },
3948
+ });
3949
+
3950
+ pi.registerTool({
3951
+ name: "devflow_review_submit",
3952
+ label: "Devflow Bound Review Submission",
3953
+ description: "Submit the exact JSON returned by the completed independent reviewer task to Core. Caller text cannot replace the child report.",
3954
+ parameters: z.object({ taskCallId: z.string().optional() }),
3955
+ approval: "write",
3956
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3957
+ assertRuntimeProjectBoundary(ctx.cwd);
3958
+ const taskCallId = params.taskCallId || latestReviewTaskCallId;
3959
+ const captured = taskCallId ? completedReviewTasks.get(taskCallId) : undefined;
3960
+ if (!taskCallId || !captured || activeContract?.step !== "code-review"
3961
+ || activeContract.feature !== captured.feature || activeContract.contractId !== captured.contractId) {
3962
+ return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "No completed reviewer task is bound to the active code-review contract." });
3963
+ }
3964
+ const reportSha256 = createHash("sha256").update(canonicalReviewJson(captured.report), "utf8").digest("hex");
3965
+ const identity = {
3966
+ agent: "devflow-code-reviewer",
3967
+ feature: captured.feature,
3968
+ contract_id: captured.contractId,
3969
+ task_call_id: taskCallId,
3970
+ report_sha256: reportSha256,
3971
+ };
3972
+ const signature = createHmac("sha256", reviewBindingKey).update(canonicalReviewJson(identity), "utf8").digest("hex");
3973
+ const request = { ...captured.report, core_review_binding: { ...identity, signature } };
3974
+ const result = await runProcess(
3975
+ ["python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_run.py"), ctx.cwd],
3976
+ ctx.cwd,
3977
+ { DEVFLOW_REVIEW_BINDING_KEY: reviewBindingKey },
3978
+ JSON.stringify(request),
3979
+ );
3980
+ const submitted = parseJsonResult(result);
3981
+ if (!result.ok || !submitted) return renderResult(result, { taskCallId, feature: captured.feature, contractId: captured.contractId });
3982
+ completedReviewTasks.delete(taskCallId);
3983
+ if (!["clean", "findings", "terminated_p0"].includes(String(submitted.status))) {
3984
+ return renderResult(result, { taskCallId, feature: captured.feature, contractId: captured.contractId });
3985
+ }
3986
+ const script = path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_run.py");
3987
+ const rendered = await runProcess(["python3", script, ctx.cwd, "--render", captured.feature, captured.contractId], ctx.cwd);
3988
+ const renderPayload = parseJsonResult(rendered);
3989
+ if (!rendered.ok || renderPayload?.status !== "rendered") {
3990
+ return renderResult({ ok: false, exitCode: 1, stdout: JSON.stringify({ review_result: submitted, render: renderPayload }), stderr: rendered.stderr || "Core review report rendering failed; call devflow_review_render to retry." }, { taskCallId });
3991
+ }
3992
+ const gated = await runProcess(["python3", script, ctx.cwd, "--gate", captured.feature, captured.contractId], ctx.cwd);
3993
+ const gatePayload = parseJsonResult(gated);
3994
+ if (!gated.ok || !gatePayload) return renderResult(gated, { taskCallId, review_result: submitted });
3995
+ return renderResult({ ok: true, exitCode: 0, stdout: JSON.stringify({ review_result: submitted, gate: gatePayload }), stderr: "" }, { taskCallId, feature: captured.feature, contractId: captured.contractId });
3996
+ },
3997
+ });
3998
+
3999
+ pi.registerTool({
4000
+ name: "devflow_review_render",
4001
+ label: "Devflow Core Review Report",
4002
+ description: "Regenerate the code-review Markdown view from the latest Core-bound review round; it does not change the verdict.",
4003
+ parameters: z.object({}),
4004
+ approval: "write",
4005
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
4006
+ assertRuntimeProjectBoundary(ctx.cwd);
4007
+ if (activeContract?.step !== "code-review") {
4008
+ return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "A current code-review contract is required." });
4009
+ }
4010
+ return renderResult(await runProcess([
4011
+ "python3", path.join(resolveKitRoot(ctx.cwd), "workflow-orchestrator/scripts/review_run.py"),
4012
+ ctx.cwd, "--render", activeContract.feature, activeContract.contractId,
4013
+ ], ctx.cwd));
4014
+ },
4015
+ });
4016
+
3827
4017
  pi.registerTool({
3828
4018
  name: "devflow_review_run",
3829
4019
  label: "Devflow Fact-Bound Review",
@@ -393,6 +393,21 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
393
393
  "execution_binding_fingerprint": file_fingerprint(execution_bindings_path), "results": results, "passed": all_passed, "history": history[-20:]}
394
394
  if write:
395
395
  write_json(result_path, payload)
396
+ meta_path = result_path.with_name("acceptance-results.meta.json")
397
+ passed = sum(1 for item in results if item.get("status") == "passed")
398
+ failed = sum(1 for item in results if item.get("status") == "failed")
399
+ blocked = sum(1 for item in results if item.get("status") == "blocked")
400
+ write_json(meta_path, {
401
+ "schema_version": 1,
402
+ "feature": feature,
403
+ "acceptance_tc_passed": passed,
404
+ "acceptance_tc_failed": failed,
405
+ "acceptance_tc_blocked": blocked,
406
+ "acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
407
+ "acceptance_results_fingerprint": file_fingerprint(result_path),
408
+ "built_by": "core_acceptance_test_runner",
409
+ "built_at": payload["executed_at"],
410
+ })
396
411
  return {"ok": all_passed, "feature": feature, "path": str(result_path.relative_to(root)), "results": results,
397
412
  "errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
398
413
 
@@ -108,7 +108,7 @@ def transition_gate_contract(workflow, step)
108
108
  "topology" => {
109
109
  "allowed_tracks" => allowed_tracks,
110
110
  "allowed_predecessors" => Array(step["depends_on"]),
111
- "allowed_intervening_predecessors" => immediate_prior ? [immediate_prior["id"]] : [],
111
+ "allowed_intervening_predecessors" => ((immediate_prior ? [immediate_prior["id"]] : []) + Array(step["allowed_intervening_predecessors"])).uniq,
112
112
  "allowed_reentry" => reentries,
113
113
  "checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
114
114
  },
@@ -529,8 +529,40 @@ def repair_legacy_parallel_join_predecessor!(state, workflow)
529
529
  predecessor
530
530
  end
531
531
 
532
+ # Business-quality checkers are advisory by default. The runtime still keeps
533
+ # a small safety floor for malformed control-plane state and unsafe writes, but
534
+ # Core-generated test/traceability/merge findings no longer strand the serial
535
+ # workflow before the AI review can decide what to do.
536
+ SOFT_GATE_MODE = ENV.fetch("DEVFLOW_SOFT_GATES", "1") != "0"
537
+ HARD_FAILURE_REASONS = %w[
538
+ missing_state_file
539
+ missing_state_front_matter
540
+ stale_revision
541
+ invalid_control_path
542
+ unknown_step
543
+ gate_target_step_unknown
544
+ gate_current_step_mismatch
545
+ parallel_transition_requires_write
546
+ feature_not_active
547
+ cancel_reason_required
548
+ session_rollover_in_progress
549
+ failure_branch_incomplete
550
+ parallel_machine_evidence_failed
551
+ parallel_join_failed
552
+ parallel_branch_transition_failed
553
+ cross_track_consistency_failed
554
+ business_cross_model_check_failed
555
+ gate_boundary_rejected
556
+ ].freeze
557
+ $devflow_advisories = []
558
+
532
559
  def fail_result(reason, details = {})
533
- { "ok" => false, "reason" => reason, "details" => details }
560
+ failure = { "ok" => false, "reason" => reason, "details" => details }
561
+ if SOFT_GATE_MODE && !HARD_FAILURE_REASONS.include?(reason.to_s)
562
+ $devflow_advisories << failure
563
+ return nil
564
+ end
565
+ failure
534
566
  end
535
567
 
536
568
  # CP-3 has to validate a *candidate* task/TC binding before it freezes the
@@ -791,6 +823,19 @@ result =
791
823
  # GateRuntime is a read-only boundary check. It runs while the project
792
824
  # lock is held and before any candidate state mutation; the existing
793
825
  # validator and advance_state CAS remain the final write guard.
826
+ if result.nil? && gate_contract_enabled?(workflow) &&
827
+ !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
828
+ (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
829
+ topology_errors = WorkflowGateContract.validate(workflow)
830
+ unless topology_errors.empty?
831
+ result = fail_result("gate_boundary_rejected", "gate" => {
832
+ "verdict" => "deny",
833
+ "reason" => "workflow_gate_contract_invalid",
834
+ "errors" => topology_errors,
835
+ })
836
+ end
837
+ end
838
+
794
839
  if result.nil? && gate_contract_enabled?(workflow) &&
795
840
  !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
796
841
  (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
@@ -1187,7 +1232,7 @@ result =
1187
1232
  phase_owner_override: prospective_owner
1188
1233
  )
1189
1234
 
1190
- if changed && !validation["ok"]
1235
+ if changed && !validation["ok"] && !SOFT_GATE_MODE
1191
1236
  result = fail_result("state_validation_failed", "validation" => validation)
1192
1237
  elsif changed && options[:write]
1193
1238
  ownership = PhaseOwnership.claim(
@@ -1249,6 +1294,7 @@ result =
1249
1294
  "actions" => actions,
1250
1295
  "state_path" => state_path,
1251
1296
  "next_intent_written" => next_intent_written,
1297
+ "advisories" => $devflow_advisories,
1252
1298
  "state_validation" => validation,
1253
1299
  "state" => state
1254
1300
  }
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The single execution boundary for Core checkers. Registry membership alone
4
+ # is not evidence that a checker can run; every non graph-backed checker must
5
+ # have an adapter here.
6
+ require "json"
7
+ require "open3"
8
+ require_relative "checker_registry"
9
+
10
+ module CheckerAdapter
11
+ module_function
12
+
13
+ def execute(root:, feature:, step:, id:, graph_results:)
14
+ CheckerRegistry.validate_executable!([id])
15
+ if CheckerRegistry::GRAPH_BACKED.include?(id.to_s)
16
+ issues = graph_results.fetch(id.to_s, ["checker produced no graph result: #{id}"])
17
+ return result(id, issues.empty?, issues)
18
+ end
19
+
20
+ command = command_for(root, feature, step, id)
21
+ return result(id, false, ["no adapter command for #{id}"]) unless command
22
+
23
+ stdout, stderr, status = Open3.capture3(*command, chdir: root)
24
+ payload = JSON.parse(stdout)
25
+ passed = status.success? && payload.is_a?(Hash) && (payload["ok"] == true || payload["valid"] == true)
26
+ issues = if passed
27
+ []
28
+ elsif payload.is_a?(Hash) && payload["errors"].is_a?(Array)
29
+ payload["errors"].map { |item| item.is_a?(String) ? item : JSON.generate(item) }
30
+ else
31
+ [stderr.strip.empty? ? "#{id} failed" : stderr.strip]
32
+ end
33
+ result(id, passed, issues)
34
+ rescue JSON::ParserError => error
35
+ result(id, false, ["#{id} returned invalid JSON: #{error.message}"])
36
+ rescue StandardError => error
37
+ result(id, false, ["#{id} adapter failed: #{error.message}"])
38
+ end
39
+
40
+ def result(id, passed, issues)
41
+ { "id" => id, "origin" => "core_fixed_checker", "exit_code" => passed ? 0 : 1,
42
+ "passed" => passed, "issues" => passed ? [] : Array(issues),
43
+ "counters" => { "issue_count" => passed ? 0 : Array(issues).length } }
44
+ end
45
+
46
+ def command_for(root, feature, step, id)
47
+ scripts = File.expand_path(__dir__)
48
+ case id.to_s
49
+ when "test-task-binding-check"
50
+ stage = step.to_s == "change-plan" ? "candidate" : "frozen"
51
+ ["python3", File.join(scripts, "test_task_binding_check.py"), "--feature", feature, "--stage", stage, "--json"]
52
+ when "tdd-evidence-check"
53
+ ["python3", File.join(scripts, "tdd_evidence.py"), "validate", "--feature", feature, "--json"]
54
+ when "acceptance-test-results-check"
55
+ ["python3", File.join(scripts, "acceptance_test_runner.py"), "validate-results", "--feature", feature, "--json"]
56
+ when "api-test-matrix-check"
57
+ ["python3", File.join(scripts, "api_test_matrix.py"), "validate", "--feature", feature, "--json"]
58
+ when "api-test-schema-check"
59
+ ["python3", File.join(scripts, "api_test_matrix.py"), "check", "--feature", feature, "--json"]
60
+ when "cross-track-consistency-check"
61
+ ["ruby", File.join(scripts, "cross_track_consistency_check.rb"), "--feature", feature, "--json"]
62
+ when "business-cross-model-check"
63
+ business = ".planning/business-model/#{feature}/canonical.json"
64
+ return nil unless File.file?(File.join(root, business))
65
+ command = ["python3", File.join(scripts, "business_cross_model_check.py"), "--business-canonical", business]
66
+ wireframe = ".planning/features/#{feature}/canonical.json"
67
+ claims = ".planning/features/#{feature}/business-claims.yaml"
68
+ traceability = ".planning/requirements/#{feature}/traceability.json"
69
+ command += ["--wireframe", wireframe] if File.file?(File.join(root, wireframe))
70
+ command += ["--claims", claims] if File.file?(File.join(root, claims))
71
+ command += ["--traceability", traceability] if File.file?(File.join(root, traceability))
72
+ command + ["--json"]
73
+ when "business-claim-reference-check"
74
+ claims = ".planning/features/#{feature}/business-claims.yaml"
75
+ business = ".planning/business-model/#{feature}/canonical.json"
76
+ return nil unless File.file?(File.join(root, claims)) && File.file?(File.join(root, business))
77
+ command = ["python3", File.join(scripts, "business_claim_validate.py"), "--claims", claims, "--business-canonical", business]
78
+ traceability = ".planning/requirements/#{feature}/traceability.json"
79
+ command += ["--traceability", traceability] if File.file?(File.join(root, traceability))
80
+ command
81
+ end
82
+ end
83
+ end
@@ -11,6 +11,15 @@ module CheckerRegistry
11
11
  business-cross-model-check business-invalidation-check
12
12
  ].freeze
13
13
  EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
14
+ GRAPH_BACKED = %w[
15
+ requirement-graph-check slice-independence-check contract-coverage-check
16
+ change-impact-check acceptance-coverage-check test-coverage-check test-obligation-check
17
+ ].freeze
18
+ ADAPTERS = %w[
19
+ api-test-schema-check api-test-matrix-check acceptance-test-results-check
20
+ test-task-binding-check tdd-evidence-check cross-track-consistency-check
21
+ business-cross-model-check business-claim-reference-check
22
+ ].freeze
14
23
 
15
24
  module_function
16
25
 
@@ -24,6 +33,16 @@ module CheckerRegistry
24
33
  nil
25
34
  end
26
35
 
36
+ def executable?(id)
37
+ GRAPH_BACKED.include?(id.to_s) || ADAPTERS.include?(id.to_s)
38
+ end
39
+
40
+ def validate_executable!(ids)
41
+ unsupported = Array(ids).map(&:to_s).reject { |id| executable?(id) }
42
+ raise ArgumentError, "checker has no Core execution adapter: #{unsupported.join(', ')}" unless unsupported.empty?
43
+ true
44
+ end
45
+
27
46
  def validate!(ids)
28
47
  unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
29
48
  raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module CheckpointMeta
7
+ module_function
8
+
9
+ def validate(root:, state:, checkpoint:)
10
+ return [] unless checkpoint.is_a?(Hash)
11
+
12
+ raw_path = checkpoint["required_meta_output"]
13
+ # Legacy/custom checkpoints may not declare checkpoint metadata. Only
14
+ # enforce the contract when a metadata path is explicitly declared.
15
+ return [] if raw_path.to_s.empty?
16
+
17
+ relative = raw_path.to_s
18
+ .gsub("{feature}", state.fetch("current_feature", "").to_s)
19
+ .gsub("{phase}", state.fetch("current_phase", "").to_s)
20
+ absolute = File.expand_path(relative, root)
21
+ unless File.file?(absolute)
22
+ return [{ "code" => "missing_checkpoint_meta_output", "message" => "checkpoint meta output is missing", "path" => relative }]
23
+ end
24
+
25
+ data = JSON.parse(File.read(absolute))
26
+ errors = []
27
+ unless data.is_a?(Hash)
28
+ errors << { "code" => "invalid_checkpoint_meta_shape", "message" => "checkpoint meta output must be a JSON object", "path" => relative }
29
+ return errors
30
+ end
31
+
32
+ Array(checkpoint["required_meta_fields"]).each do |field|
33
+ next if data.key?(field.to_s) && !data[field.to_s].nil?
34
+
35
+ errors << {
36
+ "code" => "missing_checkpoint_meta_field",
37
+ "message" => "checkpoint meta output is missing a required field",
38
+ "path" => relative,
39
+ "field" => field
40
+ }
41
+ end
42
+ errors
43
+ rescue JSON::ParserError => error
44
+ [{ "code" => "invalid_checkpoint_meta_json", "message" => error.message, "path" => relative }]
45
+ end
46
+ end