easy-coding-harness 0.6.1 → 0.7.1-beta0

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.
@@ -12,11 +12,11 @@ import sys
12
12
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
13
13
  HELP_SUFFIX = (
14
14
  "Use `ec-workflow` to start or resume a task, "
15
- "`ec-brainstorming` to brainstorm, or `ec-task-management` to view tasks"
15
+ "`ec-brainstorming` to brainstorm, or `ec-task-management` to manage tasks or session settings"
16
16
  )
17
17
  READY_LINE = (
18
18
  "> **Easy Coding** · Ready · Use `ec-workflow` to start or resume a task, "
19
- "`ec-brainstorming` to brainstorm, or `ec-task-management` to view tasks"
19
+ "`ec-brainstorming` to brainstorm, or `ec-task-management` to manage tasks or session settings"
20
20
  )
21
21
  WAITING_INIT_LINE = "> **Easy Coding** · Waiting init · Use `ec-init` to initialize"
22
22
 
@@ -47,12 +47,18 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
47
47
  "CLOSED": set(),
48
48
  }
49
49
 
50
- AUTO_TRANSITIONS = {
50
+ ALWAYS_AUTO_TRANSITIONS = {
51
51
  ("INIT", "ANALYSIS"),
52
52
  ("MEMORY", "COMPLETE"),
53
53
  }
54
54
  READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
55
55
  NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
56
+ CONFIRM_MODES = {"approve", "guard", "auto"}
57
+ DEFAULT_CONFIRM_MODE = "guard"
58
+ GUARD_CONFIRM_TRANSITIONS = {
59
+ ("ANALYSIS", "IMPLEMENT"),
60
+ ("VERIFICATION", "MEMORY"),
61
+ }
56
62
 
57
63
  LEGACY_STAGE_MAP = {
58
64
  "WAITING_CONFIRM": "ANALYSIS",
@@ -165,6 +171,51 @@ def read_memory_config(root: Path) -> dict[str, int]:
165
171
  return config
166
172
 
167
173
 
174
+ def read_project_confirm_mode(root: Path) -> str:
175
+ path = root / ".easy-coding" / "config.yaml"
176
+ try:
177
+ lines = path.read_text(encoding="utf-8").splitlines()
178
+ except OSError:
179
+ return DEFAULT_CONFIRM_MODE
180
+
181
+ in_behavior = False
182
+ behavior_indent = 0
183
+ for raw_line in lines:
184
+ without_comment = raw_line.split("#", 1)[0].rstrip()
185
+ stripped = without_comment.strip()
186
+ if not stripped:
187
+ continue
188
+ indent = len(without_comment) - len(without_comment.lstrip(" "))
189
+ if stripped == "behavior:":
190
+ in_behavior = True
191
+ behavior_indent = indent
192
+ continue
193
+ if in_behavior and indent <= behavior_indent:
194
+ in_behavior = False
195
+ if not in_behavior or ":" not in stripped:
196
+ continue
197
+ key, value = stripped.split(":", 1)
198
+ if key != "confirm_mode":
199
+ continue
200
+ mode = value.strip().strip("'\"")
201
+ if mode not in CONFIRM_MODES:
202
+ raise StateError(
203
+ "Invalid behavior.confirm_mode in .easy-coding/config.yaml: "
204
+ "expected approve, guard, or auto."
205
+ )
206
+ return mode
207
+ return DEFAULT_CONFIRM_MODE
208
+
209
+
210
+ def resolve_confirm_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
211
+ project_mode = read_project_confirm_mode(root)
212
+ session_mode = session.get("confirm_mode")
213
+ if session_mode is not None and session_mode not in CONFIRM_MODES:
214
+ raise StateError("Invalid session confirm_mode: expected approve, guard, or auto.")
215
+ effective_mode = str(session_mode or project_mode)
216
+ return project_mode, str(session_mode) if session_mode else None, effective_mode
217
+
218
+
168
219
  def short_memory_entries(root: Path) -> list[dict[str, object]]:
169
220
  short_dir = root / ".easy-coding" / "memory" / "short"
170
221
  if not short_dir.is_dir():
@@ -890,13 +941,32 @@ def get_pending_init_version(root: Path) -> str | None:
890
941
  return None
891
942
 
892
943
 
893
- def is_automatic_transition(previous: str, current: str, task_type: str = "") -> bool:
894
- if (previous, current) in AUTO_TRANSITIONS:
944
+ def transition_requires_confirmation(
945
+ previous: str,
946
+ current: str,
947
+ task_type: str,
948
+ confirm_mode: str,
949
+ ) -> bool:
950
+ if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
951
+ return False
952
+ if current == "CLOSED":
895
953
  return True
896
- return (
897
- (previous, current) == READ_ONLY_COMPLETION_TRANSITION
898
- and task_type.strip().lower() in NO_CODE_TASK_TYPES
899
- )
954
+ if confirm_mode == "auto":
955
+ return False
956
+ if confirm_mode == "guard":
957
+ return (previous, current) in GUARD_CONFIRM_TRANSITIONS
958
+ if confirm_mode == "approve":
959
+ return True
960
+ raise StateError(f"Unknown confirm mode: {confirm_mode}")
961
+
962
+
963
+ def is_automatic_transition(
964
+ previous: str,
965
+ current: str,
966
+ task_type: str,
967
+ confirm_mode: str,
968
+ ) -> bool:
969
+ return not transition_requires_confirmation(previous, current, task_type, confirm_mode)
900
970
 
901
971
 
902
972
  def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
@@ -943,6 +1013,10 @@ def snapshot_state(
943
1013
  missing = False
944
1014
  status = "idle"
945
1015
 
1016
+ project_confirm_mode, session_confirm_mode, effective_confirm_mode = resolve_confirm_mode(
1017
+ root, resolved_session
1018
+ )
1019
+
946
1020
  return {
947
1021
  "session_file": display_path(root, session_path),
948
1022
  "current_task": str(task_id) if task_id else None,
@@ -955,6 +1029,10 @@ def snapshot_state(
955
1029
  "last_agent": task.get("last_agent") if task else None,
956
1030
  "project_init_required": is_project_init_required(root),
957
1031
  "pending_init_version": get_pending_init_version(root),
1032
+ "project_confirm_mode": project_confirm_mode,
1033
+ "session_confirm_mode": session_confirm_mode,
1034
+ "effective_confirm_mode": effective_confirm_mode,
1035
+ "harness_disabled": resolved_session.get("harness_disabled") is True,
958
1036
  }
959
1037
 
960
1038
 
@@ -1000,7 +1078,11 @@ def build_machine_breadcrumbs(
1000
1078
  task = state["task"]
1001
1079
  stage = str(state["status"]) if task else "idle"
1002
1080
  resolved_session_file = str(state["session_file"])
1003
- lines = [f"[workflow-state:{stage}]", f"[easy-coding:session-file:{resolved_session_file}]"]
1081
+ lines = [
1082
+ f"[workflow-state:{stage}]",
1083
+ f"[easy-coding:session-file:{resolved_session_file}]",
1084
+ f"[easy-coding:confirm-mode:{state['effective_confirm_mode']}]",
1085
+ ]
1004
1086
 
1005
1087
  if task_id:
1006
1088
  lines.append(f"[current-task:{task_id}]")
@@ -1016,7 +1098,12 @@ def build_machine_breadcrumbs(
1016
1098
  if target:
1017
1099
  lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
1018
1100
  task_type = str(task.get("type") or "") if task else ""
1019
- if is_automatic_transition(source, target, task_type):
1101
+ if is_automatic_transition(
1102
+ source,
1103
+ target,
1104
+ task_type,
1105
+ str(state["effective_confirm_mode"]),
1106
+ ):
1020
1107
  lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
1021
1108
  else:
1022
1109
  lines.append("[easy-coding:transition-confirmation-required]")
@@ -1063,6 +1150,14 @@ def build_status_context(
1063
1150
  agent: str | None = None,
1064
1151
  session_file: str | Path | None = None,
1065
1152
  ) -> str:
1153
+ if session.get("harness_disabled") is True:
1154
+ session_path = resolve_session_path(root, session_file)
1155
+ return "\n".join(
1156
+ [
1157
+ "[easy-coding:no-harness]",
1158
+ f"[easy-coding:session-file:{display_path(root, session_path)}]",
1159
+ ]
1160
+ )
1066
1161
  return "\n".join(
1067
1162
  [
1068
1163
  build_status_line(root, session, agent, session_file),
@@ -1082,7 +1177,7 @@ def attach_status_context(
1082
1177
  if session is None:
1083
1178
  session = default_session()
1084
1179
  context = build_status_context(root, session, agent, resolved_session_file)
1085
- first_line = context.splitlines()[0] if context else ""
1180
+ first_line = context.splitlines()[0] if context.startswith("> ") else ""
1086
1181
  enriched = dict(data)
1087
1182
  enriched["status_line"] = first_line
1088
1183
  enriched["status_context"] = context
@@ -1157,6 +1252,55 @@ def clear_current_task(root: Path, agent: str, session_file: str | Path | None =
1157
1252
  return snapshot_state(root, session_file, session)
1158
1253
 
1159
1254
 
1255
+ def set_session_confirm_mode(
1256
+ root: Path,
1257
+ mode: str,
1258
+ agent: str,
1259
+ session_file: str | Path | None = None,
1260
+ ) -> dict:
1261
+ if mode not in CONFIRM_MODES:
1262
+ raise StateError("Invalid confirm mode: expected approve, guard, or auto.")
1263
+ session = ensure_session(root, session_file)
1264
+ session["confirm_mode"] = mode
1265
+ session["last_agent"] = agent
1266
+ write_session(root, session, session_file)
1267
+ snapshot = snapshot_state(root, session_file, session)
1268
+ snapshot["action"] = "set-confirm-mode"
1269
+ return snapshot
1270
+
1271
+
1272
+ def clear_session_confirm_mode(
1273
+ root: Path,
1274
+ agent: str,
1275
+ session_file: str | Path | None = None,
1276
+ ) -> dict:
1277
+ session = ensure_session(root, session_file)
1278
+ session.pop("confirm_mode", None)
1279
+ session["last_agent"] = agent
1280
+ write_session(root, session, session_file)
1281
+ snapshot = snapshot_state(root, session_file, session)
1282
+ snapshot["action"] = "clear-confirm-mode"
1283
+ return snapshot
1284
+
1285
+
1286
+ def set_harness_disabled(
1287
+ root: Path,
1288
+ disabled: bool,
1289
+ agent: str,
1290
+ session_file: str | Path | None = None,
1291
+ ) -> dict:
1292
+ session = ensure_session(root, session_file)
1293
+ if disabled:
1294
+ session["harness_disabled"] = True
1295
+ else:
1296
+ session.pop("harness_disabled", None)
1297
+ session["last_agent"] = agent
1298
+ write_session(root, session, session_file)
1299
+ snapshot = snapshot_state(root, session_file, session)
1300
+ snapshot["action"] = "disable-harness" if disabled else "enable-harness"
1301
+ return snapshot
1302
+
1303
+
1160
1304
  def handoff_task(
1161
1305
  root: Path,
1162
1306
  agent: str,
@@ -1297,15 +1441,17 @@ def request_transition(
1297
1441
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1298
1442
  previous = str(task.get("status") or "idle")
1299
1443
  task_type = str(task.get("type") or "")
1444
+ confirm_mode = resolve_confirm_mode(root, session)[2]
1300
1445
  if previous == stage:
1301
1446
  raise StateError(f"Transition target must differ from current stage: {stage}")
1302
1447
 
1303
1448
  violation = validate_transition(previous, stage, task_type)
1304
1449
  if violation:
1305
1450
  raise StateError(violation)
1306
- if is_automatic_transition(previous, stage, task_type):
1451
+ if is_automatic_transition(previous, stage, task_type, confirm_mode):
1307
1452
  raise StateError(
1308
- f"Transition {previous} -> {stage} is automatic; use auto-transition instead."
1453
+ f"Transition {previous} -> {stage} is automatic in {confirm_mode} mode; "
1454
+ "use auto-transition instead."
1309
1455
  )
1310
1456
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
1311
1457
  validate_analysis_readiness(root, resolved_task_id)
@@ -1382,11 +1528,14 @@ def auto_transition(
1382
1528
  task_id: str | None = None,
1383
1529
  session_file: str | Path | None = None,
1384
1530
  ) -> dict:
1385
- _, _, task = resolve_current_task(root, task_id, session_file)
1531
+ session, _, task = resolve_current_task(root, task_id, session_file)
1386
1532
  previous = str(task.get("status") or "idle")
1387
1533
  task_type = str(task.get("type") or "")
1388
- if not is_automatic_transition(previous, stage, task_type):
1389
- raise StateError(f"Automatic transition is not allowed: {previous} -> {stage}.")
1534
+ confirm_mode = resolve_confirm_mode(root, session)[2]
1535
+ if not is_automatic_transition(previous, stage, task_type, confirm_mode):
1536
+ raise StateError(
1537
+ f"Automatic transition is not allowed in {confirm_mode} mode: {previous} -> {stage}."
1538
+ )
1390
1539
 
1391
1540
  pending = task.get("pending_transition")
1392
1541
  if isinstance(pending, dict) and (
@@ -1409,12 +1558,13 @@ def confirm_transition(
1409
1558
  task_id: str | None = None,
1410
1559
  session_file: str | Path | None = None,
1411
1560
  ) -> dict:
1412
- _, _, task = resolve_current_task(root, task_id, session_file)
1561
+ session, _, task = resolve_current_task(root, task_id, session_file)
1413
1562
  pending = task.get("pending_transition")
1414
1563
  if not isinstance(pending, dict):
1415
1564
  raise StateError("No transition is pending user confirmation.")
1416
1565
  previous = str(task.get("status") or "idle")
1417
1566
  task_type = str(task.get("type") or "")
1567
+ confirm_mode = resolve_confirm_mode(root, session)[2]
1418
1568
  source = str(pending.get("from") or "")
1419
1569
  target = str(pending.get("to") or "")
1420
1570
  if source != previous:
@@ -1423,9 +1573,10 @@ def confirm_transition(
1423
1573
  )
1424
1574
  if stage and stage != target:
1425
1575
  raise StateError(f"Pending transition targets {target}, not {stage}.")
1426
- if is_automatic_transition(source, target, task_type):
1576
+ if is_automatic_transition(source, target, task_type, confirm_mode):
1427
1577
  raise StateError(
1428
- f"Transition {source} -> {target} is automatic; use auto-transition instead."
1578
+ f"Transition {source} -> {target} is automatic in {confirm_mode} mode; "
1579
+ "use auto-transition instead."
1429
1580
  )
1430
1581
 
1431
1582
  snapshot = apply_transition(root, target, agent, task_id, session_file)
@@ -1703,6 +1854,19 @@ def main() -> int:
1703
1854
  clear_current = subcommands.add_parser("clear-current", parents=[common])
1704
1855
  clear_current.add_argument("--agent", required=True)
1705
1856
 
1857
+ set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
1858
+ set_confirm_mode_parser.add_argument("--mode", required=True, choices=sorted(CONFIRM_MODES))
1859
+ set_confirm_mode_parser.add_argument("--agent", required=True)
1860
+
1861
+ clear_confirm_mode_parser = subcommands.add_parser("clear-confirm-mode", parents=[common])
1862
+ clear_confirm_mode_parser.add_argument("--agent", required=True)
1863
+
1864
+ disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
1865
+ disable_harness_parser.add_argument("--agent", required=True)
1866
+
1867
+ enable_harness_parser = subcommands.add_parser("enable-harness", parents=[common])
1868
+ enable_harness_parser.add_argument("--agent", required=True)
1869
+
1706
1870
  handoff = subcommands.add_parser("handoff-task", parents=[common])
1707
1871
  handoff.add_argument("--agent", required=True)
1708
1872
  handoff.add_argument("--summary", required=True)
@@ -1807,6 +1971,42 @@ def main() -> int:
1807
1971
  session_file,
1808
1972
  )
1809
1973
  )
1974
+ elif command == "set-confirm-mode":
1975
+ emit(
1976
+ attach_status_context(
1977
+ root,
1978
+ set_session_confirm_mode(root, args.mode, args.agent, session_file),
1979
+ args.agent,
1980
+ session_file,
1981
+ )
1982
+ )
1983
+ elif command == "clear-confirm-mode":
1984
+ emit(
1985
+ attach_status_context(
1986
+ root,
1987
+ clear_session_confirm_mode(root, args.agent, session_file),
1988
+ args.agent,
1989
+ session_file,
1990
+ )
1991
+ )
1992
+ elif command == "disable-harness":
1993
+ emit(
1994
+ attach_status_context(
1995
+ root,
1996
+ set_harness_disabled(root, True, args.agent, session_file),
1997
+ args.agent,
1998
+ session_file,
1999
+ )
2000
+ )
2001
+ elif command == "enable-harness":
2002
+ emit(
2003
+ attach_status_context(
2004
+ root,
2005
+ set_harness_disabled(root, False, args.agent, session_file),
2006
+ args.agent,
2007
+ session_file,
2008
+ )
2009
+ )
1810
2010
  elif command == "handoff-task":
1811
2011
  emit(
1812
2012
  attach_status_context(
@@ -4,7 +4,7 @@ import os
4
4
  from pathlib import Path
5
5
  import sys
6
6
 
7
- from easy_coding_state import snapshot_state
7
+ from easy_coding_state import load_session, snapshot_state
8
8
 
9
9
 
10
10
  def configure_stdio() -> None:
@@ -54,7 +54,11 @@ def main() -> int:
54
54
  if root is None:
55
55
  return 0
56
56
 
57
- state = snapshot_state(root)
57
+ session = load_session(root)
58
+ if session and session.get("harness_disabled") is True:
59
+ return 0
60
+
61
+ state = snapshot_state(root, session=session)
58
62
  task_id = state.get("current_task")
59
63
  context = [
60
64
  "[easy-coding:subagent-guard]",