@xenonbyte/req-2-plan 0.5.0 → 0.5.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -49,9 +49,13 @@ from tools.workflow_cli.gates import (
49
49
  check_execution_complete,
50
50
  )
51
51
  from tools.workflow_cli.output import (
52
+ COMPACT_DETAIL_LIMIT,
53
+ COMPACT_FILE_LIST_LIMIT,
54
+ compact_human_list,
52
55
  format_success,
53
56
  format_error,
54
57
  format_gate_result,
58
+ is_json_mode,
55
59
  print_and_exit,
56
60
  EXIT_OK,
57
61
  EXIT_CLI_ERR,
@@ -84,10 +88,7 @@ def _load_run(work_id: str, base_path: Path | None = None):
84
88
  no command — read-only or mutating — ever follows `.req-to-plan` or
85
89
  `.req-to-plan/<id>` out of the workspace.
86
90
  """
87
- root = base_path or Path.cwd()
88
- _reject_symlink_or_exit(root / ".req-to-plan", "unsafe_workspace_dir_symlink")
89
- run_dir = _get_run_dir(work_id, base_path)
90
- _reject_symlink_or_exit(run_dir, f"Run directory is a symlink: {run_dir}")
91
+ run_dir = _reject_symlinked_run_paths(work_id, base_path)
91
92
  mgr = RunStateManager(run_dir)
92
93
  try:
93
94
  return mgr.load(), mgr, run_dir
@@ -98,6 +99,53 @@ def _load_run(work_id: str, base_path: Path | None = None):
98
99
  )
99
100
 
100
101
 
102
+ def _write_recovery_list(run_dir: Path, work_id: str, filename: str, items: list[str]) -> str | None:
103
+ logs_dir = run_dir / "logs"
104
+ if logs_dir.is_symlink():
105
+ return None
106
+ try:
107
+ logs_dir.mkdir(parents=True, exist_ok=True)
108
+ recovery_path = logs_dir / filename
109
+ atomic_write_text(recovery_path, "\n".join(items) + "\n")
110
+ except OSError:
111
+ return None
112
+ return f".req-to-plan/{work_id}/logs/{filename}"
113
+
114
+
115
+ def _human_list_payload(
116
+ *,
117
+ run_dir: Path,
118
+ work_id: str,
119
+ label: str,
120
+ items: list,
121
+ limit: int,
122
+ recovery_filename: str,
123
+ recovery_items: list[str] | None = None,
124
+ ) -> dict:
125
+ if is_json_mode() or len(items) <= limit:
126
+ return {label: items}
127
+
128
+ recovery_path = _write_recovery_list(
129
+ run_dir,
130
+ work_id,
131
+ recovery_filename,
132
+ recovery_items if recovery_items is not None else [str(item) for item in items],
133
+ )
134
+ if recovery_path is None:
135
+ return {label: items}
136
+
137
+ payload = compact_human_list(
138
+ label=label,
139
+ items=items,
140
+ limit=limit,
141
+ recovery_path=recovery_path,
142
+ )
143
+ payload[f"{label}_summary"] = (
144
+ f"{payload[f'{label}_shown']} shown, {payload[f'{label}_total']} total"
145
+ )
146
+ return payload
147
+
148
+
101
149
  def _validate_work_id(raw: str) -> WorkId:
102
150
  """Parse WorkId or exit with CLI error."""
103
151
  try:
@@ -118,6 +166,20 @@ def _reject_symlink_or_exit(path: Path, message: str) -> None:
118
166
  print_and_exit(format_error(message, exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
119
167
 
120
168
 
169
+ def _reject_symlinked_run_paths(work_id, base_path: Path | None) -> Path:
170
+ """Reject a symlinked workspace or run directory, then return the run dir.
171
+
172
+ Guards `.req-to-plan` and `.req-to-plan/<id>` up front (EXIT_CONFLICT) so no
173
+ command — read-only or mutating — ever follows either out of the workspace.
174
+ Call before any filesystem mutation that targets the run dir.
175
+ """
176
+ root = base_path or Path.cwd()
177
+ _reject_symlink_or_exit(root / ".req-to-plan", "unsafe_workspace_dir_symlink")
178
+ run_dir = _get_run_dir(work_id, base_path)
179
+ _reject_symlink_or_exit(run_dir, f"Run directory is a symlink: {run_dir}")
180
+ return run_dir
181
+
182
+
121
183
  def _validate_repo_path(raw: str) -> Path:
122
184
  """Return a repo path only when it is an existing directory."""
123
185
  repo_path = Path(raw)
@@ -223,7 +285,7 @@ def _cmd_run_start(args):
223
285
  EXIT_CLI_ERR,
224
286
  )
225
287
  repo_path = _validate_repo_path(args.repo_path) if args.repo_path else None
226
- run_dir = _get_run_dir(work_id, args.base_path)
288
+ run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
227
289
  mgr = RunStateManager(run_dir)
228
290
 
229
291
  run_dir_occupied = False
@@ -323,6 +385,15 @@ def _cmd_run_start(args):
323
385
  def _cmd_run_resume(args):
324
386
  record, mgr, run_dir = _load_run(args.work_id, args.base_path)
325
387
  rc = record.resume_context
388
+ reread_targets = list(rc.required_reread_targets)
389
+ reread_payload = _human_list_payload(
390
+ run_dir=run_dir,
391
+ work_id=str(record.work_id),
392
+ label="required_reread_targets",
393
+ items=reread_targets,
394
+ limit=COMPACT_FILE_LIST_LIMIT,
395
+ recovery_filename="run-resume-reread-targets.txt",
396
+ )
326
397
  print_and_exit(
327
398
  format_success(
328
399
  {
@@ -333,6 +404,7 @@ def _cmd_run_resume(args):
333
404
  "next_operation": rc.next_allowed_operation,
334
405
  "active_item": rc.active_item,
335
406
  "resume_reason": rc.resume_reason,
407
+ **reread_payload,
336
408
  },
337
409
  message="Resume context",
338
410
  ),
@@ -1212,6 +1284,22 @@ def _cmd_status_run(args):
1212
1284
  for s in record.stale_artifacts
1213
1285
  ]
1214
1286
  outstanding_stale = [aa.stage.value for aa in record.active_artifacts if aa.status == "stale"]
1287
+ approved_checkpoints = [cp.stage.value for cp in record.approved_checkpoints]
1288
+ approved_payload = _human_list_payload(
1289
+ run_dir=run_dir,
1290
+ work_id=str(record.work_id),
1291
+ label="approved_checkpoints",
1292
+ items=approved_checkpoints,
1293
+ limit=COMPACT_DETAIL_LIMIT,
1294
+ recovery_filename="status-run-approved-checkpoints.txt",
1295
+ recovery_items=[
1296
+ (
1297
+ f"{cp.stage.value}\t{cp.artifact}\tv{cp.version}\t"
1298
+ f"{cp.approved_at}\t{cp.downstream_authorization}\t{cp.bundle_id or ''}"
1299
+ )
1300
+ for cp in record.approved_checkpoints
1301
+ ],
1302
+ )
1215
1303
 
1216
1304
  print_and_exit(
1217
1305
  format_success(
@@ -1226,7 +1314,7 @@ def _cmd_status_run(args):
1226
1314
  "open_routes_detail": open_routes_detail,
1227
1315
  "stale_artifacts": stale_artifacts,
1228
1316
  "outstanding_stale": outstanding_stale,
1229
- "approved_checkpoints": [cp.stage.value for cp in record.approved_checkpoints],
1317
+ **approved_payload,
1230
1318
  },
1231
1319
  message="Run status",
1232
1320
  ),
@@ -2000,7 +2088,7 @@ def _cmd_context_build(args):
2000
2088
  from tools.workflow_cli.context_pack import build_context_pack, write_context_pack
2001
2089
 
2002
2090
  work_id = str(_validate_work_id(args.work_id))
2003
- run_dir = _get_run_dir(work_id, args.base_path)
2091
+ run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
2004
2092
  if not run_dir.exists():
2005
2093
  print_and_exit(
2006
2094
  format_error(f"run not found: {work_id}", exit_code=EXIT_NOT_FOUND),
@@ -11,12 +11,38 @@ EXIT_REVIEW_REQ = 5 # forced subagent review required
11
11
  EXIT_CONFLICT = 6 # state conflict (run already closed, etc.)
12
12
  EXIT_NOT_FOUND = 7 # resource not found (run.md, artifact, etc.)
13
13
 
14
+ # Opt-in compact display limits. Default formatters remain uncapped.
15
+ COMPACT_DETAIL_LIMIT = 10
16
+ COMPACT_FILE_LIST_LIMIT = 15
17
+
14
18
 
15
19
  def is_json_mode() -> bool:
16
20
  """Check if JSON output mode is enabled via R2P_JSON environment variable."""
17
21
  return os.environ.get("R2P_JSON", "0") == "1"
18
22
 
19
23
 
24
+ def compact_human_list(
25
+ *,
26
+ label: str,
27
+ items: list,
28
+ limit: int,
29
+ recovery_path: str | None = None,
30
+ ) -> dict:
31
+ """Build an opt-in compact list payload without touching the filesystem."""
32
+ if limit < 0:
33
+ raise ValueError("limit must be non-negative")
34
+
35
+ visible_items = list(items[:limit])
36
+ result = {
37
+ label: visible_items,
38
+ f"{label}_shown": len(visible_items),
39
+ f"{label}_total": len(items),
40
+ }
41
+ if recovery_path:
42
+ result[f"{label}_full_list"] = recovery_path
43
+ return result
44
+
45
+
20
46
  def format_success(data: dict, message: str = "") -> str:
21
47
  """Format a success response."""
22
48
  if is_json_mode():
@@ -1 +1 @@
1
- R2P_VERSION = "0.5.0"
1
+ R2P_VERSION = "0.5.2"
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from tools.workflow_cli.atomic import atomic_write_text
16
16
 
17
- _WORKSPACE_GITIGNORE_LINES = ("/archive", "/.workflow-active")
17
+ _WORKSPACE_GITIGNORE_LINES = ("/archive", "/.workflow-active", "/*/logs/")
18
18
 
19
19
 
20
20
  def ensure_workspace_gitignore(base_path: Path) -> None: