agent-project-sdlc 0.1.21 → 0.1.23

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.
@@ -9,6 +9,8 @@ const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
9
9
  const PARALLEL_TRIGGERS = new Set(["user_requested", "workflow_default"]);
10
10
  const PARALLEL_RUNTIME_PROVIDERS = new Set(["codex_native_subagents", "user_orchestrated", "codex_exec_worktree"]);
11
11
  const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
12
+ const RESERVED_SUSPENDED_PHASE_TARGET = "<suspended_phase>";
13
+ const TRANSITION_KINDS = new Set(["normal", "return", "interrupt", "resume"]);
12
14
  const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
13
15
  const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
14
16
  const PARALLEL_PROTECTED_WRITE_PATTERNS = [
@@ -89,6 +91,7 @@ const TEST_REPORT_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待
89
91
  const TEST_FACT_SOURCE_PHASES = new Set(["TESTING", "RFC_RECALIBRATION"]);
90
92
  const TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"];
91
93
  const TEST_FACT_SOURCE_REF = /\.docs\/07_test\/[^\s`,)]+/g;
94
+ const RUNBOOK_DOC_PREFIX = ".docs/09_runbooks/";
92
95
  const RUNNABLE_ENTRY_EXIT_TERMS = [
93
96
  "runnable entry/exit",
94
97
  "entry/exit",
@@ -103,6 +106,9 @@ const DEVELOPMENT_SELF_TEST_CONTRACT_TERMS = ["development self-test contract",
103
106
  const DEVELOPMENT_SELF_TEST_REPORT_TERMS = ["development self-test report", "开发自测报告"];
104
107
  const DEVELOPMENT_SELF_TEST_IMPACT_TERMS = ["development self-test impact", "开发自测影响"];
105
108
  const MODULE_KEY_TEST_PATH_TERMS = ["module key test path", "模块关键测试路径"];
109
+ const MODULE_KEY_TEST_GRAPH_TERMS = ["module key test graph", "module_key_test_graph", "模块关键测试图"];
110
+ const GATE_BREAKDOWN_TERMS = ["gate breakdown", "gate 分层", "gate breakdown(gate 分层)"];
111
+ const CURRENT_OPERATOR_PATH_TERMS = ["current operator path", "operator path", "当前操作路径", "当前 operator path"];
106
112
  const TESTING_HANDOFF_TERMS = ["testing handoff contract", "测试交接合同"];
107
113
  const EVIDENCE_PLACEHOLDER_TERMS = [
108
114
  "pending",
@@ -122,7 +128,132 @@ const SELF_TEST_REPORT_PLACEHOLDER_TERMS = [
122
128
  "all self-test scenarios",
123
129
  "all task/module promised runnable entries",
124
130
  "actual internal key paths",
125
- "observable completion evidence"
131
+ "observable completion evidence",
132
+ "required only when",
133
+ "compact dag pointer",
134
+ "复杂 / high-risk",
135
+ "只记录实际 handoff"
136
+ ];
137
+ const SELF_TEST_REPORT_STATUSES = new Set(["PASS", "BLOCKED", "IN_PROGRESS", "STALE"]);
138
+ const SELF_TEST_REPORT_REQUIRED_FIELDS = [
139
+ "Contract Source",
140
+ "Module Application Entry",
141
+ "Module Key Test Path",
142
+ "Scenario Results",
143
+ "Executed Gates",
144
+ "Observable Exit",
145
+ "Current Blocker",
146
+ "Testing Handoff Readiness",
147
+ "Evidence Index Refs"
148
+ ];
149
+ const SELF_TEST_REPORT_NONE_ALLOWED_FIELDS = new Set(["Current Blocker"]);
150
+ const MAX_SELF_TEST_REPORT_LINES = 80;
151
+ const MAX_HIGH_RISK_SELF_TEST_REPORT_LINES = 120;
152
+ const SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS = [
153
+ "debug log",
154
+ "operator log",
155
+ "operation log",
156
+ "runbook",
157
+ "exploration",
158
+ "diagnostic attempts",
159
+ "fallback attempts",
160
+ "history log",
161
+ "remote operation log",
162
+ "调试日志",
163
+ "操作日志",
164
+ "远端操作日志",
165
+ "探索流水",
166
+ "失败探索",
167
+ "诊断尝试",
168
+ "历史流水"
169
+ ];
170
+ const SELF_TEST_REPORT_DISALLOWED_FIELD_TERMS = [
171
+ "Actual Evidence"
172
+ ];
173
+ const IMPLEMENTATION_MAINLINE_DISALLOWED_SECTION_TERMS = [
174
+ "debug log",
175
+ "operator log",
176
+ "operation log",
177
+ "evidence dump",
178
+ "screenshot index",
179
+ "full screenshot",
180
+ "runbook body",
181
+ "failed attempts",
182
+ "failure exploration",
183
+ "diagnostic attempts",
184
+ "diagnostic path",
185
+ "remote operation log",
186
+ "recovery notes",
187
+ "task changelog",
188
+ "old task",
189
+ "historical task",
190
+ "调试日志",
191
+ "操作日志",
192
+ "证据正文",
193
+ "截图索引",
194
+ "失败探索",
195
+ "诊断尝试",
196
+ "诊断路径",
197
+ "恢复备注",
198
+ "旧 task",
199
+ "历史 task"
200
+ ];
201
+ const HARD_CONSTRAINT_TERMS = [
202
+ "hard constraint",
203
+ "hard constraints",
204
+ "strategy constraint",
205
+ "strategy constraints",
206
+ "恢复硬约束",
207
+ "硬约束",
208
+ "策略约束"
209
+ ];
210
+ const STRATEGY_CHANGING_DECISION_GROUPS = [
211
+ [
212
+ "session persistence/reset",
213
+ [
214
+ "session persistence",
215
+ "session reset",
216
+ "session_reset",
217
+ "session 异常",
218
+ "rule_assumption_gap",
219
+ "operator_induced_logout_or_session_reset",
220
+ "operator induced logout",
221
+ "logout",
222
+ "logged out",
223
+ "退登",
224
+ "qr",
225
+ "二维码",
226
+ "扫码",
227
+ "重新扫码"
228
+ ]
229
+ ],
230
+ [
231
+ "canonical path change",
232
+ [
233
+ "canonical path changed",
234
+ "canonical path change",
235
+ "canonical path switched",
236
+ "canonical path reset",
237
+ "主路径变化",
238
+ "主路径切换",
239
+ "恢复路径变化"
240
+ ]
241
+ ],
242
+ [
243
+ "do-not-retry decision",
244
+ [
245
+ "do not retry",
246
+ "do-not-retry",
247
+ "don't retry",
248
+ "not retry",
249
+ "must not retry",
250
+ "不得重试",
251
+ "不要重试",
252
+ "不要再试",
253
+ "不要重复",
254
+ "不得直接"
255
+ ]
256
+ ]
126
257
  ];
127
258
  const SELF_TEST_OBSERVABLE_EVIDENCE_TERMS = [
128
259
  "pass output",
@@ -143,6 +274,25 @@ const SELF_TEST_OBSERVABLE_EVIDENCE_TERMS = [
143
274
  "queue",
144
275
  "file"
145
276
  ];
277
+ const RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS = new Set(["external_provider_live", "deployed_runtime", "business_handoff_ready"]);
278
+ const RESUME_CAPSULE_REQUIRED_TARGET_KINDS = new Set(["cloud_vm", "managed_service", "browser", "worker"]);
279
+ const RESUME_CAPSULE_FIELDS = [
280
+ "task_id",
281
+ "state",
282
+ "canonical_path",
283
+ "next_step",
284
+ "blocker",
285
+ "last_passed_gate",
286
+ "do_not_retry",
287
+ "recovery_refs"
288
+ ];
289
+ const MAX_WORKING_NOTES = 8;
290
+ const GATE_BREAKDOWN_LAYER_GROUPS = [
291
+ ["local gate", ["local", "unit", "lint", "test", "本地"]],
292
+ ["cloud/service gate", ["cloud", "service", "runtime", "server", "managed_service", "cloud_vm", "服务", "云端"]],
293
+ ["executor/operator readiness", ["executor", "operator", "worker", "browser", "provider", "adapter", "readiness", "执行器", "操控", "就绪"]],
294
+ ["live smoke or handoff", ["live", "smoke", "handoff", "external_provider_live", "deployed_runtime", "business_handoff_ready", "冒烟", "交接"]]
295
+ ];
146
296
  const PAGE_TASK_TERMS = ["frontend", "front-end", "browser", "page", "页面", "前端", "按钮", "表单", "跳转"];
147
297
  const PAGE_ENTRY_TERMS = ["http://", "https://", "localhost", "127.0.0.1", "page url", "页面 url", "dev server"];
148
298
  const PAGE_BROWSER_CHECK_TERMS = ["browser check", "playwright", "screenshot", "click", "button", "form", "页面可加载", "浏览器验证"];
@@ -264,6 +414,27 @@ const REVIEW_READINESS_FIELDS = [
264
414
  "Testing Handoff Readiness"
265
415
  ];
266
416
  const SELF_TEST_CONTRACT_STATUSES = new Set(["required", "not_applicable"]);
417
+ const SELF_TEST_GRAPH_NODE_KINDS = new Set(["entry", "checkpoint", "branch", "scenario", "observable_exit"]);
418
+ const SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT = 12;
419
+ const SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT = 25;
420
+ const SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS = [
421
+ "```",
422
+ "|---",
423
+ "actual evidence",
424
+ "command transcript",
425
+ "full command output",
426
+ "stdout",
427
+ "stderr",
428
+ "traceback",
429
+ "debug log",
430
+ "operator log",
431
+ "evidence dump",
432
+ "screenshot process",
433
+ "截图过程",
434
+ "调试日志",
435
+ "操作日志",
436
+ "证据正文"
437
+ ];
267
438
  const RFC_SELF_TEST_TRIGGER_TERMS = [
268
439
  "entry/exit",
269
440
  "runnable entry",
@@ -278,6 +449,11 @@ const RFC_SELF_TEST_TRIGGER_TERMS = [
278
449
  "handoff",
279
450
  "blocker",
280
451
  "module key test path",
452
+ "module key test graph",
453
+ "module_key_test_graph",
454
+ "checkpoint",
455
+ "observable exit",
456
+ "evidence refs",
281
457
  "test route",
282
458
  "test path",
283
459
  "debug path",
@@ -319,6 +495,7 @@ async function validateHarness(projectRoot) {
319
495
  for (const required of [
320
496
  "AGENTS.md",
321
497
  ".docs/INDEX.md",
498
+ ".docs/09_runbooks",
322
499
  harnessPath(root, "config.yaml"),
323
500
  harnessPath(root, "state", "lifecycle.yaml"),
324
501
  harnessPath(root, "state", "plan.yaml"),
@@ -330,6 +507,9 @@ async function validateHarness(projectRoot) {
330
507
  errors.push(`missing ${required}`);
331
508
  }
332
509
  }
510
+ const phaseContractPath = path.join(projectRoot, harnessPath(root, "pjsdlc_managed", "policies", "phase_contracts.yaml"));
511
+ const phaseContracts = await readYamlObject(phaseContractPath);
512
+ errors.push(...validatePhaseTransitionContract(phaseContracts));
333
513
  return { info: [`validate-harness checked ${projectRoot} (${root})`], errors };
334
514
  }
335
515
  async function validateCurrent(projectRoot) {
@@ -786,6 +966,7 @@ async function validatePlanState(projectRoot, allowOpen) {
786
966
  if (!Array.isArray(task.acceptance_criteria) || task.acceptance_criteria.length === 0) {
787
967
  errors.push(`Open task ${task.id} must define acceptance_criteria`);
788
968
  }
969
+ errors.push(...validateWorkingNotesLimit(task));
789
970
  errors.push(...validateRuntimeEvidenceContract(task));
790
971
  errors.push(...testingBoundaryErrorsForAllowedPaths(task));
791
972
  }
@@ -804,8 +985,90 @@ async function validatePlanState(projectRoot, allowOpen) {
804
985
  if (currentTaskId && !tasks.some((task) => isRecord(task) && task.id === currentTaskId)) {
805
986
  errors.push(`current_task_id does not match a task: ${currentTaskId}`);
806
987
  }
988
+ errors.push(...(await validateResumeCapsule(projectRoot, tasksData)));
807
989
  return { taskCount: tasks.length, errors, plan: tasksData };
808
990
  }
991
+ function validateWorkingNotesLimit(task) {
992
+ if (!("working_notes" in task))
993
+ return [];
994
+ const taskId = String(task.id ?? "Open task");
995
+ const notes = task.working_notes;
996
+ if (typeof notes !== "string" && !Array.isArray(notes)) {
997
+ return [`Open task ${taskId} working_notes must be a short string or list with at most ${MAX_WORKING_NOTES} items`];
998
+ }
999
+ const count = Array.isArray(notes) ? notes.length : notes.trim() ? 1 : 0;
1000
+ if (count > MAX_WORKING_NOTES) {
1001
+ return [`Open task ${taskId} working_notes must stay resume-first and contain at most ${MAX_WORKING_NOTES} items; found ${count}`];
1002
+ }
1003
+ return [];
1004
+ }
1005
+ async function validateResumeCapsule(projectRoot, plan) {
1006
+ const errors = [];
1007
+ const currentTask = currentOpenSprintTask(plan);
1008
+ const capsule = plan.resume_capsule;
1009
+ if (!currentTask) {
1010
+ if (capsule !== undefined) {
1011
+ errors.push("plan.yaml resume_capsule must only be present for the current open SPRINTING task");
1012
+ }
1013
+ return errors;
1014
+ }
1015
+ const taskId = String(currentTask.id ?? "current task");
1016
+ const required = requiresResumeCapsule(currentTask);
1017
+ if (!required && capsule === undefined)
1018
+ return errors;
1019
+ if (!isRecord(capsule)) {
1020
+ errors.push(`${taskId} high-risk runtime task must define top-level resume_capsule`);
1021
+ return errors;
1022
+ }
1023
+ for (const field of RESUME_CAPSULE_FIELDS) {
1024
+ if (!(field in capsule)) {
1025
+ errors.push(`${taskId} resume_capsule missing field: ${field}`);
1026
+ }
1027
+ }
1028
+ const capsuleTaskId = String(capsule.task_id ?? "").trim();
1029
+ if (capsuleTaskId !== taskId) {
1030
+ errors.push(`${taskId} resume_capsule.task_id must match current_task_id`);
1031
+ }
1032
+ for (const field of ["state", "canonical_path", "next_step", "blocker", "last_passed_gate"]) {
1033
+ const value = String(capsule[field] ?? "").trim();
1034
+ if (!value || isPlaceholderEvidence(value)) {
1035
+ errors.push(`${taskId} resume_capsule.${field} must contain concrete recovery information`);
1036
+ }
1037
+ }
1038
+ const doNotRetry = asStringList(capsule.do_not_retry);
1039
+ if (doNotRetry.length === 0 || doNotRetry.some((item) => isPlaceholderEvidence(item))) {
1040
+ errors.push(`${taskId} resume_capsule.do_not_retry must list concrete paths or attempts not to repeat`);
1041
+ }
1042
+ const refs = asStringList(capsule.recovery_refs);
1043
+ if (refs.length === 0) {
1044
+ errors.push(`${taskId} resume_capsule.recovery_refs must link implementation doc and runbook/evidence documents`);
1045
+ return errors;
1046
+ }
1047
+ const implementationDoc = String(currentTask.implementation_doc ?? "").trim();
1048
+ if (implementationDoc && !refs.includes(implementationDoc)) {
1049
+ errors.push(`${taskId} resume_capsule.recovery_refs must include current implementation_doc ${implementationDoc}`);
1050
+ }
1051
+ if (!refs.some((ref) => ref.startsWith(RUNBOOK_DOC_PREFIX))) {
1052
+ errors.push(`${taskId} resume_capsule.recovery_refs must include a runbook/evidence document under ${RUNBOOK_DOC_PREFIX}`);
1053
+ }
1054
+ for (const ref of refs) {
1055
+ if (!ref.startsWith(".docs/04_implementation/") && !ref.startsWith(RUNBOOK_DOC_PREFIX)) {
1056
+ errors.push(`${taskId} resume_capsule.recovery_refs may only point to implementation docs or runbook/evidence docs: ${ref}`);
1057
+ continue;
1058
+ }
1059
+ if (!(await pathExists(path.join(projectRoot, ref)))) {
1060
+ errors.push(`${taskId} resume_capsule recovery_ref does not exist: ${ref}`);
1061
+ }
1062
+ }
1063
+ return errors;
1064
+ }
1065
+ function requiresResumeCapsule(task) {
1066
+ if (String(task.phase ?? "") !== "SPRINTING")
1067
+ return false;
1068
+ const evidenceLevel = isRecord(task.evidence_level) ? String(task.evidence_level.required ?? "") : "";
1069
+ const targetKind = isRecord(task.target_runtime_environment) ? String(task.target_runtime_environment.kind ?? "") : "";
1070
+ return RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS.has(evidenceLevel) || RESUME_CAPSULE_REQUIRED_TARGET_KINDS.has(targetKind);
1071
+ }
809
1072
  function validateRuntimeEvidenceContract(task) {
810
1073
  const errors = [];
811
1074
  const taskId = String(task.id ?? "Task");
@@ -877,6 +1140,205 @@ function isNotApplicableRuntimeTask(task) {
877
1140
  const targetRuntime = isRecord(task.target_runtime_environment) ? task.target_runtime_environment : undefined;
878
1141
  return String(evidenceLevel?.required ?? "") === "unit" && String(targetRuntime?.kind ?? "") === "not_applicable";
879
1142
  }
1143
+ function selfTestGraphEvidenceRefIsBody(value) {
1144
+ const trimmed = value.trim();
1145
+ const lowered = trimmed.toLowerCase();
1146
+ return trimmed.includes("\n") || trimmed.length > 180 || SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS.some((term) => lowered.includes(term));
1147
+ }
1148
+ function graphHasCycle(nodeIds, adjacency) {
1149
+ const visiting = new Set();
1150
+ const visited = new Set();
1151
+ const visit = (nodeId) => {
1152
+ if (visiting.has(nodeId))
1153
+ return true;
1154
+ if (visited.has(nodeId))
1155
+ return false;
1156
+ visiting.add(nodeId);
1157
+ for (const target of adjacency.get(nodeId) ?? []) {
1158
+ if (visit(target))
1159
+ return true;
1160
+ }
1161
+ visiting.delete(nodeId);
1162
+ visited.add(nodeId);
1163
+ return false;
1164
+ };
1165
+ for (const nodeId of nodeIds) {
1166
+ if (!visited.has(nodeId) && visit(nodeId))
1167
+ return true;
1168
+ }
1169
+ return false;
1170
+ }
1171
+ function reachableFrom(entryId, adjacency) {
1172
+ const reached = new Set();
1173
+ const stack = [entryId];
1174
+ while (stack.length > 0) {
1175
+ const nodeId = stack.pop();
1176
+ if (reached.has(nodeId))
1177
+ continue;
1178
+ reached.add(nodeId);
1179
+ stack.push(...(adjacency.get(nodeId) ?? []));
1180
+ }
1181
+ return reached;
1182
+ }
1183
+ function nodesThatCanReachExits(nodeIds, adjacency, exits) {
1184
+ const reverse = new Map();
1185
+ for (const nodeId of nodeIds)
1186
+ reverse.set(nodeId, []);
1187
+ for (const [source, targets] of adjacency.entries()) {
1188
+ for (const target of targets) {
1189
+ const incoming = reverse.get(target) ?? [];
1190
+ incoming.push(source);
1191
+ reverse.set(target, incoming);
1192
+ }
1193
+ }
1194
+ const reached = new Set();
1195
+ const stack = [...exits];
1196
+ while (stack.length > 0) {
1197
+ const nodeId = stack.pop();
1198
+ if (reached.has(nodeId))
1199
+ continue;
1200
+ reached.add(nodeId);
1201
+ stack.push(...(reverse.get(nodeId) ?? []));
1202
+ }
1203
+ return reached;
1204
+ }
1205
+ function validateSelfTestGraph(taskId, contract, scenarioIds, highRiskRuntime) {
1206
+ const errors = [];
1207
+ const graphRequired = contract.graph_required;
1208
+ if (graphRequired !== undefined && typeof graphRequired !== "boolean") {
1209
+ errors.push(`${taskId} self_test_contract.graph_required must be a boolean when set`);
1210
+ }
1211
+ const graph = contract.module_key_test_graph;
1212
+ if (graphRequired === true && !isRecord(graph)) {
1213
+ errors.push(`${taskId} self_test_contract.module_key_test_graph is required when graph_required is true`);
1214
+ }
1215
+ if (graph === undefined)
1216
+ return errors;
1217
+ if (!isRecord(graph)) {
1218
+ errors.push(`${taskId} self_test_contract.module_key_test_graph must be a mapping`);
1219
+ return errors;
1220
+ }
1221
+ const nodes = graph.nodes;
1222
+ const edges = graph.edges;
1223
+ if (!Array.isArray(nodes) || nodes.length === 0) {
1224
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes must be a non-empty list`);
1225
+ return errors;
1226
+ }
1227
+ if (!Array.isArray(edges) || edges.length === 0) {
1228
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.edges must be a non-empty list`);
1229
+ return errors;
1230
+ }
1231
+ const nodeLimit = highRiskRuntime ? SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT : SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT;
1232
+ if (nodes.length > nodeLimit) {
1233
+ errors.push(`${taskId} self_test_contract.module_key_test_graph has ${nodes.length} nodes; keep ordinary graphs <= ${SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT} nodes and high-risk graphs <= ${SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT} nodes`);
1234
+ }
1235
+ const nodeIds = new Set();
1236
+ const entryIds = [];
1237
+ const observableExitIds = new Set();
1238
+ const scenarioNodesByRef = new Map();
1239
+ const adjacency = new Map();
1240
+ nodes.forEach((node, index) => {
1241
+ if (!isRecord(node)) {
1242
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${index}] must be a mapping`);
1243
+ return;
1244
+ }
1245
+ const nodeId = String(node.id ?? "").trim();
1246
+ const kind = String(node.kind ?? "").trim();
1247
+ const label = String(node.label ?? "").trim();
1248
+ if (!nodeId) {
1249
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${index}].id must be set`);
1250
+ return;
1251
+ }
1252
+ if (nodeIds.has(nodeId)) {
1253
+ errors.push(`${taskId} self_test_contract.module_key_test_graph node id must be unique: ${nodeId}`);
1254
+ }
1255
+ nodeIds.add(nodeId);
1256
+ adjacency.set(nodeId, adjacency.get(nodeId) ?? []);
1257
+ if (!SELF_TEST_GRAPH_NODE_KINDS.has(kind)) {
1258
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].kind must be one of ${[...SELF_TEST_GRAPH_NODE_KINDS].join(", ")}`);
1259
+ }
1260
+ if (!label || isPlaceholderEvidence(label)) {
1261
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].label must be concrete`);
1262
+ }
1263
+ if (kind === "entry")
1264
+ entryIds.push(nodeId);
1265
+ if (kind === "observable_exit")
1266
+ observableExitIds.add(nodeId);
1267
+ if (kind === "scenario") {
1268
+ const scenarioRef = String(node.scenario_ref ?? "").trim();
1269
+ if (!scenarioRef) {
1270
+ errors.push(`${taskId} self_test_contract.module_key_test_graph scenario node ${nodeId} must set scenario_ref`);
1271
+ }
1272
+ else if (!scenarioIds.has(scenarioRef)) {
1273
+ errors.push(`${taskId} self_test_contract.module_key_test_graph scenario node ${nodeId} references unknown scenario: ${scenarioRef}`);
1274
+ }
1275
+ else {
1276
+ const current = scenarioNodesByRef.get(scenarioRef) ?? [];
1277
+ current.push(nodeId);
1278
+ scenarioNodesByRef.set(scenarioRef, current);
1279
+ }
1280
+ }
1281
+ if (node.evidence_ref !== undefined) {
1282
+ const evidenceRef = String(node.evidence_ref).trim();
1283
+ if (!evidenceRef || isPlaceholderEvidence(evidenceRef) || selfTestGraphEvidenceRefIsBody(evidenceRef)) {
1284
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].evidence_ref must be a short evidence pointer, not evidence body`);
1285
+ }
1286
+ }
1287
+ });
1288
+ if (entryIds.length !== 1) {
1289
+ errors.push(`${taskId} self_test_contract.module_key_test_graph must have exactly one entry node`);
1290
+ }
1291
+ if (observableExitIds.size === 0) {
1292
+ errors.push(`${taskId} self_test_contract.module_key_test_graph must have at least one observable_exit node`);
1293
+ }
1294
+ edges.forEach((edge, index) => {
1295
+ if (!isRecord(edge)) {
1296
+ errors.push(`${taskId} self_test_contract.module_key_test_graph.edges[${index}] must be a mapping`);
1297
+ return;
1298
+ }
1299
+ const source = String(edge.from ?? "").trim();
1300
+ const target = String(edge.to ?? "").trim();
1301
+ if (!nodeIds.has(source)) {
1302
+ errors.push(`${taskId} self_test_contract.module_key_test_graph edge references unknown from node: ${source || "<empty>"}`);
1303
+ return;
1304
+ }
1305
+ if (!nodeIds.has(target)) {
1306
+ errors.push(`${taskId} self_test_contract.module_key_test_graph edge references unknown to node: ${target || "<empty>"}`);
1307
+ return;
1308
+ }
1309
+ const current = adjacency.get(source) ?? [];
1310
+ current.push(target);
1311
+ adjacency.set(source, current);
1312
+ });
1313
+ if (graphHasCycle(nodeIds, adjacency)) {
1314
+ errors.push(`${taskId} self_test_contract.module_key_test_graph must be a DAG; cycles are not allowed`);
1315
+ }
1316
+ let reachedFromEntry = new Set();
1317
+ if (entryIds.length === 1) {
1318
+ reachedFromEntry = reachableFrom(entryIds[0], adjacency);
1319
+ const unreachable = [...nodeIds].filter((nodeId) => !reachedFromEntry.has(nodeId)).sort();
1320
+ if (unreachable.length > 0) {
1321
+ errors.push(`${taskId} self_test_contract.module_key_test_graph nodes must be reachable from entry: ${unreachable.join(", ")}`);
1322
+ }
1323
+ }
1324
+ const canReachExit = nodesThatCanReachExits(nodeIds, adjacency, observableExitIds);
1325
+ for (const scenarioId of [...scenarioIds].sort()) {
1326
+ const scenarioNodeIds = scenarioNodesByRef.get(scenarioId) ?? [];
1327
+ if (scenarioNodeIds.length === 0) {
1328
+ errors.push(`${taskId} self_test_contract.module_key_test_graph must include a scenario node for ${scenarioId}`);
1329
+ continue;
1330
+ }
1331
+ for (const nodeId of scenarioNodeIds) {
1332
+ if (reachedFromEntry.size > 0 && !reachedFromEntry.has(nodeId)) {
1333
+ errors.push(`${taskId} self_test_contract.module_key_test_graph scenario ${scenarioId} must be reachable from entry`);
1334
+ }
1335
+ if (!canReachExit.has(nodeId)) {
1336
+ errors.push(`${taskId} self_test_contract.module_key_test_graph scenario ${scenarioId} must reach an observable_exit`);
1337
+ }
1338
+ }
1339
+ }
1340
+ return errors;
1341
+ }
880
1342
  function validateSelfTestContract(task, requiredForRunnableBoundary) {
881
1343
  const errors = [];
882
1344
  const taskId = String(task.id ?? "Task");
@@ -930,6 +1392,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
930
1392
  errors.push(`${taskId} self_test_contract.scenarios must be a non-empty list`);
931
1393
  }
932
1394
  const seen = new Set();
1395
+ const scenarioIds = new Set();
933
1396
  scenarios.forEach((scenario, index) => {
934
1397
  if (!isRecord(scenario)) {
935
1398
  errors.push(`${taskId} self_test_contract.scenarios[${index}] must be a mapping`);
@@ -942,6 +1405,9 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
942
1405
  else if (seen.has(scenarioId)) {
943
1406
  errors.push(`${taskId} self_test_contract scenario id must be unique: ${scenarioId}`);
944
1407
  }
1408
+ else {
1409
+ scenarioIds.add(scenarioId);
1410
+ }
945
1411
  seen.add(scenarioId);
946
1412
  for (const field of ["entry", "expected_exit", "evidence"]) {
947
1413
  if (typeof scenario[field] !== "string" || !String(scenario[field]).trim() || isPlaceholderEvidence(String(scenario[field]))) {
@@ -949,6 +1415,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
949
1415
  }
950
1416
  }
951
1417
  });
1418
+ errors.push(...validateSelfTestGraph(taskId, contract, scenarioIds, requiresResumeCapsule(task)));
952
1419
  return errors;
953
1420
  }
954
1421
  async function validateSelfTestContractTechPlanBinding(projectRoot, task, normalizedTechRefs) {
@@ -976,6 +1443,9 @@ async function validateSelfTestContractTechPlanBinding(projectRoot, task, normal
976
1443
  if (!containsAny(section, MODULE_KEY_TEST_PATH_TERMS)) {
977
1444
  errors.push(`${taskId} tech plan Development Self-Test Contract must include Module key test path: ${source}`);
978
1445
  }
1446
+ if (contract.graph_required === true && !containsAny(section, MODULE_KEY_TEST_GRAPH_TERMS)) {
1447
+ errors.push(`${taskId} tech plan Development Self-Test Contract must include Module Key Test Graph when graph_required is true: ${source}`);
1448
+ }
979
1449
  for (const scenario of Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : []) {
980
1450
  const scenarioId = String(scenario.id ?? "").trim();
981
1451
  if (scenarioId && !section.includes(scenarioId)) {
@@ -1192,6 +1662,95 @@ function matchesGlob(file, pattern) {
1192
1662
  function isRecord(value) {
1193
1663
  return typeof value === "object" && value !== null && !Array.isArray(value);
1194
1664
  }
1665
+ function validatePhaseTransitionContract(contract) {
1666
+ const errors = [];
1667
+ const phases = contract.phases;
1668
+ if (!isRecord(phases)) {
1669
+ return ["phase_contracts.yaml must contain phases"];
1670
+ }
1671
+ for (const [phaseName, phaseContract] of Object.entries(phases)) {
1672
+ if (!isRecord(phaseContract)) {
1673
+ errors.push(`${phaseName} phase contract must be a mapping`);
1674
+ continue;
1675
+ }
1676
+ for (const legacyKey of ["next", "returns"]) {
1677
+ if (legacyKey in phaseContract) {
1678
+ errors.push(`${phaseName} must not define legacy ${legacyKey}; use top-level transitions`);
1679
+ }
1680
+ }
1681
+ }
1682
+ const transitions = contract.transitions;
1683
+ if (!Array.isArray(transitions)) {
1684
+ errors.push("phase_contracts.yaml must contain top-level transitions");
1685
+ return errors;
1686
+ }
1687
+ const phaseNames = new Set(Object.keys(phases));
1688
+ const seen = new Set();
1689
+ const outgoing = new Set();
1690
+ transitions.forEach((transition, index) => {
1691
+ const prefix = `transition #${index + 1}`;
1692
+ if (!isRecord(transition)) {
1693
+ errors.push(`${prefix} must be a mapping`);
1694
+ return;
1695
+ }
1696
+ const fromPhase = String(transition.from ?? "");
1697
+ const toPhase = String(transition.to ?? "");
1698
+ const trigger = String(transition.trigger ?? "");
1699
+ const kind = String(transition.kind ?? "");
1700
+ for (const [field, value] of [
1701
+ ["from", fromPhase],
1702
+ ["to", toPhase],
1703
+ ["trigger", trigger],
1704
+ ["kind", kind]
1705
+ ]) {
1706
+ if (!value.trim())
1707
+ errors.push(`${prefix} missing ${field}`);
1708
+ }
1709
+ if (!fromPhase || !toPhase || !trigger || !kind)
1710
+ return;
1711
+ if (!phaseNames.has(fromPhase)) {
1712
+ errors.push(`${prefix} from references unknown phase: ${fromPhase}`);
1713
+ }
1714
+ if (toPhase === RESERVED_SUSPENDED_PHASE_TARGET) {
1715
+ if (fromPhase !== "BLOCKED" || kind !== "resume") {
1716
+ errors.push(`${prefix} may use ${RESERVED_SUSPENDED_PHASE_TARGET} only for BLOCKED resume`);
1717
+ }
1718
+ }
1719
+ else if (!phaseNames.has(toPhase)) {
1720
+ errors.push(`${prefix} to references unknown phase: ${toPhase}`);
1721
+ }
1722
+ if (!TRANSITION_KINDS.has(kind)) {
1723
+ errors.push(`${prefix} has invalid kind: ${kind}`);
1724
+ }
1725
+ const duplicateKey = `${fromPhase}\0${toPhase}\0${trigger}`;
1726
+ if (seen.has(duplicateKey)) {
1727
+ errors.push(`${prefix} duplicates transition ${fromPhase} -> ${toPhase} (${trigger})`);
1728
+ }
1729
+ seen.add(duplicateKey);
1730
+ outgoing.add(fromPhase);
1731
+ if ("effects" in transition && transition.effects !== undefined) {
1732
+ if (!isRecord(transition.effects)) {
1733
+ errors.push(`${prefix} effects must be a mapping`);
1734
+ }
1735
+ else {
1736
+ for (const [effectName, effectValue] of Object.entries(transition.effects)) {
1737
+ if (!["set_suspended_phase", "clear_suspended_phase"].includes(effectName)) {
1738
+ errors.push(`${prefix} has unknown effect: ${effectName}`);
1739
+ }
1740
+ if (typeof effectValue !== "boolean") {
1741
+ errors.push(`${prefix} effect ${effectName} must be boolean`);
1742
+ }
1743
+ }
1744
+ }
1745
+ }
1746
+ });
1747
+ for (const phaseName of phaseNames) {
1748
+ if (!outgoing.has(phaseName)) {
1749
+ errors.push(`${phaseName} must have at least one outgoing transition`);
1750
+ }
1751
+ }
1752
+ return errors;
1753
+ }
1195
1754
  async function readYamlObject(filePath) {
1196
1755
  if (!(await pathExists(filePath)))
1197
1756
  return {};
@@ -1250,7 +1809,10 @@ async function validateCurrentTaskDevelopmentEvidence(projectRoot, plan) {
1250
1809
  return [`${taskId} implementation_doc is missing: ${implementationDoc}`];
1251
1810
  }
1252
1811
  const text = await readText(docPath);
1253
- return validateDevelopmentEvidenceText(text, currentTask, implementationDoc);
1812
+ return [
1813
+ ...validateDevelopmentEvidenceText(text, currentTask, implementationDoc),
1814
+ ...(await validateHighRiskRecoveryBoundaries(projectRoot, text, plan, currentTask, implementationDoc))
1815
+ ];
1254
1816
  }
1255
1817
  function currentOpenSprintTask(plan) {
1256
1818
  const currentTaskId = String(plan.current_task_id ?? "");
@@ -1327,17 +1889,31 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1327
1889
  if (!report) {
1328
1890
  return [`${taskId} implementation_doc must include Development Self-Test Report for self_test_contract: ${implementationDoc}`];
1329
1891
  }
1892
+ const reportStatus = normalizeSelfTestReportStatus(evidenceFieldValue(report, "Report Status"));
1893
+ if (!reportStatus) {
1894
+ errors.push(`${taskId} Development Self-Test Report must include Report Status: PASS | BLOCKED | IN_PROGRESS | STALE in ${implementationDoc}`);
1895
+ }
1896
+ else if (reportStatus !== "PASS") {
1897
+ errors.push(`${taskId} Development Self-Test Report Report Status is ${reportStatus}; validate-dev cannot handoff until the report status is PASS`);
1898
+ }
1899
+ const highRiskRuntime = requiresResumeCapsule(task);
1900
+ errors.push(...validateSelfTestReportBoundary(report, taskId, implementationDoc));
1901
+ errors.push(...validateSelfTestReportLength(report, taskId, implementationDoc, highRiskRuntime));
1330
1902
  const basicSelfTest = evidenceFieldValue(developmentEvidenceSection, "Basic Self-test Evidence") ?? "";
1331
1903
  if (!containsAny(basicSelfTest, ["Development Self-Test Report", "开发自测报告", "self-test report"])) {
1332
1904
  errors.push(`${taskId} Basic Self-test Evidence must reference the Development Self-Test Report in ${implementationDoc}`);
1333
1905
  }
1334
- for (const field of ["Contract Source", "Scenario Results", "Executed Gates", "Module Key Test Path", "Actual Evidence", "Missing / Blockers", "Testing Handoff Readiness"]) {
1906
+ for (const field of SELF_TEST_REPORT_REQUIRED_FIELDS) {
1335
1907
  const value = evidenceFieldValue(report, field);
1336
- const allowsNone = field === "Missing / Blockers";
1908
+ const allowsNone = SELF_TEST_REPORT_NONE_ALLOWED_FIELDS.has(field);
1337
1909
  if (!value || (!allowsNone && isPlaceholderEvidence(value))) {
1338
1910
  errors.push(`${taskId} Development Self-Test Report ${field} must contain executed evidence in ${implementationDoc}`);
1339
1911
  }
1340
1912
  }
1913
+ const evidenceIndexRefs = evidenceFieldValue(report, "Evidence Index Refs") ?? "";
1914
+ if (highRiskRuntime && !evidenceIndexRefs.includes(RUNBOOK_DOC_PREFIX)) {
1915
+ errors.push(`${taskId} high-risk Development Self-Test Report Evidence Index Refs must link evidence under ${RUNBOOK_DOC_PREFIX} in ${implementationDoc}`);
1916
+ }
1341
1917
  const source = String(contract.source ?? "").trim();
1342
1918
  if (source && !report.includes(source)) {
1343
1919
  errors.push(`${taskId} Development Self-Test Report must reference contract source ${source} in ${implementationDoc}`);
@@ -1351,10 +1927,18 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1351
1927
  if (isPlaceholderSelfTestReportValue(moduleKeyTestPath) || isTemplateModuleKeyTestPath(moduleKeyTestPath)) {
1352
1928
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must replace template placeholders with actual executed path evidence in ${implementationDoc}`);
1353
1929
  }
1930
+ const graphRequired = contract.graph_required === true || isRecord(contract.module_key_test_graph);
1931
+ const moduleKeyTestGraph = (markdownSection(report, MODULE_KEY_TEST_GRAPH_TERMS) ?? evidenceFieldValue(report, "Module Key Test Graph") ?? "").trim();
1932
+ if (graphRequired && (!moduleKeyTestGraph || isPlaceholderSelfTestReportValue(moduleKeyTestGraph))) {
1933
+ errors.push(`${taskId} Development Self-Test Report must include Module Key Test Graph because self_test_contract graph is required or present in ${implementationDoc}`);
1934
+ }
1354
1935
  const runnableEntry = String(contract.runnable_entry ?? "").trim();
1355
1936
  if (runnableEntry && !moduleKeyTestPath.includes(runnableEntry)) {
1356
1937
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include runnable entry ${runnableEntry} in ${implementationDoc}`);
1357
1938
  }
1939
+ if (graphRequired && moduleKeyTestGraph && runnableEntry && !moduleKeyTestGraph.includes(runnableEntry)) {
1940
+ errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include runnable entry ${runnableEntry} in ${implementationDoc}`);
1941
+ }
1358
1942
  const scenarios = Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : [];
1359
1943
  const exitEvidenceTerms = [
1360
1944
  String(contract.observable_exit ?? "").trim(),
@@ -1368,6 +1952,13 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1368
1952
  && !containsAny(moduleKeyTestPath, SELF_TEST_OBSERVABLE_EVIDENCE_TERMS)) {
1369
1953
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include observable exit or evidence from self_test_contract in ${implementationDoc}`);
1370
1954
  }
1955
+ if (graphRequired
1956
+ && moduleKeyTestGraph
1957
+ && exitEvidenceTerms.length > 0
1958
+ && !exitEvidenceTerms.some((term) => normalizedIncludes(moduleKeyTestGraph, term))
1959
+ && !containsAny(moduleKeyTestGraph, ["observable_exit", "observable exit", "exit", "出口"])) {
1960
+ errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include observable exit or evidence from self_test_contract in ${implementationDoc}`);
1961
+ }
1371
1962
  for (const scenario of scenarios) {
1372
1963
  const scenarioId = String(scenario.id ?? "").trim();
1373
1964
  if (!scenarioId)
@@ -1375,15 +1966,18 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1375
1966
  if (!moduleKeyTestPath.includes(scenarioId)) {
1376
1967
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include scenario ${scenarioId} in ${implementationDoc}`);
1377
1968
  }
1969
+ if (graphRequired && moduleKeyTestGraph && !moduleKeyTestGraph.includes(scenarioId)) {
1970
+ errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include scenario ${scenarioId} in ${implementationDoc}`);
1971
+ }
1378
1972
  const status = scenarioStatus(report, scenarioId);
1379
1973
  if (!status) {
1380
- errors.push(`${taskId} Development Self-Test Report must record scenario ${scenarioId} as PASS or BLOCKED in ${implementationDoc}`);
1974
+ errors.push(`${taskId} Development Self-Test Report must record scenario ${scenarioId} as PASS, BLOCKED, IN_PROGRESS, or STALE in ${implementationDoc}`);
1381
1975
  }
1382
1976
  else if (status === "AMBIGUOUS") {
1383
- errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} must choose exactly one of PASS or BLOCKED in ${implementationDoc}`);
1977
+ errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} must choose exactly one status in ${implementationDoc}`);
1384
1978
  }
1385
- else if (status === "BLOCKED") {
1386
- errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} is BLOCKED; keep task open or record a blocker`);
1979
+ else if (status !== "PASS") {
1980
+ errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} is ${status}; validate-dev cannot handoff until every scenario is PASS`);
1387
1981
  }
1388
1982
  errors.push(...validateScenarioTableEvidence(report, scenarioId, taskId, implementationDoc));
1389
1983
  }
@@ -1398,23 +1992,215 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1398
1992
  errors.push(`${taskId} page Development Self-Test Report must include browser, Playwright, screenshot, or equivalent interaction evidence in ${implementationDoc}`);
1399
1993
  }
1400
1994
  }
1995
+ if (highRiskRuntime) {
1996
+ errors.push(...validateCurrentOperatorPath(fullText, taskId, implementationDoc));
1997
+ errors.push(...validateGateBreakdown(fullText, taskId, implementationDoc));
1998
+ }
1999
+ return errors;
2000
+ }
2001
+ function normalizeSelfTestReportStatus(value) {
2002
+ if (!value)
2003
+ return undefined;
2004
+ const normalized = value.replace(/`/g, "").trim().toUpperCase().replace(/[\s-]+/g, "_");
2005
+ return SELF_TEST_REPORT_STATUSES.has(normalized) ? normalized : undefined;
2006
+ }
2007
+ function validateSelfTestReportBoundary(report, taskId, implementationDoc) {
2008
+ const errors = [];
2009
+ for (const line of report.split(/\r?\n/)) {
2010
+ const fieldMatch = line.match(/^\s*[-*]\s*([^:]+)\s*:/);
2011
+ if (fieldMatch) {
2012
+ const field = fieldMatch[1].trim().toLowerCase();
2013
+ const blockedField = SELF_TEST_REPORT_DISALLOWED_FIELD_TERMS.find((term) => field === term.toLowerCase());
2014
+ if (blockedField) {
2015
+ errors.push(`${taskId} Development Self-Test Report must not use ${blockedField}; put evidence bodies in an Evidence Index and reference them with Evidence Index Refs in ${implementationDoc}`);
2016
+ }
2017
+ }
2018
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
2019
+ if (!match)
2020
+ continue;
2021
+ const title = match[2].trim().toLowerCase();
2022
+ const blockedTerm = SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS.find((term) => title.includes(term));
2023
+ if (blockedTerm) {
2024
+ errors.push(`${taskId} Development Self-Test Report must not include debug/operator/runbook/exploration log section "${match[2].trim()}" in ${implementationDoc}; link a runbook or exploration appendix instead`);
2025
+ }
2026
+ }
2027
+ return errors;
2028
+ }
2029
+ function validateSelfTestReportLength(report, taskId, implementationDoc, highRiskRuntime) {
2030
+ const limit = highRiskRuntime ? MAX_HIGH_RISK_SELF_TEST_REPORT_LINES : MAX_SELF_TEST_REPORT_LINES;
2031
+ const lineCount = report.split(/\r?\n/).filter((line) => line.trim()).length;
2032
+ if (lineCount <= limit)
2033
+ return [];
2034
+ return [
2035
+ `${taskId} Development Self-Test Report must stay as a short handoff card (${limit} non-empty lines max); move logs, evidence bodies, runbook steps, and exploration history out of ${implementationDoc}`
2036
+ ];
2037
+ }
2038
+ async function validateHighRiskRecoveryBoundaries(projectRoot, implementationText, plan, task, implementationDoc) {
2039
+ if (!requiresResumeCapsule(task))
2040
+ return [];
2041
+ const taskId = String(task.id ?? "current task");
2042
+ const errors = validateImplementationDocMainlineBoundary(implementationText, taskId, implementationDoc);
2043
+ errors.push(...(await validateStrategyChangingDecisionPromotion(projectRoot, implementationText, plan, task, implementationDoc)));
2044
+ return errors;
2045
+ }
2046
+ function validateImplementationDocMainlineBoundary(fullText, taskId, implementationDoc) {
2047
+ const errors = [];
2048
+ for (const line of fullText.split(/\r?\n/)) {
2049
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
2050
+ if (!match)
2051
+ continue;
2052
+ const title = match[2].trim().toLowerCase();
2053
+ const blockedTerm = IMPLEMENTATION_MAINLINE_DISALLOWED_SECTION_TERMS.find((term) => title.includes(term));
2054
+ if (blockedTerm) {
2055
+ errors.push(`${taskId} implementation_doc must not keep ${blockedTerm} as a mainline section in ${implementationDoc}; move it to a runbook, evidence index, exploration appendix, or external artifact`);
2056
+ }
2057
+ }
2058
+ return errors;
2059
+ }
2060
+ async function validateStrategyChangingDecisionPromotion(projectRoot, implementationText, plan, task, implementationDoc) {
2061
+ const taskId = String(task.id ?? "current task");
2062
+ const capsule = isRecord(plan.resume_capsule) ? plan.resume_capsule : {};
2063
+ const recoveryRefs = asStringList(capsule.recovery_refs);
2064
+ const runbookTexts = await readExistingRunbookRefs(projectRoot, recoveryRefs);
2065
+ const promotionText = [
2066
+ asStringList(capsule.do_not_retry).join("\n"),
2067
+ markdownSection(implementationText, CURRENT_OPERATOR_PATH_TERMS) ?? "",
2068
+ ...runbookTexts.map((text) => markdownSection(text, HARD_CONSTRAINT_TERMS) ?? "")
2069
+ ].join("\n");
2070
+ const sourceText = [
2071
+ asStringList(task.working_notes).join("\n"),
2072
+ removeMarkdownSections(implementationText, CURRENT_OPERATOR_PATH_TERMS),
2073
+ ...runbookTexts.map((text) => removeMarkdownSections(text, HARD_CONSTRAINT_TERMS))
2074
+ ].join("\n");
2075
+ const errors = [];
2076
+ for (const [label, terms] of STRATEGY_CHANGING_DECISION_GROUPS) {
2077
+ if (containsAny(sourceText, terms) && !containsAny(promotionText, terms)) {
2078
+ errors.push(`${taskId} strategy-changing ${label} judgment appears outside promoted hard constraints; promote it to resume_capsule.do_not_retry, runbook Hard Constraints, or Current Operator Path in ${implementationDoc}`);
2079
+ }
2080
+ }
2081
+ return errors;
2082
+ }
2083
+ async function readExistingRunbookRefs(projectRoot, refs) {
2084
+ const texts = [];
2085
+ for (const ref of refs) {
2086
+ if (!ref.startsWith(RUNBOOK_DOC_PREFIX) || !ref.endsWith(".md"))
2087
+ continue;
2088
+ const fullPath = path.join(projectRoot, ref);
2089
+ if (await pathExists(fullPath)) {
2090
+ texts.push(await readText(fullPath));
2091
+ }
2092
+ }
2093
+ return texts;
2094
+ }
2095
+ function removeMarkdownSections(text, headerTerms) {
2096
+ const lines = text.split(/\r?\n/);
2097
+ const kept = [];
2098
+ let skippingLevel = 0;
2099
+ for (const line of lines) {
2100
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
2101
+ if (match) {
2102
+ const level = match[1].length;
2103
+ if (skippingLevel > 0 && level <= skippingLevel) {
2104
+ skippingLevel = 0;
2105
+ }
2106
+ const title = match[2].toLowerCase();
2107
+ if (skippingLevel === 0 && headerTerms.some((term) => title.includes(term.toLowerCase()))) {
2108
+ skippingLevel = level;
2109
+ continue;
2110
+ }
2111
+ }
2112
+ if (skippingLevel === 0)
2113
+ kept.push(line);
2114
+ }
2115
+ return kept.join("\n");
2116
+ }
2117
+ function validateCurrentOperatorPath(fullText, taskId, implementationDoc) {
2118
+ const section = markdownSection(fullText, CURRENT_OPERATOR_PATH_TERMS);
2119
+ if (!section) {
2120
+ return [`${taskId} high-risk runtime task must include a short Current Operator Path section in ${implementationDoc}`];
2121
+ }
2122
+ const errors = [];
2123
+ const requiredFields = [
2124
+ ["canonical operator path", ["Canonical operator path", "Canonical path"]],
2125
+ ["runbook link", ["Operator runbook", "Runbook"]],
2126
+ ["credential reference name", ["Credential reference", "Credential reference name"]],
2127
+ ["command/UI channel", ["Command/UI channel", "Command channel", "UI channel"]],
2128
+ ["do-not-retry summary", ["Do-not-retry summary", "Do not retry summary"]],
2129
+ ["hard constraints", ["Hard Constraints", "Hard Constraint", "Strategy Constraints", "Strategy Constraint"]]
2130
+ ];
2131
+ for (const [label, fields] of requiredFields) {
2132
+ const value = fields.map((field) => evidenceFieldValue(section, field)).find((candidate) => candidate && candidate.trim());
2133
+ if (!value) {
2134
+ errors.push(`${taskId} Current Operator Path must record ${label} in ${implementationDoc}`);
2135
+ }
2136
+ else if (label !== "credential reference name" && isPlaceholderEvidence(value)) {
2137
+ errors.push(`${taskId} Current Operator Path ${label} must be concrete in ${implementationDoc}`);
2138
+ }
2139
+ }
2140
+ const runbookValue = evidenceFieldValue(section, "Operator runbook") ?? evidenceFieldValue(section, "Runbook") ?? section;
2141
+ if (!runbookValue.includes(RUNBOOK_DOC_PREFIX)) {
2142
+ errors.push(`${taskId} Current Operator Path must link a runbook/evidence document under ${RUNBOOK_DOC_PREFIX} in ${implementationDoc}`);
2143
+ }
2144
+ if (!containsAny(section, HARD_CONSTRAINT_TERMS)) {
2145
+ errors.push(`${taskId} Current Operator Path must promote strategy-changing recovery decisions as Hard Constraints in ${implementationDoc}`);
2146
+ }
2147
+ return errors;
2148
+ }
2149
+ function validateGateBreakdown(fullText, taskId, implementationDoc) {
2150
+ const section = markdownSection(fullText, GATE_BREAKDOWN_TERMS);
2151
+ if (!section) {
2152
+ return [`${taskId} high-risk runtime task Development Self-Test Report must include Gate Breakdown in ${implementationDoc}`];
2153
+ }
2154
+ const errors = [];
2155
+ const lowered = section.toLowerCase();
2156
+ for (const [label, terms] of GATE_BREAKDOWN_LAYER_GROUPS) {
2157
+ if (!containsAny(lowered, terms)) {
2158
+ errors.push(`${taskId} Gate Breakdown must include ${label} status/evidence in ${implementationDoc}`);
2159
+ }
2160
+ }
2161
+ const rows = markdownTableRows(section).filter((cells) => !cells.some((cell) => /gate layer|layer|层级/i.test(cell)));
2162
+ const concreteRows = rows.filter((cells) => cells.some((cell) => !isPlaceholderSelfTestReportValue(cell)));
2163
+ if (concreteRows.length < 2) {
2164
+ errors.push(`${taskId} Gate Breakdown must split evidence into multiple concrete gate layers in ${implementationDoc}`);
2165
+ }
2166
+ if (concreteRows.length <= 1 && lowered.includes("validate-dev")) {
2167
+ errors.push(`${taskId} Gate Breakdown cannot collapse high-risk runtime progress into only validate-dev in ${implementationDoc}`);
2168
+ }
1401
2169
  return errors;
1402
2170
  }
1403
2171
  function scenarioStatus(text, scenarioId) {
1404
2172
  const escaped = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1405
2173
  const pattern = new RegExp("^.*" + escaped + ".*$", "gim");
2174
+ const seen = new Set();
1406
2175
  for (const match of text.matchAll(pattern)) {
1407
- const line = match[0];
1408
- const hasPass = /\bPASS\b/i.test(line);
1409
- const hasBlocked = /\bBLOCKED\b/i.test(line);
1410
- if (hasPass && hasBlocked)
1411
- return "AMBIGUOUS";
1412
- if (hasPass)
1413
- return "PASS";
1414
- if (hasBlocked)
1415
- return "BLOCKED";
1416
- }
1417
- return undefined;
2176
+ const status = selfTestLineStatus(match[0]);
2177
+ if (status === "AMBIGUOUS")
2178
+ return status;
2179
+ if (status)
2180
+ seen.add(status);
2181
+ }
2182
+ if (seen.size > 1)
2183
+ return "AMBIGUOUS";
2184
+ return [...seen][0];
2185
+ }
2186
+ function selfTestLineStatus(line) {
2187
+ const normalized = line.toUpperCase().replace(/\bIN[\s-]+PROGRESS\b/g, "IN_PROGRESS");
2188
+ const matches = [];
2189
+ if (hasStatusToken(normalized, "PASS"))
2190
+ matches.push("PASS");
2191
+ if (hasStatusToken(normalized, "BLOCKED"))
2192
+ matches.push("BLOCKED");
2193
+ if (hasStatusToken(normalized, "IN_PROGRESS"))
2194
+ matches.push("IN_PROGRESS");
2195
+ if (hasStatusToken(normalized, "STALE"))
2196
+ matches.push("STALE");
2197
+ if (matches.length > 1)
2198
+ return "AMBIGUOUS";
2199
+ return matches[0];
2200
+ }
2201
+ function hasStatusToken(line, status) {
2202
+ const escaped = status.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2203
+ return new RegExp(`(^|[^A-Z0-9_])${escaped}([^A-Z0-9_]|$)`).test(line);
1418
2204
  }
1419
2205
  function validateScenarioTableEvidence(report, scenarioId, taskId, implementationDoc) {
1420
2206
  const errors = [];
@@ -1434,8 +2220,12 @@ function validateScenarioTableEvidence(report, scenarioId, taskId, implementatio
1434
2220
  errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table ${label} must contain concrete evidence in ${implementationDoc}`);
1435
2221
  }
1436
2222
  }
1437
- if (result && scenarioStatus(`| ${cells.join(" | ")} |`, scenarioId) === "AMBIGUOUS") {
1438
- errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table Result must choose exactly one of PASS or BLOCKED in ${implementationDoc}`);
2223
+ const tableStatus = result ? scenarioStatus(`| ${cells.join(" | ")} |`, scenarioId) : undefined;
2224
+ if (tableStatus === "AMBIGUOUS") {
2225
+ errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table Result must choose exactly one status in ${implementationDoc}`);
2226
+ }
2227
+ else if (tableStatus && tableStatus !== "PASS") {
2228
+ errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table Result is ${tableStatus}; validate-dev cannot handoff until every scenario is PASS`);
1439
2229
  }
1440
2230
  }
1441
2231
  return errors;