easy-coding-harness 0.8.3-beta.0 → 0.9.0-beta.0

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 (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +16 -10
  3. package/dist/cli.js +256 -47
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/claude/agents/ec-fixer.md +3 -2
  7. package/templates/claude/agents/ec-implementer.md +4 -1
  8. package/templates/claude/agents/ec-reviewer.md +4 -2
  9. package/templates/claude/agents/ec-verifier.md +6 -3
  10. package/templates/codex/agents/ec-fixer.toml +3 -2
  11. package/templates/codex/agents/ec-implementer.toml +4 -1
  12. package/templates/codex/agents/ec-reviewer.toml +4 -2
  13. package/templates/codex/agents/ec-verifier.toml +6 -3
  14. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +9 -7
  15. package/templates/common/skills/ec-analysis/SKILL.md +92 -266
  16. package/templates/common/skills/ec-implementing/SKILL.md +82 -131
  17. package/templates/common/skills/ec-memory/SKILL.md +23 -149
  18. package/templates/common/skills/ec-reviewing/SKILL.md +54 -73
  19. package/templates/common/skills/ec-task-management/SKILL.md +34 -94
  20. package/templates/common/skills/ec-verification/SKILL.md +54 -79
  21. package/templates/common/skills/ec-workflow/SKILL.md +109 -302
  22. package/templates/main-constraint/AGENTS.md.tpl +19 -17
  23. package/templates/main-constraint/CLAUDE.md.tpl +19 -17
  24. package/templates/qoder/agents/ec-fixer.md +3 -2
  25. package/templates/qoder/agents/ec-implementer.md +4 -1
  26. package/templates/qoder/agents/ec-reviewer.md +4 -2
  27. package/templates/qoder/agents/ec-verifier.md +6 -3
  28. package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +2 -0
  29. package/templates/runtime/templates/dev-spec-skeleton.md +14 -6
  30. package/templates/shared-hooks/easy_coding_state.py +1240 -89
  31. package/templates/shared-hooks/inject-subagent-context.py +3 -0
@@ -5,6 +5,7 @@ import json
5
5
  import os
6
6
  import re
7
7
  import secrets
8
+ import subprocess
8
9
  import time
9
10
  import uuid
10
11
  from datetime import datetime, timezone
@@ -32,6 +33,7 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
32
33
  "### 修改方案",
33
34
  "### 实施拆解",
34
35
  "### 测试策略",
36
+ "### Workflow Mode",
35
37
  "### 风险与注意事项",
36
38
  ]
37
39
 
@@ -39,6 +41,7 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
39
41
  "idle": {"INIT"},
40
42
  "INIT": {"ANALYSIS", "CLOSED"},
41
43
  "ANALYSIS": {"IMPLEMENT", "CLOSED"},
44
+ # IMPLEMENT -> VERIFICATION remains parseable only for pre-0.9 in-flight tasks.
42
45
  "IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
43
46
  "REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
44
47
  "VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
@@ -53,13 +56,25 @@ ALWAYS_AUTO_TRANSITIONS = {
53
56
  }
54
57
  READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
55
58
  NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
56
- CONFIRM_MODES = {"approve", "guard", "lite", "auto"}
57
- DEFAULT_CONFIRM_MODE = "guard"
59
+ APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
60
+ CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
61
+ WORKFLOW_MODES = {"fast", "standard", "strict"}
62
+ WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
63
+ STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
64
+ REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
65
+ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
66
+ r"(migration|migrate|schema|state[-_ ]?machine|security|payment|data[-_ ]?loss|"
67
+ r"concurren|cross[-_ ]?repo|public[-_ ]?(api|contract)|迁移|状态机|安全|支付|"
68
+ r"数据丢失|并发|跨仓|公共接口|公共契约)",
69
+ re.IGNORECASE,
70
+ )
71
+ DEFAULT_APPROVAL_MODE = "guard"
72
+ DEFAULT_WORKFLOW_MODE = "adaptive"
58
73
  CRITICAL_CONFIRM_TRANSITIONS = {
59
74
  ("ANALYSIS", "IMPLEMENT"),
60
75
  ("VERIFICATION", "MEMORY"),
61
76
  }
62
- LITE_SKIPPED_TRANSITION = ("IMPLEMENT", "REVIEW")
77
+ ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
63
78
 
64
79
  LEGACY_STAGE_MAP = {
65
80
  "WAITING_CONFIRM": "ANALYSIS",
@@ -96,6 +111,8 @@ TABLE_HEADER_CELLS = {
96
111
  "归属单元",
97
112
  "方式",
98
113
  "验证命令",
114
+ "验收条件",
115
+ "跨单元契约",
99
116
  }
100
117
 
101
118
 
@@ -269,15 +286,16 @@ def read_memory_config(root: Path) -> dict[str, int]:
269
286
  return config
270
287
 
271
288
 
272
- def read_project_confirm_mode(root: Path) -> str:
289
+ def read_project_behavior(root: Path) -> tuple[str, str]:
273
290
  path = root / ".easy-coding" / "config.yaml"
274
291
  try:
275
292
  lines = path.read_text(encoding="utf-8").splitlines()
276
293
  except OSError:
277
- return DEFAULT_CONFIRM_MODE
294
+ return DEFAULT_APPROVAL_MODE, DEFAULT_WORKFLOW_MODE
278
295
 
279
296
  in_behavior = False
280
297
  behavior_indent = 0
298
+ behavior: dict[str, str] = {}
281
299
  for raw_line in lines:
282
300
  without_comment = raw_line.split("#", 1)[0].rstrip()
283
301
  stripped = without_comment.strip()
@@ -293,25 +311,88 @@ def read_project_confirm_mode(root: Path) -> str:
293
311
  if not in_behavior or ":" not in stripped:
294
312
  continue
295
313
  key, value = stripped.split(":", 1)
296
- if key != "confirm_mode":
297
- continue
298
- mode = value.strip().strip("'\"")
299
- if mode not in CONFIRM_MODES:
300
- raise StateError(
301
- "Invalid behavior.confirm_mode in .easy-coding/config.yaml: "
302
- "expected approve, guard, lite, or auto."
303
- )
304
- return mode
305
- return DEFAULT_CONFIRM_MODE
314
+ behavior[key] = value.strip().strip("'\"")
315
+
316
+ legacy = behavior.get("confirm_mode")
317
+ approval_mode = behavior.get("approval_mode")
318
+ workflow_mode = behavior.get("workflow_mode")
319
+ if approval_mode is None:
320
+ if legacy == "lite":
321
+ approval_mode = "guard"
322
+ elif legacy in APPROVAL_MODES:
323
+ approval_mode = legacy
324
+ else:
325
+ approval_mode = DEFAULT_APPROVAL_MODE
326
+ if workflow_mode is None:
327
+ workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
328
+ if approval_mode not in APPROVAL_MODES:
329
+ raise StateError(
330
+ "Invalid behavior.approval_mode in .easy-coding/config.yaml: "
331
+ "expected approve, guard, confirm, or auto."
332
+ )
333
+ if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
334
+ raise StateError(
335
+ "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
336
+ "expected adaptive, fast, standard, or strict."
337
+ )
338
+ return approval_mode, workflow_mode
339
+
340
+
341
+ def resolve_behavior(
342
+ root: Path, session: dict
343
+ ) -> tuple[str, str | None, str, str, str | None, str]:
344
+ project_approval, project_workflow = read_project_behavior(root)
345
+ legacy = session.get("confirm_mode")
346
+ session_approval = session.get("approval_mode")
347
+ session_workflow = session.get("workflow_mode")
348
+ if session_approval is None:
349
+ if legacy == "lite":
350
+ session_approval = "guard"
351
+ elif legacy in APPROVAL_MODES:
352
+ session_approval = legacy
353
+ if session_workflow is None:
354
+ if legacy == "lite":
355
+ session_workflow = "fast"
356
+ elif legacy in APPROVAL_MODES:
357
+ session_workflow = "adaptive"
358
+ if session_approval is not None and session_approval not in APPROVAL_MODES:
359
+ raise StateError(
360
+ "Invalid session approval_mode: expected approve, guard, confirm, or auto."
361
+ )
362
+ if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
363
+ raise StateError(
364
+ "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
365
+ )
366
+ return (
367
+ project_approval,
368
+ str(session_approval) if session_approval else None,
369
+ str(session_approval or project_approval),
370
+ project_workflow,
371
+ str(session_workflow) if session_workflow else None,
372
+ str(session_workflow or project_workflow),
373
+ )
306
374
 
307
375
 
308
- def resolve_confirm_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
309
- project_mode = read_project_confirm_mode(root)
310
- session_mode = session.get("confirm_mode")
311
- if session_mode is not None and session_mode not in CONFIRM_MODES:
312
- raise StateError("Invalid session confirm_mode: expected approve, guard, lite, or auto.")
313
- effective_mode = str(session_mode or project_mode)
314
- return project_mode, str(session_mode) if session_mode else None, effective_mode
376
+ def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
377
+ behavior = resolve_behavior(root, session)
378
+ return behavior[0], behavior[1], behavior[2]
379
+
380
+
381
+ def materialize_legacy_session_behavior(session: dict) -> None:
382
+ legacy = session.get("confirm_mode")
383
+ if legacy == "lite":
384
+ session.setdefault("approval_mode", "guard")
385
+ if "workflow_mode" not in session:
386
+ session["workflow_mode"] = "fast"
387
+ session["workflow_mode_legacy_confirm_override"] = True
388
+ session.pop("workflow_mode_legacy_alias_override", None)
389
+ elif legacy in APPROVAL_MODES:
390
+ session.setdefault("approval_mode", legacy)
391
+ if "workflow_mode" not in session:
392
+ session["workflow_mode"] = "adaptive"
393
+ session["workflow_mode_legacy_alias_override"] = True
394
+ session.pop("workflow_mode_legacy_confirm_override", None)
395
+ session.pop("confirm_mode", None)
315
396
 
316
397
 
317
398
  def short_memory_entries(root: Path) -> list[dict[str, object]]:
@@ -846,6 +927,20 @@ def is_string_list(value: object, allow_empty: bool = True) -> bool:
846
927
  )
847
928
 
848
929
 
930
+ def is_valid_review_finding(value: object) -> bool:
931
+ if not isinstance(value, dict):
932
+ return False
933
+ line = value.get("line")
934
+ return (
935
+ is_non_empty_string(value.get("file"))
936
+ and isinstance(line, int)
937
+ and not isinstance(line, bool)
938
+ and line >= 1
939
+ and is_non_empty_string(value.get("issue"))
940
+ and value.get("severity") in REVIEW_FINDING_SEVERITIES
941
+ )
942
+
943
+
849
944
  def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
850
945
  remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
851
946
  resolved: set[str] = set()
@@ -861,7 +956,11 @@ def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
861
956
  return True
862
957
 
863
958
 
864
- def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bool:
959
+ def is_valid_execution_plan(
960
+ plan: object,
961
+ allow_empty_files: bool = False,
962
+ require_unit_contracts: bool = False,
963
+ ) -> bool:
865
964
  if not isinstance(plan, dict):
866
965
  return False
867
966
  strategy = plan.get("strategy")
@@ -889,6 +988,15 @@ def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bo
889
988
  for optional_list in ("rules_sections", "abstract_modules"):
890
989
  if optional_list in unit and not is_string_list(unit.get(optional_list)):
891
990
  return False
991
+ if require_unit_contracts:
992
+ for contract_field in (
993
+ "acceptance_criteria",
994
+ "test_points",
995
+ "contracts",
996
+ "risks",
997
+ ):
998
+ if not is_string_list(unit.get(contract_field), allow_empty=False):
999
+ return False
892
1000
  unit_ids.append(str(unit["id"]))
893
1001
 
894
1002
  if has_empty_file_scope and (not allow_empty_files or strategy != "single" or len(units) != 1):
@@ -947,6 +1055,19 @@ def is_read_only_execution_plan(plan: object) -> bool:
947
1055
  )
948
1056
 
949
1057
 
1058
+ def read_project_schema_version(root: Path) -> int:
1059
+ path = root / ".easy-coding" / "config.yaml"
1060
+ try:
1061
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
1062
+ line = raw_line.split("#", 1)[0].strip()
1063
+ if not line.startswith("version:"):
1064
+ continue
1065
+ return int(line.split(":", 1)[1].strip())
1066
+ except (OSError, ValueError):
1067
+ return 0
1068
+ return 0
1069
+
1070
+
950
1071
  def has_valid_execution_plan(root: Path, task_id: str) -> bool:
951
1072
  path = execution_log_path(root, task_id)
952
1073
  if not path.exists():
@@ -968,7 +1089,547 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
968
1089
  task_type = str(task.get("type") or "").strip().lower() if task else ""
969
1090
  if task_type in NO_CODE_TASK_TYPES:
970
1091
  return is_read_only_execution_plan(latest_plan)
971
- return is_valid_execution_plan(latest_plan)
1092
+ return is_valid_execution_plan(
1093
+ latest_plan,
1094
+ require_unit_contracts=read_project_schema_version(root) >= 3,
1095
+ )
1096
+
1097
+
1098
+ def execution_records(root: Path, task_id: str) -> list[dict]:
1099
+ path = execution_log_path(root, task_id)
1100
+ if not path.exists():
1101
+ return []
1102
+ records: list[dict] = []
1103
+ try:
1104
+ for line in path.read_text(encoding="utf-8").splitlines():
1105
+ if not line.strip():
1106
+ continue
1107
+ record = json.loads(line)
1108
+ if isinstance(record, dict):
1109
+ records.append(record)
1110
+ except (OSError, json.JSONDecodeError):
1111
+ return []
1112
+ return records
1113
+
1114
+
1115
+ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1116
+ latest: dict | None = None
1117
+ for record in execution_records(root, task_id):
1118
+ if record.get("type") == "plan" and is_valid_execution_plan(
1119
+ record, allow_empty_files=True
1120
+ ):
1121
+ latest = record
1122
+ return latest
1123
+
1124
+
1125
+ def existing_parent(path: Path) -> Path:
1126
+ candidate = path
1127
+ while not candidate.exists() and candidate != candidate.parent:
1128
+ candidate = candidate.parent
1129
+ return candidate.parent if candidate.is_file() else candidate
1130
+
1131
+
1132
+ def run_git(repository: Path, *args: str) -> subprocess.CompletedProcess[bytes] | None:
1133
+ try:
1134
+ return subprocess.run(
1135
+ ["git", "-C", str(repository), *args],
1136
+ stdout=subprocess.PIPE,
1137
+ stderr=subprocess.DEVNULL,
1138
+ check=False,
1139
+ )
1140
+ except OSError:
1141
+ return None
1142
+
1143
+
1144
+ def git_repository_root(path: Path) -> Path | None:
1145
+ candidate = existing_parent(path).resolve()
1146
+ for directory in (candidate, *candidate.parents):
1147
+ if (directory / ".git").exists():
1148
+ return directory
1149
+ return None
1150
+
1151
+
1152
+ def is_path_within(path: Path, parent: Path) -> bool:
1153
+ try:
1154
+ path.relative_to(parent)
1155
+ return True
1156
+ except ValueError:
1157
+ return False
1158
+
1159
+
1160
+ def minimize_repository_scopes(repository: Path, scopes: set[Path]) -> list[Path]:
1161
+ minimized: list[Path] = []
1162
+ for scope in sorted(scopes, key=lambda path: (len(path.parts), path.as_posix())):
1163
+ normalized = scope.resolve()
1164
+ if not is_path_within(normalized, repository):
1165
+ continue
1166
+ if any(is_path_within(normalized, existing) for existing in minimized):
1167
+ continue
1168
+ minimized.append(normalized)
1169
+ return minimized
1170
+
1171
+
1172
+ def task_repository_scopes(
1173
+ root: Path, task: dict | None, plan: dict
1174
+ ) -> list[tuple[Path, list[Path]]]:
1175
+ scope_candidates = [root]
1176
+ if task:
1177
+ repo_paths = task.get("repo_paths")
1178
+ if isinstance(repo_paths, dict):
1179
+ for repo_path in repo_paths.values():
1180
+ if is_non_empty_string(repo_path):
1181
+ candidate = Path(str(repo_path))
1182
+ scope_candidates.append(
1183
+ candidate if candidate.is_absolute() else root / candidate
1184
+ )
1185
+
1186
+ repositories: dict[Path, set[Path]] = {}
1187
+ for candidate in scope_candidates:
1188
+ normalized = candidate.resolve()
1189
+ if candidate.exists() and candidate.is_file():
1190
+ normalized = normalized.parent
1191
+ repository = git_repository_root(normalized)
1192
+ if repository is not None:
1193
+ repositories.setdefault(repository, set()).add(normalized)
1194
+
1195
+ for unit in plan.get("units", []):
1196
+ if not isinstance(unit, dict):
1197
+ continue
1198
+ for file_name in unit.get("files", []):
1199
+ if not is_non_empty_string(file_name):
1200
+ continue
1201
+ candidate = Path(str(file_name))
1202
+ normalized = (
1203
+ candidate if candidate.is_absolute() else root / candidate
1204
+ ).resolve()
1205
+ repository = git_repository_root(normalized)
1206
+ if repository is None:
1207
+ continue
1208
+ scopes = repositories.setdefault(repository, set())
1209
+ if not any(is_path_within(normalized, scope) for scope in scopes):
1210
+ # Without project metadata for an external file, conservatively cover
1211
+ # the full repository so unplanned sibling changes remain visible.
1212
+ scopes.add(repository)
1213
+
1214
+ return [
1215
+ (repository, minimize_repository_scopes(repository, scopes))
1216
+ for repository, scopes in sorted(
1217
+ repositories.items(), key=lambda item: item[0].as_posix()
1218
+ )
1219
+ ]
1220
+
1221
+
1222
+ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Path]:
1223
+ return [
1224
+ repository
1225
+ for repository, _scopes in task_repository_scopes(root, task, plan)
1226
+ ]
1227
+
1228
+
1229
+ def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
1230
+ return [
1231
+ f":(literal){scope.relative_to(repository).as_posix()}"
1232
+ for scope in scopes
1233
+ ]
1234
+
1235
+
1236
+ def is_easy_coding_state_path(
1237
+ repository: Path, relative_name: str, scopes: list[Path]
1238
+ ) -> bool:
1239
+ candidate = repository / relative_name
1240
+ for scope in scopes:
1241
+ if not is_path_within(candidate, scope):
1242
+ continue
1243
+ scoped_name = candidate.relative_to(scope).as_posix()
1244
+ if scoped_name == ".easy-coding" or scoped_name.startswith(".easy-coding/"):
1245
+ return True
1246
+ return False
1247
+
1248
+
1249
+ def git_index_entries(
1250
+ repository: Path, pathspecs: list[str]
1251
+ ) -> dict[bytes, tuple[bytes, bytes]]:
1252
+ result = run_git(
1253
+ repository,
1254
+ "ls-files",
1255
+ "--stage",
1256
+ "-z",
1257
+ "--",
1258
+ *pathspecs,
1259
+ )
1260
+ if result is None or result.returncode != 0:
1261
+ return {}
1262
+ entries: dict[bytes, tuple[bytes, bytes]] = {}
1263
+ for raw_entry in filter(None, result.stdout.split(b"\0")):
1264
+ try:
1265
+ metadata, raw_path = raw_entry.split(b"\t", 1)
1266
+ mode, object_id, stage = metadata.split()
1267
+ except ValueError:
1268
+ continue
1269
+ if stage == b"0":
1270
+ entries[raw_path] = (mode, object_id)
1271
+ return entries
1272
+
1273
+
1274
+ def git_worktree_blob_oid(repository: Path, relative_name: str) -> bytes | None:
1275
+ result = run_git(
1276
+ repository,
1277
+ "hash-object",
1278
+ f"--path={relative_name}",
1279
+ "--",
1280
+ relative_name,
1281
+ )
1282
+ if result is None or result.returncode != 0:
1283
+ return None
1284
+ object_id = result.stdout.strip()
1285
+ return object_id or None
1286
+
1287
+
1288
+ def worktree_git_mode(path: Path) -> bytes:
1289
+ if path.is_symlink():
1290
+ return b"120000"
1291
+ try:
1292
+ return b"100755" if path.stat().st_mode & 0o111 else b"100644"
1293
+ except OSError:
1294
+ return b"<missing-mode>"
1295
+
1296
+
1297
+ def update_git_repository_content_fingerprint(
1298
+ digest,
1299
+ root: Path,
1300
+ repository: Path,
1301
+ scopes: list[Path],
1302
+ visited: set[tuple[Path, tuple[Path, ...]]],
1303
+ ) -> None:
1304
+ normalized_repository = repository.resolve()
1305
+ normalized_scopes = tuple(scope.resolve() for scope in scopes)
1306
+ visit_key = (normalized_repository, normalized_scopes)
1307
+ if visit_key in visited:
1308
+ digest.update(b"<git-scope-cycle>\0")
1309
+ return
1310
+ visited.add(visit_key)
1311
+ try:
1312
+ digest.update(b"git-repository\0")
1313
+ digest.update(os.fsencode(display_path(root, normalized_repository)))
1314
+ digest.update(b"\0")
1315
+ pathspecs = repository_scope_pathspecs(
1316
+ normalized_repository, list(normalized_scopes)
1317
+ )
1318
+ for scope in normalized_scopes:
1319
+ relative_scope = scope.relative_to(normalized_repository).as_posix()
1320
+ digest.update(b"git-scope\0")
1321
+ digest.update(os.fsencode(relative_scope))
1322
+ digest.update(b"\0")
1323
+
1324
+ index_entries = git_index_entries(normalized_repository, pathspecs)
1325
+ listed = run_git(
1326
+ normalized_repository,
1327
+ "ls-files",
1328
+ "--cached",
1329
+ "--others",
1330
+ "--exclude-standard",
1331
+ "-z",
1332
+ "--",
1333
+ *pathspecs,
1334
+ )
1335
+ modified = run_git(
1336
+ normalized_repository,
1337
+ "diff-files",
1338
+ "--name-only",
1339
+ "-z",
1340
+ "--ignore-submodules=none",
1341
+ "--",
1342
+ *pathspecs,
1343
+ )
1344
+ if listed is None or listed.returncode != 0:
1345
+ digest.update(b"<git-files-error>\0")
1346
+ return
1347
+ if modified is None or modified.returncode != 0:
1348
+ digest.update(b"<git-diff-files-error>\0")
1349
+ return
1350
+ modified_paths = set(filter(None, modified.stdout.split(b"\0")))
1351
+
1352
+ for raw_path in sorted(set(filter(None, listed.stdout.split(b"\0")))):
1353
+ relative_name = os.fsdecode(raw_path)
1354
+ if is_easy_coding_state_path(
1355
+ normalized_repository, relative_name, list(normalized_scopes)
1356
+ ):
1357
+ continue
1358
+ candidate = normalized_repository / relative_name
1359
+ index_entry = index_entries.get(raw_path)
1360
+ if index_entry is not None and index_entry[0] == b"160000":
1361
+ digest.update(b"git-entry\0")
1362
+ digest.update(raw_path)
1363
+ digest.update(b"\0gitlink\0")
1364
+ submodule_root = git_repository_root(candidate)
1365
+ if (
1366
+ submodule_root is not None
1367
+ and submodule_root.resolve() == candidate.resolve()
1368
+ ):
1369
+ update_git_repository_content_fingerprint(
1370
+ digest,
1371
+ root,
1372
+ submodule_root,
1373
+ [submodule_root],
1374
+ visited,
1375
+ )
1376
+ else:
1377
+ digest.update(index_entry[1])
1378
+ digest.update(b"\0")
1379
+ continue
1380
+
1381
+ exists = candidate.exists() or candidate.is_symlink()
1382
+ if not exists:
1383
+ if raw_path in modified_paths or index_entry is None:
1384
+ # A worktree deletion is canonically absent before and after staging.
1385
+ continue
1386
+ # Sparse or otherwise intentionally absent tracked files retain index content.
1387
+ mode, object_id = index_entry
1388
+ elif index_entry is not None and raw_path not in modified_paths:
1389
+ mode, object_id = index_entry
1390
+ else:
1391
+ mode = worktree_git_mode(candidate)
1392
+ object_id = git_worktree_blob_oid(
1393
+ normalized_repository, relative_name
1394
+ )
1395
+ if object_id is None:
1396
+ try:
1397
+ content = (
1398
+ os.fsencode(os.readlink(candidate))
1399
+ if candidate.is_symlink()
1400
+ else candidate.read_bytes()
1401
+ )
1402
+ except OSError:
1403
+ content = b"<missing>"
1404
+ object_id = hashlib.sha256(content).hexdigest().encode("ascii")
1405
+
1406
+ digest.update(b"git-entry\0")
1407
+ digest.update(raw_path)
1408
+ digest.update(b"\0")
1409
+ digest.update(mode)
1410
+ digest.update(b"\0")
1411
+ digest.update(object_id)
1412
+ digest.update(b"\0")
1413
+ finally:
1414
+ visited.remove(visit_key)
1415
+
1416
+
1417
+ def update_git_worktree_fingerprint(
1418
+ digest,
1419
+ root: Path,
1420
+ task: dict | None,
1421
+ plan: dict,
1422
+ ) -> None:
1423
+ visited: set[tuple[Path, tuple[Path, ...]]] = set()
1424
+ for repository, scopes in task_repository_scopes(root, task, plan):
1425
+ if not scopes:
1426
+ continue
1427
+ update_git_repository_content_fingerprint(
1428
+ digest, root, repository, scopes, visited
1429
+ )
1430
+
1431
+
1432
+ def implementation_fingerprint(root: Path, task_id: str) -> str:
1433
+ plan = latest_execution_plan(root, task_id)
1434
+ if not plan:
1435
+ raise StateError("Cannot calculate implementation fingerprint without a valid plan.")
1436
+ task = load_task(root, task_id)
1437
+ workflow_mode = str(task.get("workflow_mode") or "") if task else ""
1438
+ digest = hashlib.sha256()
1439
+ digest.update(b"workflow-mode\0")
1440
+ digest.update(workflow_mode.encode("utf-8"))
1441
+ digest.update(b"\0")
1442
+ digest.update(b"execution-plan\0")
1443
+ digest.update(
1444
+ json.dumps(
1445
+ plan,
1446
+ ensure_ascii=False,
1447
+ sort_keys=True,
1448
+ separators=(",", ":"),
1449
+ ).encode("utf-8")
1450
+ )
1451
+ digest.update(b"\0")
1452
+ update_git_worktree_fingerprint(digest, root, task, plan)
1453
+ file_names = sorted(
1454
+ {
1455
+ str(file_name)
1456
+ for unit in plan.get("units", [])
1457
+ if isinstance(unit, dict)
1458
+ for file_name in unit.get("files", [])
1459
+ if is_non_empty_string(file_name)
1460
+ }
1461
+ )
1462
+ for file_name in file_names:
1463
+ candidate = Path(file_name)
1464
+ was_absolute = candidate.is_absolute()
1465
+ if not was_absolute:
1466
+ candidate = root / candidate
1467
+ resolved = candidate.resolve()
1468
+ if not was_absolute:
1469
+ try:
1470
+ resolved.relative_to(root.resolve())
1471
+ except ValueError as error:
1472
+ raise StateError(f"Execution plan file escapes project root: {file_name}") from error
1473
+ digest.update(file_name.encode("utf-8"))
1474
+ digest.update(b"\0")
1475
+ try:
1476
+ digest.update(resolved.read_bytes())
1477
+ except OSError:
1478
+ digest.update(b"<missing>")
1479
+ digest.update(b"\0")
1480
+ return digest.hexdigest()
1481
+
1482
+
1483
+ def behavior_config_fingerprint(root: Path) -> str:
1484
+ path = root / ".easy-coding" / "config.yaml"
1485
+ digest = hashlib.sha256()
1486
+ try:
1487
+ digest.update(path.read_bytes())
1488
+ except OSError:
1489
+ digest.update(b"<missing-config>")
1490
+ return digest.hexdigest()
1491
+
1492
+
1493
+ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
1494
+ return {
1495
+ "implementation_fingerprint": implementation_fingerprint(root, task_id),
1496
+ "config_fingerprint": behavior_config_fingerprint(root),
1497
+ }
1498
+
1499
+
1500
+ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1501
+ if task.get("workflow_mode_legacy") is True:
1502
+ return
1503
+ expected = implementation_fingerprint(root, task_id)
1504
+ latest_by_dimension: dict[str, dict] = {}
1505
+ for record in execution_records(root, task_id):
1506
+ if (
1507
+ record.get("type") == "review"
1508
+ and record.get("implementation_fingerprint") == expected
1509
+ and is_non_empty_string(record.get("dimension"))
1510
+ ):
1511
+ latest_by_dimension[str(record["dimension"])] = record
1512
+ if not latest_by_dimension:
1513
+ raise StateError(
1514
+ "REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
1515
+ )
1516
+ for record in latest_by_dimension.values():
1517
+ if (
1518
+ not is_non_empty_string(record.get("reviewer"))
1519
+ or not is_non_empty_string(record.get("timestamp"))
1520
+ or not isinstance(record.get("findings"), list)
1521
+ ):
1522
+ raise StateError(
1523
+ "Review evidence for new tasks must include reviewer, timestamp, and a findings array."
1524
+ )
1525
+ if not all(is_valid_review_finding(finding) for finding in record["findings"]):
1526
+ raise StateError(
1527
+ "Each review finding must include a non-empty file and issue, a positive integer "
1528
+ "line, and severity error, warning, or info."
1529
+ )
1530
+ has_failed_dimension = False
1531
+ for record in latest_by_dimension.values():
1532
+ findings = record.get("findings")
1533
+ has_blocker = isinstance(findings, list) and any(
1534
+ isinstance(finding, dict)
1535
+ and str(finding.get("severity") or "").lower() == "error"
1536
+ for finding in findings
1537
+ )
1538
+ if record.get("passed") is not True or has_blocker:
1539
+ has_failed_dimension = True
1540
+ break
1541
+ if has_failed_dimension:
1542
+ raise StateError(
1543
+ "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
1544
+ )
1545
+ if task.get("workflow_mode") == "strict" and len(latest_by_dimension) < 2:
1546
+ raise StateError(
1547
+ "Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
1548
+ )
1549
+
1550
+
1551
+ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
1552
+ fingerprints = evidence_fingerprints(root, task_id)
1553
+ if (
1554
+ task.get("workflow_mode_legacy") is not True
1555
+ and task.get("workflow_mode_legacy_review_bypass_fingerprint")
1556
+ != fingerprints["implementation_fingerprint"]
1557
+ ):
1558
+ validate_review_readiness(root, task_id, task)
1559
+ latest_by_check: dict[str, dict] = {}
1560
+ for record in execution_records(root, task_id):
1561
+ if (
1562
+ record.get("type") == "verify"
1563
+ and record.get("implementation_fingerprint")
1564
+ == fingerprints["implementation_fingerprint"]
1565
+ and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
1566
+ and is_non_empty_string(record.get("check"))
1567
+ ):
1568
+ check = str(record["check"])
1569
+ previous = latest_by_check.get(check)
1570
+ if (
1571
+ record.get("applicable") is False
1572
+ and previous is not None
1573
+ and previous.get("applicable") is not False
1574
+ ):
1575
+ continue
1576
+ latest_by_check[check] = record
1577
+ if not latest_by_check:
1578
+ raise StateError(
1579
+ "VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
1580
+ )
1581
+ if task.get("workflow_mode_legacy") is not True:
1582
+ for record in latest_by_check.values():
1583
+ check_type = str(record.get("check_type") or "")
1584
+ if (
1585
+ check_type not in STRICT_VERIFICATION_CHECK_TYPES
1586
+ or not is_non_empty_string(record.get("timestamp"))
1587
+ or (
1588
+ record.get("applicable") is not False
1589
+ and not is_non_empty_string(record.get("command"))
1590
+ )
1591
+ ):
1592
+ raise StateError(
1593
+ "Verification evidence for new tasks must include check_type, timestamp, and command for applicable checks."
1594
+ )
1595
+ if record.get("applicable") is False and not is_non_empty_string(
1596
+ record.get("not_applicable_reason")
1597
+ ):
1598
+ raise StateError(
1599
+ "Verification evidence marked not applicable must include a non-empty not_applicable_reason."
1600
+ )
1601
+ applicable_records = [
1602
+ record for record in latest_by_check.values() if record.get("applicable") is not False
1603
+ ]
1604
+ if not applicable_records:
1605
+ raise StateError(
1606
+ "VERIFICATION cannot advance to MEMORY without at least one applicable executed check."
1607
+ )
1608
+ if any(record.get("passed") is not True for record in applicable_records):
1609
+ raise StateError(
1610
+ "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
1611
+ )
1612
+ if task.get("workflow_mode") == "strict":
1613
+ latest_by_type: dict[str, dict] = {}
1614
+ for record in latest_by_check.values():
1615
+ check_type = str(record.get("check_type") or "")
1616
+ if check_type in STRICT_VERIFICATION_CHECK_TYPES:
1617
+ latest_by_type[check_type] = record
1618
+ missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
1619
+ if missing_types:
1620
+ raise StateError(
1621
+ "Strict workflow requires current verification evidence for every check type: "
1622
+ + ", ".join(missing_types)
1623
+ + "."
1624
+ )
1625
+ for check_type, record in latest_by_type.items():
1626
+ if record.get("applicable") is False and not is_non_empty_string(
1627
+ record.get("not_applicable_reason")
1628
+ ):
1629
+ raise StateError(
1630
+ "Strict workflow requires a non-empty not_applicable_reason when "
1631
+ f"{check_type} is marked not applicable."
1632
+ )
972
1633
 
973
1634
 
974
1635
  def validate_read_only_completion(root: Path, task_id: str) -> None:
@@ -1248,41 +1909,38 @@ def transition_requires_confirmation(
1248
1909
  previous: str,
1249
1910
  current: str,
1250
1911
  task_type: str,
1251
- confirm_mode: str,
1912
+ approval_mode: str,
1252
1913
  ) -> bool:
1253
1914
  if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
1254
1915
  return False
1255
1916
  if current == "CLOSED":
1256
1917
  return True
1257
- if confirm_mode == "auto":
1918
+ if approval_mode == "auto":
1258
1919
  return False
1259
- if confirm_mode in {"guard", "lite"}:
1920
+ if approval_mode == "guard":
1260
1921
  return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
1261
- if confirm_mode == "approve":
1922
+ if approval_mode == "confirm":
1923
+ return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
1924
+ if approval_mode == "approve":
1262
1925
  return True
1263
- raise StateError(f"Unknown confirm mode: {confirm_mode}")
1264
-
1265
-
1266
- def validate_confirm_mode_transition(
1267
- previous: str,
1268
- current: str,
1269
- confirm_mode: str,
1270
- ) -> str | None:
1271
- if confirm_mode == "lite" and (previous, current) == LITE_SKIPPED_TRANSITION:
1272
- return "LITE MODE TRANSITION: IMPLEMENT -> REVIEW is disabled; use IMPLEMENT -> VERIFICATION."
1273
- return None
1926
+ raise StateError(f"Unknown approval mode: {approval_mode}")
1274
1927
 
1275
1928
 
1276
1929
  def is_automatic_transition(
1277
1930
  previous: str,
1278
1931
  current: str,
1279
1932
  task_type: str,
1280
- confirm_mode: str,
1933
+ approval_mode: str,
1281
1934
  ) -> bool:
1282
- return not transition_requires_confirmation(previous, current, task_type, confirm_mode)
1935
+ return not transition_requires_confirmation(previous, current, task_type, approval_mode)
1283
1936
 
1284
1937
 
1285
- def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
1938
+ def validate_transition(
1939
+ previous: str,
1940
+ current: str,
1941
+ task_type: str = "",
1942
+ task: dict | None = None,
1943
+ ) -> str | None:
1286
1944
  if previous == current:
1287
1945
  return None
1288
1946
  normalized_task_type = task_type.strip().lower()
@@ -1291,6 +1949,11 @@ def validate_transition(previous: str, current: str, task_type: str = "") -> str
1291
1949
  allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
1292
1950
  elif previous == "IMPLEMENT":
1293
1951
  allowed.discard("COMPLETE")
1952
+ if not (
1953
+ isinstance(task, dict)
1954
+ and task.get("workflow_mode_legacy_direct_edge") is True
1955
+ ):
1956
+ allowed.discard("VERIFICATION")
1294
1957
  if current in allowed:
1295
1958
  return None
1296
1959
  return (
@@ -1326,9 +1989,20 @@ def snapshot_state(
1326
1989
  missing = False
1327
1990
  status = "idle"
1328
1991
 
1329
- project_confirm_mode, session_confirm_mode, effective_confirm_mode = resolve_confirm_mode(
1330
- root, resolved_session
1331
- )
1992
+ (
1993
+ project_approval_mode,
1994
+ session_approval_mode,
1995
+ effective_approval_mode,
1996
+ project_workflow_mode,
1997
+ session_workflow_mode,
1998
+ configured_workflow_mode,
1999
+ ) = resolve_behavior(root, resolved_session)
2000
+ concrete_workflow_mode = None
2001
+ if task:
2002
+ concrete_workflow_mode = task.get("workflow_mode")
2003
+ proposal = task.get("workflow_mode_proposal")
2004
+ if concrete_workflow_mode is None and isinstance(proposal, dict):
2005
+ concrete_workflow_mode = proposal.get("selected_mode")
1332
2006
 
1333
2007
  return {
1334
2008
  "session_file": display_path(root, session_path),
@@ -1342,9 +2016,17 @@ def snapshot_state(
1342
2016
  "last_agent": task.get("last_agent") if task else None,
1343
2017
  "project_init_required": is_project_init_required(root),
1344
2018
  "pending_init_version": get_pending_init_version(root),
1345
- "project_confirm_mode": project_confirm_mode,
1346
- "session_confirm_mode": session_confirm_mode,
1347
- "effective_confirm_mode": effective_confirm_mode,
2019
+ "project_approval_mode": project_approval_mode,
2020
+ "session_approval_mode": session_approval_mode,
2021
+ "effective_approval_mode": effective_approval_mode,
2022
+ "project_workflow_mode": project_workflow_mode,
2023
+ "session_workflow_mode": session_workflow_mode,
2024
+ "configured_workflow_mode": configured_workflow_mode,
2025
+ "concrete_workflow_mode": concrete_workflow_mode,
2026
+ # Compatibility output aliases for pre-0.9 clients.
2027
+ "project_confirm_mode": project_approval_mode,
2028
+ "session_confirm_mode": session_approval_mode,
2029
+ "effective_confirm_mode": effective_approval_mode,
1348
2030
  "harness_disabled": resolved_session.get("harness_disabled") is True,
1349
2031
  }
1350
2032
 
@@ -1356,7 +2038,9 @@ def build_status_line(
1356
2038
  session_file: str | Path | None = None,
1357
2039
  ) -> str:
1358
2040
  state = snapshot_state(root, session_file, session)
1359
- status_brand = f"> **Easy Coding** · **{str(state['effective_confirm_mode']).capitalize()}**"
2041
+ approval = str(state["effective_approval_mode"]).capitalize()
2042
+ workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
2043
+ status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
1360
2044
  task_id = state["current_task"]
1361
2045
  if task_id:
1362
2046
  status = str(state["status"])
@@ -1395,8 +2079,11 @@ def build_machine_breadcrumbs(
1395
2079
  lines = [
1396
2080
  f"[workflow-state:{stage}]",
1397
2081
  f"[easy-coding:session-file:{resolved_session_file}]",
1398
- f"[easy-coding:confirm-mode:{state['effective_confirm_mode']}]",
2082
+ f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
2083
+ f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
1399
2084
  ]
2085
+ if state.get("concrete_workflow_mode"):
2086
+ lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
1400
2087
 
1401
2088
  if task_id:
1402
2089
  lines.append(f"[current-task:{task_id}]")
@@ -1412,19 +2099,21 @@ def build_machine_breadcrumbs(
1412
2099
  if target:
1413
2100
  lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
1414
2101
  task_type = str(task.get("type") or "") if task else ""
1415
- # A mode switch can leave a REVIEW edge that lite must bypass instead of consume.
1416
- mode_violation = validate_confirm_mode_transition(
1417
- source,
1418
- target,
1419
- str(state["effective_confirm_mode"]),
2102
+ legacy_review_bypass = (
2103
+ source == "IMPLEMENT"
2104
+ and target == "REVIEW"
2105
+ and isinstance(task, dict)
2106
+ and task.get("workflow_mode_legacy_direct_edge") is True
1420
2107
  )
1421
- if mode_violation:
1422
- lines.append(f"[easy-coding:lite-review-bypass-required:{source}->{target}]")
2108
+ if legacy_review_bypass:
2109
+ lines.append(
2110
+ "[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
2111
+ )
1423
2112
  elif is_automatic_transition(
1424
2113
  source,
1425
2114
  target,
1426
2115
  task_type,
1427
- str(state["effective_confirm_mode"]),
2116
+ str(state["effective_approval_mode"]),
1428
2117
  ):
1429
2118
  lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
1430
2119
  else:
@@ -1575,16 +2264,52 @@ def clear_current_task(root: Path, agent: str, session_file: str | Path | None =
1575
2264
  return snapshot_state(root, session_file, session)
1576
2265
 
1577
2266
 
1578
- def set_session_confirm_mode(
2267
+ def set_session_approval_mode(
2268
+ root: Path,
2269
+ mode: str,
2270
+ agent: str,
2271
+ session_file: str | Path | None = None,
2272
+ ) -> dict:
2273
+ if mode not in APPROVAL_MODES:
2274
+ raise StateError("Invalid approval mode: expected approve, guard, confirm, or auto.")
2275
+ session = ensure_session(root, session_file)
2276
+ materialize_legacy_session_behavior(session)
2277
+ session["approval_mode"] = mode
2278
+ session["last_agent"] = agent
2279
+ write_session(root, session, session_file)
2280
+ snapshot = snapshot_state(root, session_file, session)
2281
+ snapshot["action"] = "set-approval-mode"
2282
+ return snapshot
2283
+
2284
+
2285
+ def set_session_legacy_confirm_mode(
1579
2286
  root: Path,
1580
2287
  mode: str,
1581
2288
  agent: str,
1582
2289
  session_file: str | Path | None = None,
1583
2290
  ) -> dict:
1584
- if mode not in CONFIRM_MODES:
1585
- raise StateError("Invalid confirm mode: expected approve, guard, lite, or auto.")
1586
2291
  session = ensure_session(root, session_file)
1587
- session["confirm_mode"] = mode
2292
+ materialize_legacy_session_behavior(session)
2293
+ if mode == "lite":
2294
+ session["approval_mode"] = "guard"
2295
+ session["workflow_mode"] = "fast"
2296
+ session["workflow_mode_legacy_confirm_override"] = True
2297
+ session.pop("workflow_mode_legacy_alias_override", None)
2298
+ else:
2299
+ session["approval_mode"] = mode
2300
+ legacy_lite_owned = (
2301
+ session.pop("workflow_mode_legacy_confirm_override", None) is True
2302
+ )
2303
+ legacy_alias_owned = (
2304
+ session.get("workflow_mode_legacy_alias_override") is True
2305
+ )
2306
+ if (
2307
+ "workflow_mode" not in session
2308
+ or legacy_lite_owned
2309
+ or legacy_alias_owned
2310
+ ):
2311
+ session["workflow_mode"] = "adaptive"
2312
+ session["workflow_mode_legacy_alias_override"] = True
1588
2313
  session["last_agent"] = agent
1589
2314
  write_session(root, session, session_file)
1590
2315
  snapshot = snapshot_state(root, session_file, session)
@@ -1592,13 +2317,33 @@ def set_session_confirm_mode(
1592
2317
  return snapshot
1593
2318
 
1594
2319
 
1595
- def clear_session_confirm_mode(
2320
+ def clear_session_approval_mode(
1596
2321
  root: Path,
1597
2322
  agent: str,
1598
2323
  session_file: str | Path | None = None,
1599
2324
  ) -> dict:
1600
2325
  session = ensure_session(root, session_file)
1601
- session.pop("confirm_mode", None)
2326
+ materialize_legacy_session_behavior(session)
2327
+ session.pop("approval_mode", None)
2328
+ session["last_agent"] = agent
2329
+ write_session(root, session, session_file)
2330
+ snapshot = snapshot_state(root, session_file, session)
2331
+ snapshot["action"] = "clear-approval-mode"
2332
+ return snapshot
2333
+
2334
+
2335
+ def clear_session_legacy_confirm_mode(
2336
+ root: Path,
2337
+ agent: str,
2338
+ session_file: str | Path | None = None,
2339
+ ) -> dict:
2340
+ session = ensure_session(root, session_file)
2341
+ materialize_legacy_session_behavior(session)
2342
+ session.pop("approval_mode", None)
2343
+ legacy_lite_owned = session.pop("workflow_mode_legacy_confirm_override", False)
2344
+ legacy_alias_owned = session.pop("workflow_mode_legacy_alias_override", False)
2345
+ if legacy_lite_owned or legacy_alias_owned:
2346
+ session.pop("workflow_mode", None)
1602
2347
  session["last_agent"] = agent
1603
2348
  write_session(root, session, session_file)
1604
2349
  snapshot = snapshot_state(root, session_file, session)
@@ -1606,6 +2351,45 @@ def clear_session_confirm_mode(
1606
2351
  return snapshot
1607
2352
 
1608
2353
 
2354
+ def set_session_workflow_mode(
2355
+ root: Path,
2356
+ mode: str,
2357
+ agent: str,
2358
+ session_file: str | Path | None = None,
2359
+ ) -> dict:
2360
+ if mode not in CONFIGURED_WORKFLOW_MODES:
2361
+ raise StateError(
2362
+ "Invalid workflow mode: expected adaptive, fast, standard, or strict."
2363
+ )
2364
+ session = ensure_session(root, session_file)
2365
+ materialize_legacy_session_behavior(session)
2366
+ session["workflow_mode"] = mode
2367
+ session.pop("workflow_mode_legacy_confirm_override", None)
2368
+ session.pop("workflow_mode_legacy_alias_override", None)
2369
+ session["last_agent"] = agent
2370
+ write_session(root, session, session_file)
2371
+ snapshot = snapshot_state(root, session_file, session)
2372
+ snapshot["action"] = "set-workflow-mode"
2373
+ return snapshot
2374
+
2375
+
2376
+ def clear_session_workflow_mode(
2377
+ root: Path,
2378
+ agent: str,
2379
+ session_file: str | Path | None = None,
2380
+ ) -> dict:
2381
+ session = ensure_session(root, session_file)
2382
+ materialize_legacy_session_behavior(session)
2383
+ session.pop("workflow_mode", None)
2384
+ session.pop("workflow_mode_legacy_confirm_override", None)
2385
+ session.pop("workflow_mode_legacy_alias_override", None)
2386
+ session["last_agent"] = agent
2387
+ write_session(root, session, session_file)
2388
+ snapshot = snapshot_state(root, session_file, session)
2389
+ snapshot["action"] = "clear-workflow-mode"
2390
+ return snapshot
2391
+
2392
+
1609
2393
  def set_harness_disabled(
1610
2394
  root: Path,
1611
2395
  disabled: bool,
@@ -1751,6 +2535,200 @@ def resolve_current_task(
1751
2535
  return session, str(resolved_task_id), task
1752
2536
 
1753
2537
 
2538
+ def validate_workflow_mode_proposal(
2539
+ root: Path,
2540
+ session: dict,
2541
+ proposal: object,
2542
+ task_id: str | None = None,
2543
+ ) -> dict:
2544
+ if not isinstance(proposal, dict):
2545
+ raise StateError("workflow_mode_proposal is missing.")
2546
+ configured = str(proposal.get("configured_mode") or "")
2547
+ selected = str(proposal.get("selected_mode") or "")
2548
+ minimum = str(proposal.get("minimum_mode") or "")
2549
+ source = str(proposal.get("source") or "")
2550
+ reasons = proposal.get("reasons")
2551
+ effective_configured = resolve_behavior(root, session)[5]
2552
+ if configured != effective_configured:
2553
+ raise StateError(
2554
+ "Workflow proposal configured_mode no longer matches the effective project/session setting."
2555
+ )
2556
+ if configured not in CONFIGURED_WORKFLOW_MODES:
2557
+ raise StateError("Invalid configured workflow mode.")
2558
+ if selected not in WORKFLOW_MODES or minimum not in WORKFLOW_MODES:
2559
+ raise StateError("selected_mode and minimum_mode must be fast, standard, or strict.")
2560
+ if source not in {"project", "session", "adaptive", "user", "migration"}:
2561
+ raise StateError("Invalid workflow proposal source.")
2562
+ if not is_string_list(reasons, allow_empty=False):
2563
+ raise StateError("Workflow proposal reasons must contain at least one non-empty reason.")
2564
+ required_rank = WORKFLOW_MODE_RANK[minimum]
2565
+ if configured in WORKFLOW_MODES and WORKFLOW_MODE_RANK[minimum] < WORKFLOW_MODE_RANK[configured]:
2566
+ raise StateError(
2567
+ f"Workflow minimum {minimum} is below configured floor {configured}."
2568
+ )
2569
+ if task_id:
2570
+ calculated_minimum, calculated_reasons = calculate_workflow_floor(root, task_id)
2571
+ calculated_rank = WORKFLOW_MODE_RANK[calculated_minimum]
2572
+ if WORKFLOW_MODE_RANK[minimum] < calculated_rank:
2573
+ raise StateError(
2574
+ f"Workflow minimum {minimum} is below calculated floor {calculated_minimum}: "
2575
+ + ", ".join(calculated_reasons)
2576
+ )
2577
+ required_rank = max(required_rank, calculated_rank)
2578
+ if configured in WORKFLOW_MODES:
2579
+ required_rank = max(required_rank, WORKFLOW_MODE_RANK[configured])
2580
+ if WORKFLOW_MODE_RANK[selected] < required_rank:
2581
+ raise StateError(
2582
+ f"Workflow mode {selected} is below the allowed minimum for this task."
2583
+ )
2584
+ return proposal
2585
+
2586
+
2587
+ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
2588
+ task = load_task(root, task_id)
2589
+ if task is None:
2590
+ raise StateError(f"Task not found: {task_id}")
2591
+ task_type = str(task.get("type") or "").strip().lower()
2592
+ if task_type in NO_CODE_TASK_TYPES:
2593
+ return "fast", ["read-only-task"]
2594
+
2595
+ plan = latest_execution_plan(root, task_id)
2596
+ if not plan:
2597
+ raise StateError("Cannot calculate workflow floor without a valid execution plan.")
2598
+ units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
2599
+ files = {
2600
+ str(file_name)
2601
+ for unit in units
2602
+ for file_name in unit.get("files", [])
2603
+ if is_non_empty_string(file_name)
2604
+ }
2605
+ repositories = task_repository_roots(root, task, plan)
2606
+ repos = task.get("repos")
2607
+ repo_paths = task.get("repo_paths")
2608
+ metadata_repo_count = max(
2609
+ len(repos) if isinstance(repos, list) else 0,
2610
+ len(repo_paths) if isinstance(repo_paths, dict) else 0,
2611
+ )
2612
+ repo_count = max(len(repositories), metadata_repo_count)
2613
+ risk_text = " ".join(
2614
+ [
2615
+ str(task.get("title") or ""),
2616
+ task_type,
2617
+ *files,
2618
+ *[
2619
+ str(item)
2620
+ for unit in units
2621
+ for field in ("risks", "contracts")
2622
+ for item in unit.get(field, [])
2623
+ if is_non_empty_string(item)
2624
+ and str(item).strip().lower() not in {"none", "no", "n/a", "无", "无风险"}
2625
+ ],
2626
+ ]
2627
+ )
2628
+ strict_reasons: list[str] = []
2629
+ if repo_count > 1:
2630
+ strict_reasons.append("cross-repository-scope")
2631
+ if len(units) >= 4 or len(files) >= 8:
2632
+ strict_reasons.append("broad-change-scope")
2633
+ if STRICT_WORKFLOW_RISK_PATTERN.search(risk_text):
2634
+ strict_reasons.append("high-risk-contract-or-domain")
2635
+ if strict_reasons:
2636
+ return "strict", strict_reasons
2637
+
2638
+ standard_reasons: list[str] = []
2639
+ if len(units) > 1:
2640
+ standard_reasons.append("multiple-units")
2641
+ if len(files) >= 3:
2642
+ standard_reasons.append("multi-file-impact")
2643
+ if plan.get("strategy") == "parallel":
2644
+ standard_reasons.append("parallel-execution")
2645
+ if standard_reasons:
2646
+ return "standard", standard_reasons
2647
+ return "fast", ["single-bounded-unit"]
2648
+
2649
+
2650
+ def propose_workflow_mode(
2651
+ root: Path,
2652
+ configured_mode: str,
2653
+ selected_mode: str,
2654
+ minimum_mode: str,
2655
+ source: str,
2656
+ reasons: list[str],
2657
+ agent: str,
2658
+ task_id: str | None = None,
2659
+ session_file: str | Path | None = None,
2660
+ ) -> dict:
2661
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2662
+ if str(task.get("status") or "") != "ANALYSIS":
2663
+ raise StateError("Workflow mode can only be proposed during ANALYSIS.")
2664
+ proposal = {
2665
+ "configured_mode": configured_mode,
2666
+ "selected_mode": selected_mode,
2667
+ "minimum_mode": minimum_mode,
2668
+ "source": source,
2669
+ "reasons": [reason.strip() for reason in reasons if reason.strip()],
2670
+ "proposed_at": now_iso(),
2671
+ "proposed_by": agent,
2672
+ }
2673
+ validate_workflow_mode_proposal(root, session, proposal, resolved_task_id)
2674
+ task["workflow_mode_proposal"] = proposal
2675
+ task["last_agent"] = agent
2676
+ write_task(root, resolved_task_id, task)
2677
+ snapshot = snapshot_state(root, session_file, session)
2678
+ snapshot["action"] = "propose-workflow-mode"
2679
+ return snapshot
2680
+
2681
+
2682
+ def freeze_workflow_mode(
2683
+ root: Path, session: dict, task_id: str, task: dict, agent: str
2684
+ ) -> None:
2685
+ proposal = validate_workflow_mode_proposal(
2686
+ root, session, task.get("workflow_mode_proposal"), task_id
2687
+ )
2688
+ task["workflow_mode"] = proposal["selected_mode"]
2689
+ task["workflow_mode_confirmed_at"] = now_iso()
2690
+ task["workflow_mode_confirmed_by"] = agent
2691
+
2692
+
2693
+ def raise_workflow_mode(
2694
+ root: Path,
2695
+ mode: str,
2696
+ reason: str,
2697
+ agent: str,
2698
+ task_id: str | None = None,
2699
+ session_file: str | Path | None = None,
2700
+ ) -> dict:
2701
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2702
+ stage = str(task.get("status") or "")
2703
+ if stage == "VERIFICATION":
2704
+ raise StateError(
2705
+ "Return to IMPLEMENT before raising workflow mode from VERIFICATION so the "
2706
+ "task can re-enter REVIEW with fresh evidence."
2707
+ )
2708
+ if stage not in {"IMPLEMENT", "REVIEW"}:
2709
+ raise StateError("A frozen workflow mode can only be raised during active execution.")
2710
+ current = str(task.get("workflow_mode") or "")
2711
+ if current not in WORKFLOW_MODES or mode not in WORKFLOW_MODES:
2712
+ raise StateError("Workflow mode must be frozen before it can be raised.")
2713
+ if WORKFLOW_MODE_RANK[mode] <= WORKFLOW_MODE_RANK[current]:
2714
+ raise StateError(f"Workflow mode can only be raised above {current}.")
2715
+ task["workflow_mode"] = mode
2716
+ task.setdefault("workflow_mode_escalations", []).append(
2717
+ {
2718
+ "from": current,
2719
+ "to": mode,
2720
+ "reason": reason.strip(),
2721
+ "raised_at": now_iso(),
2722
+ "raised_by": agent,
2723
+ }
2724
+ )
2725
+ task["last_agent"] = agent
2726
+ write_task(root, resolved_task_id, task)
2727
+ snapshot = snapshot_state(root, session_file, session)
2728
+ snapshot["action"] = "raise-workflow-mode"
2729
+ return snapshot
2730
+
2731
+
1754
2732
  def request_transition(
1755
2733
  root: Path,
1756
2734
  stage: str,
@@ -1764,23 +2742,31 @@ def request_transition(
1764
2742
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1765
2743
  previous = str(task.get("status") or "idle")
1766
2744
  task_type = str(task.get("type") or "")
1767
- confirm_mode = resolve_confirm_mode(root, session)[2]
2745
+ approval_mode = resolve_approval_mode(root, session)[2]
1768
2746
  if previous == stage:
1769
2747
  raise StateError(f"Transition target must differ from current stage: {stage}")
1770
2748
 
1771
- violation = validate_transition(previous, stage, task_type)
2749
+ violation = validate_transition(previous, stage, task_type, task)
1772
2750
  if violation:
1773
2751
  raise StateError(violation)
1774
- mode_violation = validate_confirm_mode_transition(previous, stage, confirm_mode)
1775
- if mode_violation:
1776
- raise StateError(mode_violation)
1777
- if is_automatic_transition(previous, stage, task_type, confirm_mode):
2752
+ if is_automatic_transition(previous, stage, task_type, approval_mode):
1778
2753
  raise StateError(
1779
- f"Transition {previous} -> {stage} is automatic in {confirm_mode} mode; "
2754
+ f"Transition {previous} -> {stage} is automatic in {approval_mode} mode; "
1780
2755
  "use auto-transition instead."
1781
2756
  )
1782
2757
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
1783
2758
  validate_analysis_readiness(root, resolved_task_id)
2759
+ if task.get("workflow_mode_legacy") is not True:
2760
+ validate_workflow_mode_proposal(
2761
+ root,
2762
+ session,
2763
+ task.get("workflow_mode_proposal"),
2764
+ resolved_task_id,
2765
+ )
2766
+ if previous == "REVIEW" and stage == "VERIFICATION":
2767
+ validate_review_readiness(root, resolved_task_id, task)
2768
+ if previous == "VERIFICATION" and stage == "MEMORY":
2769
+ validate_verification_readiness(root, resolved_task_id, task)
1784
2770
  existing = task.get("pending_transition")
1785
2771
  if isinstance(existing, dict):
1786
2772
  if existing.get("from") != previous or existing.get("to") != stage:
@@ -1816,15 +2802,19 @@ def apply_transition(
1816
2802
 
1817
2803
  previous = str(task.get("status") or "idle")
1818
2804
  task_type = str(task.get("type") or "")
1819
- confirm_mode = resolve_confirm_mode(root, session)[2]
1820
- violation = validate_transition(previous, stage, task_type)
2805
+ approval_mode = resolve_approval_mode(root, session)[2]
2806
+ legacy_edge = task.get("workflow_mode_legacy") is True
2807
+ violation = validate_transition(previous, stage, task_type, task)
1821
2808
  if violation:
1822
2809
  raise StateError(violation)
1823
- mode_violation = validate_confirm_mode_transition(previous, stage, confirm_mode)
1824
- if mode_violation:
1825
- raise StateError(mode_violation)
1826
2810
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
1827
2811
  validate_analysis_readiness(root, resolved_task_id)
2812
+ if task.get("workflow_mode_legacy") is not True:
2813
+ freeze_workflow_mode(root, session, resolved_task_id, task, agent)
2814
+ if previous == "REVIEW" and stage == "VERIFICATION":
2815
+ validate_review_readiness(root, resolved_task_id, task)
2816
+ if previous == "VERIFICATION" and stage == "MEMORY":
2817
+ validate_verification_readiness(root, resolved_task_id, task)
1828
2818
  if previous == "MEMORY" and stage == "COMPLETE":
1829
2819
  progress = task.get("memory_progress")
1830
2820
  if not isinstance(progress, dict) or progress.get("completed") is not True:
@@ -1834,6 +2824,15 @@ def apply_transition(
1834
2824
  if previous != stage:
1835
2825
  task["status"] = stage
1836
2826
  append_stage_history(task, stage, agent)
2827
+ if legacy_edge:
2828
+ task.pop("workflow_mode_legacy", None)
2829
+ if previous in {"IMPLEMENT", "REVIEW"} and stage == "VERIFICATION":
2830
+ task["workflow_mode_legacy_review_bypass_fingerprint"] = (
2831
+ implementation_fingerprint(root, resolved_task_id)
2832
+ )
2833
+ task.pop("workflow_mode_legacy_direct_edge", None)
2834
+ if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
2835
+ task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
1837
2836
  task.pop("pending_transition", None)
1838
2837
  if stage == "MEMORY" and previous != stage:
1839
2838
  task["memory_progress"] = {}
@@ -1861,10 +2860,10 @@ def auto_transition(
1861
2860
  session, _, task = resolve_current_task(root, task_id, session_file)
1862
2861
  previous = str(task.get("status") or "idle")
1863
2862
  task_type = str(task.get("type") or "")
1864
- confirm_mode = resolve_confirm_mode(root, session)[2]
1865
- if not is_automatic_transition(previous, stage, task_type, confirm_mode):
2863
+ approval_mode = resolve_approval_mode(root, session)[2]
2864
+ if not is_automatic_transition(previous, stage, task_type, approval_mode):
1866
2865
  raise StateError(
1867
- f"Automatic transition is not allowed in {confirm_mode} mode: {previous} -> {stage}."
2866
+ f"Automatic transition is not allowed in {approval_mode} mode: {previous} -> {stage}."
1868
2867
  )
1869
2868
 
1870
2869
  pending = task.get("pending_transition")
@@ -1894,7 +2893,7 @@ def confirm_transition(
1894
2893
  raise StateError("No transition is pending user confirmation.")
1895
2894
  previous = str(task.get("status") or "idle")
1896
2895
  task_type = str(task.get("type") or "")
1897
- confirm_mode = resolve_confirm_mode(root, session)[2]
2896
+ approval_mode = resolve_approval_mode(root, session)[2]
1898
2897
  source = str(pending.get("from") or "")
1899
2898
  target = str(pending.get("to") or "")
1900
2899
  if source != previous:
@@ -1903,12 +2902,9 @@ def confirm_transition(
1903
2902
  )
1904
2903
  if stage and stage != target:
1905
2904
  raise StateError(f"Pending transition targets {target}, not {stage}.")
1906
- mode_violation = validate_confirm_mode_transition(source, target, confirm_mode)
1907
- if mode_violation:
1908
- raise StateError(mode_violation)
1909
- if is_automatic_transition(source, target, task_type, confirm_mode):
2905
+ if is_automatic_transition(source, target, task_type, approval_mode):
1910
2906
  raise StateError(
1911
- f"Transition {source} -> {target} is automatic in {confirm_mode} mode; "
2907
+ f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
1912
2908
  "use auto-transition instead."
1913
2909
  )
1914
2910
 
@@ -2138,7 +3134,7 @@ def record_seen_stage(
2138
3134
  if last_seen_task == task_id and last_seen_stage:
2139
3135
  task = load_task(root, task_id)
2140
3136
  task_type = str(task.get("type") or "") if task else ""
2141
- violation = validate_transition(str(last_seen_stage), stage, task_type)
3137
+ violation = validate_transition(str(last_seen_stage), stage, task_type, task)
2142
3138
 
2143
3139
  if last_seen_task != task_id or last_seen_stage != stage:
2144
3140
  session["last_seen_task"] = task_id
@@ -2191,13 +3187,67 @@ def main() -> int:
2191
3187
  clear_current = subcommands.add_parser("clear-current", parents=[common])
2192
3188
  clear_current.add_argument("--agent", required=True)
2193
3189
 
3190
+ set_approval_mode_parser = subcommands.add_parser("set-approval-mode", parents=[common])
3191
+ set_approval_mode_parser.add_argument("--mode", required=True, choices=sorted(APPROVAL_MODES))
3192
+ set_approval_mode_parser.add_argument("--agent", required=True)
3193
+
3194
+ clear_approval_mode_parser = subcommands.add_parser("clear-approval-mode", parents=[common])
3195
+ clear_approval_mode_parser.add_argument("--agent", required=True)
3196
+
3197
+ set_workflow_mode_parser = subcommands.add_parser("set-workflow-mode", parents=[common])
3198
+ set_workflow_mode_parser.add_argument(
3199
+ "--mode", required=True, choices=sorted(CONFIGURED_WORKFLOW_MODES)
3200
+ )
3201
+ set_workflow_mode_parser.add_argument("--agent", required=True)
3202
+
3203
+ clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
3204
+ clear_workflow_mode_parser.add_argument("--agent", required=True)
3205
+
3206
+ # Compatibility aliases for pre-0.9 callers.
2194
3207
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
2195
- set_confirm_mode_parser.add_argument("--mode", required=True, choices=sorted(CONFIRM_MODES))
3208
+ set_confirm_mode_parser.add_argument(
3209
+ "--mode", required=True, choices=sorted(APPROVAL_MODES | {"lite"})
3210
+ )
2196
3211
  set_confirm_mode_parser.add_argument("--agent", required=True)
2197
3212
 
2198
3213
  clear_confirm_mode_parser = subcommands.add_parser("clear-confirm-mode", parents=[common])
2199
3214
  clear_confirm_mode_parser.add_argument("--agent", required=True)
2200
3215
 
3216
+ propose_workflow_parser = subcommands.add_parser(
3217
+ "propose-workflow-mode", parents=[common]
3218
+ )
3219
+ propose_workflow_parser.add_argument(
3220
+ "--configured", required=True, choices=sorted(CONFIGURED_WORKFLOW_MODES)
3221
+ )
3222
+ propose_workflow_parser.add_argument(
3223
+ "--selected", required=True, choices=sorted(WORKFLOW_MODES)
3224
+ )
3225
+ propose_workflow_parser.add_argument(
3226
+ "--minimum", required=True, choices=sorted(WORKFLOW_MODES)
3227
+ )
3228
+ propose_workflow_parser.add_argument(
3229
+ "--source",
3230
+ required=True,
3231
+ choices=["project", "session", "adaptive", "user", "migration"],
3232
+ )
3233
+ propose_workflow_parser.add_argument("--reason", required=True, action="append")
3234
+ propose_workflow_parser.add_argument("--agent", required=True)
3235
+ propose_workflow_parser.add_argument("--task-id")
3236
+
3237
+ workflow_floor_parser = subcommands.add_parser("workflow-floor", parents=[common])
3238
+ workflow_floor_parser.add_argument("--agent", required=True)
3239
+ workflow_floor_parser.add_argument("--task-id")
3240
+
3241
+ raise_workflow_parser = subcommands.add_parser("raise-workflow-mode", parents=[common])
3242
+ raise_workflow_parser.add_argument("--mode", required=True, choices=sorted(WORKFLOW_MODES))
3243
+ raise_workflow_parser.add_argument("--reason", required=True)
3244
+ raise_workflow_parser.add_argument("--agent", required=True)
3245
+ raise_workflow_parser.add_argument("--task-id")
3246
+
3247
+ fingerprints_parser = subcommands.add_parser("evidence-fingerprints", parents=[common])
3248
+ fingerprints_parser.add_argument("--agent", required=True)
3249
+ fingerprints_parser.add_argument("--task-id")
3250
+
2201
3251
  disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
2202
3252
  disable_harness_parser.add_argument("--agent", required=True)
2203
3253
 
@@ -2324,11 +3374,29 @@ def main() -> int:
2324
3374
  session_file,
2325
3375
  )
2326
3376
  )
3377
+ elif command == "set-approval-mode":
3378
+ emit(
3379
+ attach_status_context(
3380
+ root,
3381
+ set_session_approval_mode(root, args.mode, args.agent, session_file),
3382
+ args.agent,
3383
+ session_file,
3384
+ )
3385
+ )
2327
3386
  elif command == "set-confirm-mode":
2328
3387
  emit(
2329
3388
  attach_status_context(
2330
3389
  root,
2331
- set_session_confirm_mode(root, args.mode, args.agent, session_file),
3390
+ set_session_legacy_confirm_mode(root, args.mode, args.agent, session_file),
3391
+ args.agent,
3392
+ session_file,
3393
+ )
3394
+ )
3395
+ elif command == "clear-approval-mode":
3396
+ emit(
3397
+ attach_status_context(
3398
+ root,
3399
+ clear_session_approval_mode(root, args.agent, session_file),
2332
3400
  args.agent,
2333
3401
  session_file,
2334
3402
  )
@@ -2337,7 +3405,90 @@ def main() -> int:
2337
3405
  emit(
2338
3406
  attach_status_context(
2339
3407
  root,
2340
- clear_session_confirm_mode(root, args.agent, session_file),
3408
+ clear_session_legacy_confirm_mode(root, args.agent, session_file),
3409
+ args.agent,
3410
+ session_file,
3411
+ )
3412
+ )
3413
+ elif command == "set-workflow-mode":
3414
+ emit(
3415
+ attach_status_context(
3416
+ root,
3417
+ set_session_workflow_mode(root, args.mode, args.agent, session_file),
3418
+ args.agent,
3419
+ session_file,
3420
+ )
3421
+ )
3422
+ elif command == "clear-workflow-mode":
3423
+ emit(
3424
+ attach_status_context(
3425
+ root,
3426
+ clear_session_workflow_mode(root, args.agent, session_file),
3427
+ args.agent,
3428
+ session_file,
3429
+ )
3430
+ )
3431
+ elif command == "propose-workflow-mode":
3432
+ emit(
3433
+ attach_status_context(
3434
+ root,
3435
+ propose_workflow_mode(
3436
+ root,
3437
+ args.configured,
3438
+ args.selected,
3439
+ args.minimum,
3440
+ args.source,
3441
+ args.reason,
3442
+ args.agent,
3443
+ args.task_id,
3444
+ session_file,
3445
+ ),
3446
+ args.agent,
3447
+ session_file,
3448
+ )
3449
+ )
3450
+ elif command == "workflow-floor":
3451
+ _, resolved_task_id, _ = resolve_current_task(root, args.task_id, session_file)
3452
+ minimum_mode, reasons = calculate_workflow_floor(root, resolved_task_id)
3453
+ emit(
3454
+ attach_status_context(
3455
+ root,
3456
+ {
3457
+ "task_id": resolved_task_id,
3458
+ "minimum_mode": minimum_mode,
3459
+ "reasons": reasons,
3460
+ },
3461
+ args.agent,
3462
+ session_file,
3463
+ )
3464
+ )
3465
+ elif command == "raise-workflow-mode":
3466
+ emit(
3467
+ attach_status_context(
3468
+ root,
3469
+ raise_workflow_mode(
3470
+ root,
3471
+ args.mode,
3472
+ args.reason,
3473
+ args.agent,
3474
+ args.task_id,
3475
+ session_file,
3476
+ ),
3477
+ args.agent,
3478
+ session_file,
3479
+ )
3480
+ )
3481
+ elif command == "evidence-fingerprints":
3482
+ session, resolved_task_id, _ = resolve_current_task(
3483
+ root, args.task_id, session_file
3484
+ )
3485
+ emit(
3486
+ attach_status_context(
3487
+ root,
3488
+ {
3489
+ "task_id": resolved_task_id,
3490
+ **evidence_fingerprints(root, resolved_task_id),
3491
+ },
2341
3492
  args.agent,
2342
3493
  session_file,
2343
3494
  )