arctx-cli 0.3.1b2__tar.gz → 0.3.1b3__tar.gz

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 (94) hide show
  1. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/PKG-INFO +2 -2
  2. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/pyproject.toml +2 -2
  3. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/add.py +6 -0
  4. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/asset.py +4 -0
  5. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/attach.py +6 -0
  6. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/guide.py +3 -2
  7. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/lane.py +147 -75
  8. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/__init__.py +4 -0
  9. arctx_cli-0.3.1b3/src/arctx_cli/lane_gate.py +25 -0
  10. arctx_cli-0.3.1b3/tests/cli/test_lane_lifecycle.py +155 -0
  11. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/.gitignore +0 -0
  12. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/README.md +0 -0
  13. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/__init__.py +0 -0
  14. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/alias.py +0 -0
  15. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/append_batch.py +0 -0
  16. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/__init__.py +0 -0
  17. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/_targets.py +0 -0
  18. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/alias_cmd.py +0 -0
  19. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/current.py +0 -0
  20. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/cut.py +0 -0
  21. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/dump.py +0 -0
  22. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/export.py +0 -0
  23. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/ext.py +0 -0
  24. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/graph.py +0 -0
  25. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/init.py +0 -0
  26. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/list.py +0 -0
  27. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/log.py +0 -0
  28. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/migrate.py +0 -0
  29. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/node.py +0 -0
  30. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/outcomes.py +0 -0
  31. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/payload.py +0 -0
  32. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/pr.py +0 -0
  33. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/reachable.py +0 -0
  34. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/reparent.py +0 -0
  35. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/serve.py +0 -0
  36. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/show.py +0 -0
  37. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/step.py +0 -0
  38. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/sync_cmd.py +0 -0
  39. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/trace.py +0 -0
  40. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/uncut.py +0 -0
  41. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/use.py +0 -0
  42. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/commands/web.py +0 -0
  43. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/context.py +0 -0
  44. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/__init__.py +0 -0
  45. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/command/__init__.py +0 -0
  46. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/branch.py +0 -0
  47. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/cherry_pick.py +0 -0
  48. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/commit.py +0 -0
  49. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/hook.py +0 -0
  50. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/merge.py +0 -0
  51. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/repo.py +0 -0
  52. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/reset.py +0 -0
  53. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/revert.py +0 -0
  54. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/verify.py +0 -0
  55. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext/git/worktree.py +0 -0
  56. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/ext_registry.py +0 -0
  57. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/main.py +0 -0
  58. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/paths.py +0 -0
  59. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/payload_builder.py +0 -0
  60. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/src/arctx_cli/workspace.py +0 -0
  61. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/__init__.py +0 -0
  62. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/__init__.py +0 -0
  63. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_alias_cli.py +0 -0
  64. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_alias_resolve.py +0 -0
  65. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_asset_extension_cli.py +0 -0
  66. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_basic.py +0 -0
  67. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_branch.py +0 -0
  68. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_cherry_pick.py +0 -0
  69. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_command_extension.py +0 -0
  70. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_commit.py +0 -0
  71. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_commit_guard.py +0 -0
  72. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_dag_core_cli.py +0 -0
  73. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_ext_cli.py +0 -0
  74. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_git_namespace_cli.py +0 -0
  75. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_git_repo.py +0 -0
  76. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_hook_install.py +0 -0
  77. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_hook_post_merge.py +0 -0
  78. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_hook_post_rewrite.py +0 -0
  79. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_init_extension.py +0 -0
  80. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_init_hooks.py +0 -0
  81. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_init_stag_id.py +0 -0
  82. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_lane.py +0 -0
  83. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_merge.py +0 -0
  84. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_migrate.py +0 -0
  85. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_paths.py +0 -0
  86. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_pr.py +0 -0
  87. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_reset.py +0 -0
  88. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_revert.py +0 -0
  89. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_serve_api.py +0 -0
  90. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_show_history.py +0 -0
  91. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_sync.py +0 -0
  92. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/cli/test_verify.py +0 -0
  93. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/fixtures/__init__.py +0 -0
  94. {arctx_cli-0.3.1b2 → arctx_cli-0.3.1b3}/tests/fixtures/dummy_ext.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arctx-cli
3
- Version: 0.3.1b2
3
+ Version: 0.3.1b3
4
4
  Summary: CLI for ARCTX — append-only DAG editing and refactoring
5
5
  Project-URL: Homepage, https://github.com/takumiecd/arctx
6
6
  Project-URL: Repository, https://github.com/takumiecd/arctx
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Utilities
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: arctx>=0.3.1b2
20
+ Requires-Dist: arctx>=0.3.1b3
21
21
  Provides-Extra: dev
22
22
  Requires-Dist: black>=23.0; extra == 'dev'
23
23
  Requires-Dist: mypy>=1.0; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arctx-cli"
7
- version = "0.3.1b2"
7
+ version = "0.3.1b3"
8
8
  description = "CLI for ARCTX — append-only DAG editing and refactoring"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Topic :: Utilities",
26
26
  ]
27
27
  dependencies = [
28
- "arctx>=0.3.1b2",
28
+ "arctx>=0.3.1b3",
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
@@ -14,6 +14,7 @@ from arctx_cli.context import (
14
14
  resolve_user_id_from_args,
15
15
  resolve_lane_id_from_args,
16
16
  )
17
+ from arctx_cli.lane_gate import ensure_lane_open
17
18
  from arctx_cli.payload_builder import build_payload, parse_field_args, parse_json_object
18
19
 
19
20
 
@@ -36,6 +37,8 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
36
37
  parser.add_argument("--store-dir", default=None)
37
38
  parser.add_argument("--user", default=None)
38
39
  parser.add_argument("--lane", default=None)
40
+ parser.add_argument("--force", action="store_true",
41
+ help="Write even if the target lane is closed")
39
42
 
40
43
  return parser
41
44
 
@@ -52,11 +55,13 @@ def run_add_step_command(
52
55
  store_dir: str,
53
56
  user_id: str | None = None,
54
57
  lane_id: str | None = None,
58
+ force: bool = False,
55
59
  ) -> dict:
56
60
  store = resolve_store(store_dir)
57
61
  if not store.run_path(run_id).exists():
58
62
  raise KeyError(f"unknown run_id: {run_id}")
59
63
  handle = store.load_run(run_id)
64
+ ensure_lane_open(handle, lane_id, force=force)
60
65
  data = dict(json_data or {})
61
66
  data.update(field_data or {})
62
67
  if title is not None:
@@ -105,6 +110,7 @@ def cli_add(args) -> int:
105
110
  store_dir=args.store_dir,
106
111
  user_id=resolve_user_id_from_args(args),
107
112
  lane_id=resolve_lane_id_from_args(args),
113
+ force=args.force,
108
114
  )
109
115
  print(json.dumps(result["step"], ensure_ascii=False, indent=2))
110
116
  return 0
@@ -13,6 +13,7 @@ from arctx_cli.context import (
13
13
  resolve_user_id_from_args,
14
14
  resolve_lane_id_from_args,
15
15
  )
16
+ from arctx_cli.lane_gate import ensure_lane_open
16
17
 
17
18
 
18
19
  def add_parser(subparsers) -> argparse.ArgumentParser:
@@ -27,6 +28,8 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
27
28
  sp_attach.add_argument("--store-dir", default=None)
28
29
  sp_attach.add_argument("--user", default=None)
29
30
  sp_attach.add_argument("--lane", default=None)
31
+ sp_attach.add_argument("--force", action="store_true",
32
+ help="Write even if the target lane is closed")
30
33
 
31
34
  sp_list = asset_sub.add_parser("list", help="List assets for a Node or Step")
32
35
  sp_list.add_argument("--target", required=True, dest="target_id", help="Node or Step ID")
@@ -72,6 +75,7 @@ def _cli_asset_attach(args) -> int:
72
75
  return 1
73
76
 
74
77
  try:
78
+ ensure_lane_open(handle, lane_id, force=args.force)
75
79
  before = graph_counts(handle)
76
80
  payload = handle.attach_asset(
77
81
  args.target_id,
@@ -15,6 +15,7 @@ from arctx_cli.context import (
15
15
  resolve_lane_id_from_args,
16
16
  )
17
17
  from arctx_cli.payload_builder import build_payload, parse_field_args, parse_json_object
18
+ from arctx_cli.lane_gate import ensure_lane_open
18
19
 
19
20
 
20
21
  def add_parser(subparsers) -> argparse.ArgumentParser:
@@ -28,6 +29,8 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
28
29
  parser.add_argument("--store-dir", default=None)
29
30
  parser.add_argument("--user", default=None)
30
31
  parser.add_argument("--lane", default=None)
32
+ parser.add_argument("--force", action="store_true",
33
+ help="Write even if the target lane is closed")
31
34
  return parser
32
35
 
33
36
 
@@ -42,11 +45,13 @@ def run_attach_command(
42
45
  store_dir: str,
43
46
  user_id: str | None = None,
44
47
  lane_id: str | None = None,
48
+ force: bool = False,
45
49
  ) -> dict:
46
50
  store = resolve_store(store_dir)
47
51
  if not store.run_path(run_id).exists():
48
52
  raise KeyError(f"unknown run_id: {run_id}")
49
53
  handle = store.load_run(run_id)
54
+ ensure_lane_open(handle, lane_id, force=force)
50
55
  target_kind = resolve_target_kind(handle, target_id)
51
56
  if target_kind == "payload":
52
57
  raise ValueError("cannot attach a payload to another payload")
@@ -107,6 +112,7 @@ def cli_attach(args) -> int:
107
112
  store_dir=args.store_dir,
108
113
  user_id=resolve_user_id_from_args(args),
109
114
  lane_id=resolve_lane_id_from_args(args),
115
+ force=args.force,
110
116
  )
111
117
  print(json.dumps(result["payload"], ensure_ascii=False, indent=2))
112
118
  return 0
@@ -27,7 +27,7 @@ def cli_guide(args) -> int:
27
27
  1. **Understand Context**: Use `arctx dump` to view the graph. Check your **Current Lane** and **Active Frontiers** at the bottom of this guide to know where to resume work.
28
28
  2. **Isolate Approaches**: Use lanes to explore different solutions independently.
29
29
  3. **Record Everything**: Record your steps (e.g., via `arctx add` or `arctx git commit`). The graph should capture *how* you solved the problem, not just the final result.
30
- 4. **Merge & Summarize**: Once you have explored multiple paths or completed a complex task, use `arctx lane join` to merge your active frontiers and attach a summary of your findings.
30
+ 4. **Close & Summarize**: When a lane's work is done, close it with `arctx lane close NAME --summary "..."`. This is the *close* action — use it even for a single linear chain, not only when several paths must be merged. The summary is **Markdown** (rendered as such in the web GUI) and becomes the closing node's payload, so **put your full synthesis there** (one leaf → it stamps that leaf; several leaves → it merges them into one node). Do **not** write a separate synthesis step and then close on top of it — the close *is* your synthesis-and-close. A closed lane refuses further writes; reopen it with `arctx lane open NAME` to resume.
31
31
 
32
32
  ## Essential Core Commands
33
33
 
@@ -35,7 +35,8 @@ def cli_guide(args) -> int:
35
35
  * `arctx dump --format outline --lane` : View the history of the current lane.
36
36
  * `arctx lane` : Show the currently active lane.
37
37
  * `arctx show <ID>` : Show details of a specific Node, Step, or Payload.
38
- * `arctx lane join <LANE> --summary "..."` : Merge frontier nodes in a lane and attach a summary.
38
+ * `arctx lane close <LANE> --summary "<your synthesis>"` : Close a lane. Attaches the summary (**Markdown**) to its terminal (its one leaf, or a fresh node merging several leaves) and marks it closed; writes are then refused until reopened. Put your findings in `--summary` here — not in a separate preceding step. (`arctx lane join` is a deprecated alias.)
39
+ * `arctx lane open <LANE>` : Reopen a closed lane to resume work.
39
40
  """
40
41
  # Try to load execution context dynamically
41
42
  try:
@@ -46,9 +46,12 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
46
46
  "lane",
47
47
  help="Switch/show the active lane (solo or collaborative unit of work)",
48
48
  description=(
49
- "`arctx lane create NAME` creates a lane. `arctx lane switch NAME` "
50
- "switches to an existing lane and pins it for this checkout. "
51
- "`arctx lane NAME` is switch shorthand and errors when NAME is absent."
49
+ "`arctx lane create NAME` creates a lane (status open). `arctx lane switch "
50
+ "NAME` switches to an existing lane and pins it for this checkout. "
51
+ "`arctx lane NAME` is switch shorthand and errors when NAME is absent. "
52
+ "`arctx lane close NAME --summary TEXT` closes a lane (attaching the summary "
53
+ "to its terminal); `arctx lane open NAME` reopens it. Writes to a closed lane "
54
+ "are refused until it is reopened."
52
55
  ),
53
56
  )
54
57
  parser.add_argument(
@@ -57,7 +60,7 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
57
60
  metavar="COMMAND|NAME",
58
61
  help=(
59
62
  "No args = current lane. Commands: create NAME, switch NAME, "
60
- "adopt NAME, validate, list, show LANE, summaries LANE."
63
+ "close NAME, open NAME, adopt NAME, validate, list, show LANE, summaries LANE."
61
64
  ),
62
65
  )
63
66
  parser.add_argument("--list", action="store_true", dest="list_lanes",
@@ -86,18 +89,19 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
86
89
  parser.add_argument(
87
90
  "--reason",
88
91
  default=None,
89
- help="Reason recorded on a lane adoption event",
92
+ help="Reason recorded on a lane adoption / close / open event",
90
93
  )
91
94
  parser.add_argument(
92
95
  "--summary",
93
96
  default=None,
94
- help="Summary text to attach when joining a lane",
97
+ help="Closing summary (Markdown), attached to the lane's terminal node (lane close)",
95
98
  )
96
99
  parser.add_argument(
97
100
  "--node",
98
101
  action="append",
99
102
  default=None,
100
- help="Specific node id to join (repeatable; if omitted, joins all leaf nodes of the lane)",
103
+ help="Specific leaf node id to summarise on close "
104
+ "(repeatable; if omitted, uses all active leaf nodes of the lane)",
101
105
  )
102
106
  parser.add_argument("--user", default=None, help="Actor (created_by) when creating")
103
107
  parser.add_argument("--run", default=None)
@@ -392,24 +396,76 @@ def validate_lane_run(*, run_id: str, store_dir: str | None) -> dict:
392
396
  }
393
397
 
394
398
 
395
- def run_lane_join_command(
399
+ def _lane_active_leaves(handle, lane) -> list[str]:
400
+ """Active leaf nodes of a lane (nodes in the lane with no step extending them)."""
401
+ from arctx.core.cuts import is_active_node
402
+ from arctx.core.lanes import lane_membership
403
+
404
+ membership = lane_membership(handle.run_graph)
405
+ group = next((g for g in membership.groups if g.lane_id == lane.lane_id), None)
406
+ if not group:
407
+ return []
408
+ leaves = [
409
+ nid
410
+ for nid in group.node_ids
411
+ if is_active_node(handle.run_graph, nid)
412
+ and not handle.run_graph.steps_from_node(nid)
413
+ ]
414
+ return list(dict.fromkeys(leaves))
415
+
416
+
417
+ def _attach_lane_summary(handle, *, lane, summary: str, node_ids, user_id: str):
418
+ """Stamp a lane's terminal with ``summary``. Returns (summary_node, leaves).
419
+
420
+ - 1 leaf → attach the summary to that existing leaf (no extra node).
421
+ - ≥2 leaves → create one convergence (join) node and summarise that.
422
+ - 0 leaves → nothing to summarise (returns (None, [])).
423
+ """
424
+ from arctx.core.schema.payloads import JoinPayload, SummaryPayload
425
+
426
+ leaves = list(dict.fromkeys(node_ids)) if node_ids else _lane_active_leaves(handle, lane)
427
+ if not leaves:
428
+ return None, []
429
+
430
+ if len(leaves) == 1:
431
+ target = leaves[0]
432
+ else:
433
+ join_payload = JoinPayload(payload_id=handle._next_id("pl"), target_id="")
434
+ step = handle.add_step(
435
+ input_node_ids=leaves,
436
+ payload=join_payload,
437
+ user_id=user_id,
438
+ lane_id=lane.lane_id,
439
+ )
440
+ target = step.output_node_id
441
+
442
+ handle.attach(
443
+ target,
444
+ SummaryPayload(payload_id=handle._next_id("pl"), target_id=target, text=summary),
445
+ user_id=user_id,
446
+ lane_id=lane.lane_id,
447
+ )
448
+ return target, leaves
449
+
450
+
451
+ def run_lane_close_command(
396
452
  *,
397
453
  name_or_id: str,
398
- summary: str,
454
+ summary: str | None,
399
455
  node_ids: list[str] | None,
456
+ reason: str | None,
400
457
  run_id: str,
401
458
  user_id: str,
402
459
  store_dir: str | None,
403
460
  ) -> dict:
404
- """Join multiple leaf nodes in a lane into a single output node with a summary.
405
-
406
- If node_ids is not provided, this automatically finds all active leaf nodes
407
- currently belonging to the lane.
408
- """
409
- from arctx.core.cuts import is_active_node
410
- from arctx.core.lanes import lane_membership
411
- from arctx.core.schema.payloads import JoinPayload, SummaryPayload
461
+ """Close a lane: attach the closing ``summary`` to its terminal, then mark it closed.
412
462
 
463
+ ``summary`` is **Markdown** (rendered as such in the web GUI), so put the full
464
+ closing write-up here. It lands on the lane's single leaf when there is one (no
465
+ redundant node), or on a fresh convergence node when several leaves must be
466
+ merged. Once closed, writes to the lane are refused until it is reopened with
467
+ ``lane open``.
468
+ """
413
469
  store = resolve_store(store_dir)
414
470
  if not store.run_path(run_id).exists():
415
471
  raise KeyError(f"unknown run_id: {run_id}")
@@ -418,64 +474,60 @@ def run_lane_join_command(
418
474
  lane = _find_lane(handle, name_or_id)
419
475
  if lane is None:
420
476
  raise KeyError(f"unknown lane: {name_or_id!r}")
477
+ if lane.status == "closed":
478
+ raise ValueError(
479
+ f"lane already closed: {name_or_id!r}; "
480
+ f"reopen it with `arctx lane open {name_or_id}`"
481
+ )
421
482
 
422
- # Determine nodes to join
423
- if not node_ids:
424
- # Collect all leaf nodes currently assigned to this lane
425
- membership = lane_membership(handle.run_graph)
426
- group = next((g for g in membership.groups if g.lane_id == lane.lane_id), None)
427
- if not group:
428
- raise ValueError(f"lane has no nodes: {name_or_id!r}")
429
-
430
- candidates = []
431
- for nid in group.node_ids:
432
- if not is_active_node(handle.run_graph, nid):
433
- continue
434
- # Check if this node has no steps extending from it
435
- if not handle.run_graph.steps_from_node(nid):
436
- candidates.append(nid)
437
-
438
- node_ids = candidates
439
-
440
- if not node_ids:
441
- raise ValueError(f"no active leaf nodes found in lane {name_or_id!r}")
442
-
443
- # Deduplicate while preserving order
444
- node_ids = list(dict.fromkeys(node_ids))
445
-
446
- if len(node_ids) < 1:
447
- raise ValueError("join requires at least one node")
448
-
449
- # 1. Create Step with JoinPayload
450
- join_payload = JoinPayload(
451
- payload_id=handle._next_id("pl"),
452
- target_id="",
453
- )
454
- step = handle.add_step(
455
- input_node_ids=node_ids,
456
- payload=join_payload,
457
- user_id=user_id,
458
- lane_id=lane.lane_id,
459
- )
460
-
461
- # 2. Attach SummaryPayload to the new output node
462
- summary_payload = SummaryPayload(
463
- payload_id=handle._next_id("pl"),
464
- target_id=step.output_node_id,
465
- text=summary,
466
- )
467
- handle.attach(
468
- step.output_node_id,
469
- summary_payload,
470
- user_id=user_id,
471
- lane_id=lane.lane_id,
483
+ summary_node, leaves = (None, [])
484
+ if summary:
485
+ summary_node, leaves = _attach_lane_summary(
486
+ handle, lane=lane, summary=summary, node_ids=node_ids, user_id=user_id
487
+ )
488
+ event = handle.set_lane_status(
489
+ lane.lane_id, status="closed", user_id=user_id, reason=reason
472
490
  )
491
+ store.save_run(handle)
492
+ return {
493
+ "lane_id": lane.lane_id,
494
+ "name": lane.name,
495
+ "status": "closed",
496
+ "summary_node": summary_node,
497
+ "joined_nodes": leaves,
498
+ "event_id": event.event_id,
499
+ }
500
+
501
+
502
+ def run_lane_open_command(
503
+ *,
504
+ name_or_id: str,
505
+ reason: str | None,
506
+ run_id: str,
507
+ user_id: str,
508
+ store_dir: str | None,
509
+ ) -> dict:
510
+ """Reopen a closed lane so work can resume. Symmetric to ``lane close``."""
511
+ store = resolve_store(store_dir)
512
+ if not store.run_path(run_id).exists():
513
+ raise KeyError(f"unknown run_id: {run_id}")
514
+ handle = store.load_run(run_id)
515
+
516
+ lane = _find_lane(handle, name_or_id)
517
+ if lane is None:
518
+ raise KeyError(f"unknown lane: {name_or_id!r}")
519
+ if lane.status != "closed":
520
+ raise ValueError(f"lane is already open: {name_or_id!r}")
473
521
 
522
+ event = handle.set_lane_status(
523
+ lane.lane_id, status="open", user_id=user_id, reason=reason
524
+ )
474
525
  store.save_run(handle)
475
526
  return {
476
- "step_id": step.step_id,
477
- "output_node_id": step.output_node_id,
478
- "joined_nodes": node_ids,
527
+ "lane_id": lane.lane_id,
528
+ "name": lane.name,
529
+ "status": "open",
530
+ "event_id": event.event_id,
479
531
  }
480
532
 
481
533
 
@@ -533,15 +585,35 @@ def cli_lane(args) -> int:
533
585
  print(json.dumps(result, ensure_ascii=False, indent=2))
534
586
  return 0
535
587
 
536
- if command == "join":
588
+ if command in ("close", "join"):
589
+ if command == "join":
590
+ print(
591
+ "warning: `arctx lane join` is deprecated; use `arctx lane close` "
592
+ "(it closes the lane and attaches the summary to its terminal).",
593
+ file=sys.stderr,
594
+ )
537
595
  if len(argv) != 2:
538
- raise ValueError("usage: arctx lane join NAME_OR_ID --summary TEXT [--node ID...]")
539
- if not args.summary:
540
- raise ValueError("--summary is required to join a lane")
541
- result = run_lane_join_command(
596
+ raise ValueError(
597
+ "usage: arctx lane close NAME_OR_ID --summary TEXT [--node ID...] [--reason TEXT]"
598
+ )
599
+ result = run_lane_close_command(
542
600
  name_or_id=argv[1],
543
601
  summary=args.summary,
544
602
  node_ids=args.node,
603
+ reason=args.reason,
604
+ run_id=resolve_run_id_from_args(args),
605
+ user_id=resolve_user_id_from_args(args),
606
+ store_dir=args.store_dir,
607
+ )
608
+ print(json.dumps(result, ensure_ascii=False, indent=2))
609
+ return 0
610
+
611
+ if command == "open":
612
+ if len(argv) != 2:
613
+ raise ValueError("usage: arctx lane open NAME_OR_ID [--reason TEXT]")
614
+ result = run_lane_open_command(
615
+ name_or_id=argv[1],
616
+ reason=args.reason,
545
617
  run_id=resolve_run_id_from_args(args),
546
618
  user_id=resolve_user_id_from_args(args),
547
619
  store_dir=args.store_dir,
@@ -14,6 +14,7 @@ from arctx_cli.context import (
14
14
  resolve_user_id_from_args,
15
15
  resolve_lane_id_from_args,
16
16
  )
17
+ from arctx_cli.lane_gate import ensure_lane_open
17
18
 
18
19
  # ---------------------------------------------------------------------------
19
20
  # Parser registration
@@ -60,6 +61,8 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
60
61
  sp_add.add_argument("--store-dir", default=None)
61
62
  sp_add.add_argument("--user", default=None)
62
63
  sp_add.add_argument("--lane", default=None)
64
+ sp_add.add_argument("--force", action="store_true",
65
+ help="Write even if the target lane is closed")
63
66
 
64
67
  sp_show = git_sub.add_parser("show", help="Show git_change payloads for a Step")
65
68
  sp_show.add_argument("--step", required=True, dest="step_id")
@@ -203,6 +206,7 @@ def _cli_git_attach(args) -> int:
203
206
  from arctx.ext.git.helpers.attach import attach_commits_to_step
204
207
 
205
208
  try:
209
+ ensure_lane_open(handle, lane_id, force=getattr(args, "force", False))
206
210
  before = graph_counts(handle)
207
211
  result = attach_commits_to_step(
208
212
  handle,
@@ -0,0 +1,25 @@
1
+ """Guard that refuses writes to a closed lane.
2
+
3
+ A lane's lifecycle is open → closed → (reopen) open. "Closed" only means
4
+ something if writes respect it: once a lane is closed, ``add`` / ``attach`` /
5
+ ``asset attach`` / ``git add`` and friends refuse to append to it until it is
6
+ explicitly reopened with ``arctx lane open`` (or the caller passes ``--force``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def ensure_lane_open(handle, lane_id: str | None, *, force: bool = False) -> None:
13
+ """Raise if *lane_id* names a closed lane, unless *force* is set.
14
+
15
+ No-op when *lane_id* is falsy (no lane context) or unknown — those are the
16
+ pre-existing default/lane-less write paths and are not this guard's business.
17
+ """
18
+ if not lane_id or force:
19
+ return
20
+ lane = handle.run_graph.lanes.get(lane_id)
21
+ if lane is not None and getattr(lane, "status", "open") == "closed":
22
+ raise ValueError(
23
+ f"lane is closed: {lane_id}; reopen it with "
24
+ f"`arctx lane open {lane_id}` (or pass --force to write anyway)"
25
+ )
@@ -0,0 +1,155 @@
1
+ """Tests for the lane open/close lifecycle and the closed-lane write gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from arctx_cli.commands.add import run_add_step_command
11
+ from arctx_cli.commands.init import run_init_command
12
+ from arctx_cli.commands.lane import (
13
+ list_lanes,
14
+ run_lane_close_command,
15
+ run_lane_create_command,
16
+ run_lane_open_command,
17
+ )
18
+ from arctx_cli.context import resolve_store
19
+
20
+
21
+ def _store_dir(td: str) -> str:
22
+ return str(Path(td) / "runs")
23
+
24
+
25
+ def _init(td: str) -> None:
26
+ run_init_command(
27
+ requirement_id="req",
28
+ target_type="task",
29
+ target_id="target",
30
+ run_id="run_lc",
31
+ store_dir=_store_dir(td),
32
+ )
33
+
34
+
35
+ def _create_lane(sd: str) -> str:
36
+ res = run_lane_create_command(name="work", run_id="run_lc", user_id="alice", store_dir=sd)
37
+ return res["lane_id"]
38
+
39
+
40
+ def _root(sd: str) -> str:
41
+ return resolve_store(sd).load_run("run_lc").root_node_id
42
+
43
+
44
+ def _add(sd: str, from_node: str, lane_id: str, *, title: str, force: bool = False) -> str:
45
+ res = run_add_step_command(
46
+ run_id="run_lc",
47
+ input_node_ids=[from_node],
48
+ title=title,
49
+ payload_kind=None,
50
+ payload_type="step_payload",
51
+ field_data={},
52
+ json_data={},
53
+ store_dir=sd,
54
+ user_id="alice",
55
+ lane_id=lane_id,
56
+ force=force,
57
+ )
58
+ return res["step"]["output_node_id"]
59
+
60
+
61
+ def _status(sd: str, lane_id: str) -> str:
62
+ return next(l["status"] for l in list_lanes(run_id="run_lc", store_dir=sd) if l["lane_id"] == lane_id)
63
+
64
+
65
+ def test_close_single_leaf_stamps_existing_leaf_no_extra_node():
66
+ with tempfile.TemporaryDirectory() as td:
67
+ sd = _store_dir(td)
68
+ _init(td)
69
+ lane_id = _create_lane(sd)
70
+ leaf = _add(sd, _root(sd), lane_id, title="s1")
71
+
72
+ res = run_lane_close_command(
73
+ name_or_id="work", summary="findings", node_ids=None, reason="done",
74
+ run_id="run_lc", user_id="alice", store_dir=sd,
75
+ )
76
+ # The summary lands on the existing leaf — no convergence node is created.
77
+ assert res["status"] == "closed"
78
+ assert res["summary_node"] == leaf
79
+ assert res["joined_nodes"] == [leaf]
80
+ assert _status(sd, lane_id) == "closed"
81
+
82
+
83
+ def test_status_persists_across_reload():
84
+ with tempfile.TemporaryDirectory() as td:
85
+ sd = _store_dir(td)
86
+ _init(td)
87
+ lane_id = _create_lane(sd)
88
+ _add(sd, _root(sd), lane_id, title="s1")
89
+ run_lane_close_command(
90
+ name_or_id="work", summary="x", node_ids=None, reason=None,
91
+ run_id="run_lc", user_id="alice", store_dir=sd,
92
+ )
93
+ # Fresh load → status is the projection of the lane_closed event.
94
+ assert _status(sd, lane_id) == "closed"
95
+
96
+
97
+ def test_write_to_closed_lane_is_blocked_then_force_overrides():
98
+ with tempfile.TemporaryDirectory() as td:
99
+ sd = _store_dir(td)
100
+ _init(td)
101
+ lane_id = _create_lane(sd)
102
+ leaf = _add(sd, _root(sd), lane_id, title="s1")
103
+ run_lane_close_command(
104
+ name_or_id="work", summary="x", node_ids=None, reason=None,
105
+ run_id="run_lc", user_id="alice", store_dir=sd,
106
+ )
107
+ with pytest.raises(ValueError, match="closed"):
108
+ _add(sd, leaf, lane_id, title="s2")
109
+ # --force punches through.
110
+ forced = _add(sd, leaf, lane_id, title="s2", force=True)
111
+ assert forced
112
+
113
+
114
+ def test_open_reopens_and_allows_writes():
115
+ with tempfile.TemporaryDirectory() as td:
116
+ sd = _store_dir(td)
117
+ _init(td)
118
+ lane_id = _create_lane(sd)
119
+ leaf = _add(sd, _root(sd), lane_id, title="s1")
120
+ run_lane_close_command(
121
+ name_or_id="work", summary="x", node_ids=None, reason=None,
122
+ run_id="run_lc", user_id="alice", store_dir=sd,
123
+ )
124
+ res = run_lane_open_command(
125
+ name_or_id="work", reason="resume", run_id="run_lc",
126
+ user_id="alice", store_dir=sd,
127
+ )
128
+ assert res["status"] == "open"
129
+ assert _status(sd, lane_id) == "open"
130
+ # Writing now succeeds without --force.
131
+ assert _add(sd, leaf, lane_id, title="s2")
132
+
133
+
134
+ def test_open_when_open_and_close_when_closed_error():
135
+ with tempfile.TemporaryDirectory() as td:
136
+ sd = _store_dir(td)
137
+ _init(td)
138
+ _create_lane(sd)
139
+ # opening an already-open lane errors
140
+ with pytest.raises(ValueError, match="already open"):
141
+ run_lane_open_command(
142
+ name_or_id="work", reason=None, run_id="run_lc",
143
+ user_id="alice", store_dir=sd,
144
+ )
145
+ # close with no summary is allowed (closing an empty/dead-end lane)
146
+ run_lane_close_command(
147
+ name_or_id="work", summary=None, node_ids=None, reason=None,
148
+ run_id="run_lc", user_id="alice", store_dir=sd,
149
+ )
150
+ # closing an already-closed lane errors
151
+ with pytest.raises(ValueError, match="already closed"):
152
+ run_lane_close_command(
153
+ name_or_id="work", summary=None, node_ids=None, reason=None,
154
+ run_id="run_lc", user_id="alice", store_dir=sd,
155
+ )
File without changes
File without changes