agent-project-sdlc 0.1.22 → 0.1.24

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 = [
@@ -104,6 +106,7 @@ const DEVELOPMENT_SELF_TEST_CONTRACT_TERMS = ["development self-test contract",
104
106
  const DEVELOPMENT_SELF_TEST_REPORT_TERMS = ["development self-test report", "开发自测报告"];
105
107
  const DEVELOPMENT_SELF_TEST_IMPACT_TERMS = ["development self-test impact", "开发自测影响"];
106
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", "模块关键测试图"];
107
110
  const GATE_BREAKDOWN_TERMS = ["gate breakdown", "gate 分层", "gate breakdown(gate 分层)"];
108
111
  const CURRENT_OPERATOR_PATH_TERMS = ["current operator path", "operator path", "当前操作路径", "当前 operator path"];
109
112
  const TESTING_HANDOFF_TERMS = ["testing handoff contract", "测试交接合同"];
@@ -125,9 +128,27 @@ const SELF_TEST_REPORT_PLACEHOLDER_TERMS = [
125
128
  "all self-test scenarios",
126
129
  "all task/module promised runnable entries",
127
130
  "actual internal key paths",
128
- "observable completion evidence"
131
+ "observable completion evidence",
132
+ "required only when",
133
+ "compact dag pointer",
134
+ "复杂 / high-risk",
135
+ "只记录实际 handoff"
129
136
  ];
130
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;
131
152
  const SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS = [
132
153
  "debug log",
133
154
  "operator log",
@@ -146,6 +167,94 @@ const SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS = [
146
167
  "诊断尝试",
147
168
  "历史流水"
148
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
+ ]
257
+ ];
149
258
  const SELF_TEST_OBSERVABLE_EVIDENCE_TERMS = [
150
259
  "pass output",
151
260
  "response",
@@ -305,6 +414,27 @@ const REVIEW_READINESS_FIELDS = [
305
414
  "Testing Handoff Readiness"
306
415
  ];
307
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
+ ];
308
438
  const RFC_SELF_TEST_TRIGGER_TERMS = [
309
439
  "entry/exit",
310
440
  "runnable entry",
@@ -319,6 +449,11 @@ const RFC_SELF_TEST_TRIGGER_TERMS = [
319
449
  "handoff",
320
450
  "blocker",
321
451
  "module key test path",
452
+ "module key test graph",
453
+ "module_key_test_graph",
454
+ "checkpoint",
455
+ "observable exit",
456
+ "evidence refs",
322
457
  "test route",
323
458
  "test path",
324
459
  "debug path",
@@ -372,6 +507,9 @@ async function validateHarness(projectRoot) {
372
507
  errors.push(`missing ${required}`);
373
508
  }
374
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));
375
513
  return { info: [`validate-harness checked ${projectRoot} (${root})`], errors };
376
514
  }
377
515
  async function validateCurrent(projectRoot) {
@@ -1002,6 +1140,205 @@ function isNotApplicableRuntimeTask(task) {
1002
1140
  const targetRuntime = isRecord(task.target_runtime_environment) ? task.target_runtime_environment : undefined;
1003
1141
  return String(evidenceLevel?.required ?? "") === "unit" && String(targetRuntime?.kind ?? "") === "not_applicable";
1004
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
+ }
1005
1342
  function validateSelfTestContract(task, requiredForRunnableBoundary) {
1006
1343
  const errors = [];
1007
1344
  const taskId = String(task.id ?? "Task");
@@ -1055,6 +1392,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
1055
1392
  errors.push(`${taskId} self_test_contract.scenarios must be a non-empty list`);
1056
1393
  }
1057
1394
  const seen = new Set();
1395
+ const scenarioIds = new Set();
1058
1396
  scenarios.forEach((scenario, index) => {
1059
1397
  if (!isRecord(scenario)) {
1060
1398
  errors.push(`${taskId} self_test_contract.scenarios[${index}] must be a mapping`);
@@ -1067,6 +1405,9 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
1067
1405
  else if (seen.has(scenarioId)) {
1068
1406
  errors.push(`${taskId} self_test_contract scenario id must be unique: ${scenarioId}`);
1069
1407
  }
1408
+ else {
1409
+ scenarioIds.add(scenarioId);
1410
+ }
1070
1411
  seen.add(scenarioId);
1071
1412
  for (const field of ["entry", "expected_exit", "evidence"]) {
1072
1413
  if (typeof scenario[field] !== "string" || !String(scenario[field]).trim() || isPlaceholderEvidence(String(scenario[field]))) {
@@ -1074,6 +1415,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
1074
1415
  }
1075
1416
  }
1076
1417
  });
1418
+ errors.push(...validateSelfTestGraph(taskId, contract, scenarioIds, requiresResumeCapsule(task)));
1077
1419
  return errors;
1078
1420
  }
1079
1421
  async function validateSelfTestContractTechPlanBinding(projectRoot, task, normalizedTechRefs) {
@@ -1101,6 +1443,9 @@ async function validateSelfTestContractTechPlanBinding(projectRoot, task, normal
1101
1443
  if (!containsAny(section, MODULE_KEY_TEST_PATH_TERMS)) {
1102
1444
  errors.push(`${taskId} tech plan Development Self-Test Contract must include Module key test path: ${source}`);
1103
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
+ }
1104
1449
  for (const scenario of Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : []) {
1105
1450
  const scenarioId = String(scenario.id ?? "").trim();
1106
1451
  if (scenarioId && !section.includes(scenarioId)) {
@@ -1317,6 +1662,95 @@ function matchesGlob(file, pattern) {
1317
1662
  function isRecord(value) {
1318
1663
  return typeof value === "object" && value !== null && !Array.isArray(value);
1319
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
+ }
1320
1754
  async function readYamlObject(filePath) {
1321
1755
  if (!(await pathExists(filePath)))
1322
1756
  return {};
@@ -1375,7 +1809,10 @@ async function validateCurrentTaskDevelopmentEvidence(projectRoot, plan) {
1375
1809
  return [`${taskId} implementation_doc is missing: ${implementationDoc}`];
1376
1810
  }
1377
1811
  const text = await readText(docPath);
1378
- return validateDevelopmentEvidenceText(text, currentTask, implementationDoc);
1812
+ return [
1813
+ ...validateDevelopmentEvidenceText(text, currentTask, implementationDoc),
1814
+ ...(await validateHighRiskRecoveryBoundaries(projectRoot, text, plan, currentTask, implementationDoc))
1815
+ ];
1379
1816
  }
1380
1817
  function currentOpenSprintTask(plan) {
1381
1818
  const currentTaskId = String(plan.current_task_id ?? "");
@@ -1459,18 +1896,24 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1459
1896
  else if (reportStatus !== "PASS") {
1460
1897
  errors.push(`${taskId} Development Self-Test Report Report Status is ${reportStatus}; validate-dev cannot handoff until the report status is PASS`);
1461
1898
  }
1899
+ const highRiskRuntime = requiresResumeCapsule(task);
1462
1900
  errors.push(...validateSelfTestReportBoundary(report, taskId, implementationDoc));
1901
+ errors.push(...validateSelfTestReportLength(report, taskId, implementationDoc, highRiskRuntime));
1463
1902
  const basicSelfTest = evidenceFieldValue(developmentEvidenceSection, "Basic Self-test Evidence") ?? "";
1464
1903
  if (!containsAny(basicSelfTest, ["Development Self-Test Report", "开发自测报告", "self-test report"])) {
1465
1904
  errors.push(`${taskId} Basic Self-test Evidence must reference the Development Self-Test Report in ${implementationDoc}`);
1466
1905
  }
1467
- 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) {
1468
1907
  const value = evidenceFieldValue(report, field);
1469
- const allowsNone = field === "Missing / Blockers";
1908
+ const allowsNone = SELF_TEST_REPORT_NONE_ALLOWED_FIELDS.has(field);
1470
1909
  if (!value || (!allowsNone && isPlaceholderEvidence(value))) {
1471
1910
  errors.push(`${taskId} Development Self-Test Report ${field} must contain executed evidence in ${implementationDoc}`);
1472
1911
  }
1473
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
+ }
1474
1917
  const source = String(contract.source ?? "").trim();
1475
1918
  if (source && !report.includes(source)) {
1476
1919
  errors.push(`${taskId} Development Self-Test Report must reference contract source ${source} in ${implementationDoc}`);
@@ -1484,10 +1927,18 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1484
1927
  if (isPlaceholderSelfTestReportValue(moduleKeyTestPath) || isTemplateModuleKeyTestPath(moduleKeyTestPath)) {
1485
1928
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must replace template placeholders with actual executed path evidence in ${implementationDoc}`);
1486
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
+ }
1487
1935
  const runnableEntry = String(contract.runnable_entry ?? "").trim();
1488
1936
  if (runnableEntry && !moduleKeyTestPath.includes(runnableEntry)) {
1489
1937
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include runnable entry ${runnableEntry} in ${implementationDoc}`);
1490
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
+ }
1491
1942
  const scenarios = Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : [];
1492
1943
  const exitEvidenceTerms = [
1493
1944
  String(contract.observable_exit ?? "").trim(),
@@ -1501,6 +1952,13 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1501
1952
  && !containsAny(moduleKeyTestPath, SELF_TEST_OBSERVABLE_EVIDENCE_TERMS)) {
1502
1953
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include observable exit or evidence from self_test_contract in ${implementationDoc}`);
1503
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
+ }
1504
1962
  for (const scenario of scenarios) {
1505
1963
  const scenarioId = String(scenario.id ?? "").trim();
1506
1964
  if (!scenarioId)
@@ -1508,6 +1966,9 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1508
1966
  if (!moduleKeyTestPath.includes(scenarioId)) {
1509
1967
  errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include scenario ${scenarioId} in ${implementationDoc}`);
1510
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
+ }
1511
1972
  const status = scenarioStatus(report, scenarioId);
1512
1973
  if (!status) {
1513
1974
  errors.push(`${taskId} Development Self-Test Report must record scenario ${scenarioId} as PASS, BLOCKED, IN_PROGRESS, or STALE in ${implementationDoc}`);
@@ -1531,7 +1992,7 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
1531
1992
  errors.push(`${taskId} page Development Self-Test Report must include browser, Playwright, screenshot, or equivalent interaction evidence in ${implementationDoc}`);
1532
1993
  }
1533
1994
  }
1534
- if (requiresResumeCapsule(task)) {
1995
+ if (highRiskRuntime) {
1535
1996
  errors.push(...validateCurrentOperatorPath(fullText, taskId, implementationDoc));
1536
1997
  errors.push(...validateGateBreakdown(fullText, taskId, implementationDoc));
1537
1998
  }
@@ -1546,6 +2007,14 @@ function normalizeSelfTestReportStatus(value) {
1546
2007
  function validateSelfTestReportBoundary(report, taskId, implementationDoc) {
1547
2008
  const errors = [];
1548
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
+ }
1549
2018
  const match = line.match(/^(#{1,6})\s+(.+)$/);
1550
2019
  if (!match)
1551
2020
  continue;
@@ -1557,6 +2026,94 @@ function validateSelfTestReportBoundary(report, taskId, implementationDoc) {
1557
2026
  }
1558
2027
  return errors;
1559
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
+ }
1560
2117
  function validateCurrentOperatorPath(fullText, taskId, implementationDoc) {
1561
2118
  const section = markdownSection(fullText, CURRENT_OPERATOR_PATH_TERMS);
1562
2119
  if (!section) {
@@ -1568,7 +2125,8 @@ function validateCurrentOperatorPath(fullText, taskId, implementationDoc) {
1568
2125
  ["runbook link", ["Operator runbook", "Runbook"]],
1569
2126
  ["credential reference name", ["Credential reference", "Credential reference name"]],
1570
2127
  ["command/UI channel", ["Command/UI channel", "Command channel", "UI channel"]],
1571
- ["do-not-retry summary", ["Do-not-retry summary", "Do not retry summary"]]
2128
+ ["do-not-retry summary", ["Do-not-retry summary", "Do not retry summary"]],
2129
+ ["hard constraints", ["Hard Constraints", "Hard Constraint", "Strategy Constraints", "Strategy Constraint"]]
1572
2130
  ];
1573
2131
  for (const [label, fields] of requiredFields) {
1574
2132
  const value = fields.map((field) => evidenceFieldValue(section, field)).find((candidate) => candidate && candidate.trim());
@@ -1583,6 +2141,9 @@ function validateCurrentOperatorPath(fullText, taskId, implementationDoc) {
1583
2141
  if (!runbookValue.includes(RUNBOOK_DOC_PREFIX)) {
1584
2142
  errors.push(`${taskId} Current Operator Path must link a runbook/evidence document under ${RUNBOOK_DOC_PREFIX} in ${implementationDoc}`);
1585
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
+ }
1586
2147
  return errors;
1587
2148
  }
1588
2149
  function validateGateBreakdown(fullText, taskId, implementationDoc) {