forgexa-cli 1.18.5__tar.gz → 1.18.7__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.
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/PKG-INFO +1 -1
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli/daemon.py +157 -78
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/pyproject.toml +1 -1
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/README.md +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/setup.cfg +0 -0
- {forgexa_cli-1.18.5 → forgexa_cli-1.18.7}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.18.
|
|
2
|
+
__version__ = "1.18.7"
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.18.
|
|
558
|
+
DAEMON_VERSION = "1.18.7"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -1341,6 +1341,17 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
|
1341
1341
|
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
1342
1342
|
|
|
1343
1343
|
|
|
1344
|
+
def _is_requirement_analysis_path(path: str) -> bool:
|
|
1345
|
+
"""Recognize v1 and v2 requirement analysis paths without backend imports."""
|
|
1346
|
+
normalized = str(path or "").replace("\\", "/").lstrip("./")
|
|
1347
|
+
return (
|
|
1348
|
+
normalized.startswith("docs/requirements/")
|
|
1349
|
+
and "/implement/" not in normalized
|
|
1350
|
+
and "/bugfix/" not in normalized
|
|
1351
|
+
and "/delivery/" not in normalized
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
|
|
1344
1355
|
# Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
|
|
1345
1356
|
# Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
|
|
1346
1357
|
_DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
|
|
@@ -4053,6 +4064,17 @@ class ProcessManager:
|
|
|
4053
4064
|
return normalized
|
|
4054
4065
|
|
|
4055
4066
|
def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
|
|
4067
|
+
input_data = task.input_data or {}
|
|
4068
|
+
if input_data.get("verification_work_item"):
|
|
4069
|
+
path_by_node_type = {
|
|
4070
|
+
"analysis": input_data.get("verification_analysis_doc_path", ""),
|
|
4071
|
+
"fix": input_data.get("bugfix_doc_path", ""),
|
|
4072
|
+
"testing": input_data.get("verification_test_report_path", ""),
|
|
4073
|
+
}
|
|
4074
|
+
required_path = str(path_by_node_type.get(task.node_type, "") or "")
|
|
4075
|
+
required_path = required_path.replace("\\", "/").lstrip("./")
|
|
4076
|
+
return {required_path} if required_path else set()
|
|
4077
|
+
|
|
4056
4078
|
# For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/<key>/analysis)
|
|
4057
4079
|
# For delivery nodes, deliverables live in output_dir (docs/requirements/<key>/delivery)
|
|
4058
4080
|
# For other nodes, use output_dir (docs/requirements/<key>/implement)
|
|
@@ -4392,6 +4414,14 @@ class ProcessManager:
|
|
|
4392
4414
|
]
|
|
4393
4415
|
|
|
4394
4416
|
env = os.environ.copy()
|
|
4417
|
+
for key in (
|
|
4418
|
+
"XDG_CONFIG_HOME",
|
|
4419
|
+
"XDG_DATA_HOME",
|
|
4420
|
+
"XDG_CACHE_HOME",
|
|
4421
|
+
"XDG_STATE_HOME",
|
|
4422
|
+
"CLAUDE_CODE_DEBUG_LOGS_DIR",
|
|
4423
|
+
):
|
|
4424
|
+
env.pop(key, None)
|
|
4395
4425
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
4396
4426
|
if model_override:
|
|
4397
4427
|
env["ANTHROPIC_MODEL"] = model_override
|
|
@@ -4557,7 +4587,8 @@ class ProcessManager:
|
|
|
4557
4587
|
"""Run OpenCode CLI in non-interactive mode.
|
|
4558
4588
|
|
|
4559
4589
|
Uses `opencode run --format json --dir <cwd>` for headless execution.
|
|
4560
|
-
The
|
|
4590
|
+
The full prompt is attached through a temporary file so it never exceeds
|
|
4591
|
+
the operating system command-line length limit.
|
|
4561
4592
|
NOTE: `--dir` is the correct flag (not `--cwd` which is invalid).
|
|
4562
4593
|
|
|
4563
4594
|
Each invocation gets an isolated XDG_DATA_HOME so that concurrent
|
|
@@ -4604,16 +4635,18 @@ class ProcessManager:
|
|
|
4604
4635
|
model_override = os.environ.get("FACTORY_OPENCODE_MODEL")
|
|
4605
4636
|
if model_override:
|
|
4606
4637
|
cmd += ["--model", model_override]
|
|
4607
|
-
# -- ensures yargs treats everything after it as positional args, not flags.
|
|
4608
|
-
# Without this, prompts containing --flag-like text cause yargs to print help and exit 1.
|
|
4609
|
-
cmd += ["--", prompt]
|
|
4610
|
-
|
|
4611
4638
|
# Isolate each opencode run in its own data directory to prevent
|
|
4612
4639
|
# concurrent processes from racing on the shared SQLite WAL file.
|
|
4613
4640
|
tmp_data_root = tempfile.mkdtemp(prefix=f"opencode-{task_id[:8]}-")
|
|
4614
4641
|
try:
|
|
4615
4642
|
isolated_data_dir = Path(tmp_data_root) / "opencode"
|
|
4616
4643
|
isolated_data_dir.mkdir()
|
|
4644
|
+
prompt_file = Path(tmp_data_root) / "instructions.md"
|
|
4645
|
+
prompt_file.write_text(prompt, encoding="utf-8")
|
|
4646
|
+
cmd += [
|
|
4647
|
+
"--file", str(prompt_file),
|
|
4648
|
+
"--", "Read and follow the attached instructions.",
|
|
4649
|
+
]
|
|
4617
4650
|
# Copy auth.json so provider credentials are available.
|
|
4618
4651
|
auth_src = Path.home() / ".local" / "share" / "opencode" / "auth.json"
|
|
4619
4652
|
if auth_src.exists():
|
|
@@ -4798,59 +4831,45 @@ class ProcessManager:
|
|
|
4798
4831
|
def _prepare_claude_environment(
|
|
4799
4832
|
sandbox_root: "Path | None" = None,
|
|
4800
4833
|
) -> dict[str, str]:
|
|
4801
|
-
"""Prepare
|
|
4834
|
+
"""Prepare an isolated Claude home and mirror stable configuration files.
|
|
4802
4835
|
|
|
4803
4836
|
When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
|
|
4804
4837
|
directory. When None, falls back to the shared legacy path under ~/.forgexa.
|
|
4805
4838
|
|
|
4806
4839
|
The daemon service runs with ProtectSystem=strict and only allows
|
|
4807
|
-
writes under ~/.forgexa plus the workspace root.
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4840
|
+
writes under ~/.forgexa plus the workspace root. Redirecting HOME lets
|
|
4841
|
+
Claude use its default config, cache, and session locations under a
|
|
4842
|
+
writable task sandbox.
|
|
4843
|
+
|
|
4844
|
+
Mirror only stable authentication and settings files. Runtime state such
|
|
4845
|
+
as session-env, projects, task locks, and history must never cross into
|
|
4846
|
+
an isolated daemon invocation. Do not set CLAUDE_CODE_DEBUG_LOGS_DIR:
|
|
4847
|
+
Claude Code 2.1.63 treats a directory supplied through that variable as
|
|
4848
|
+
a file after TodoWrite and exits with EISDIR.
|
|
4815
4849
|
"""
|
|
4816
4850
|
if sandbox_root is None:
|
|
4817
4851
|
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4818
|
-
config_root = sandbox_root / "config"
|
|
4819
|
-
data_root = sandbox_root / "data"
|
|
4820
|
-
cache_root = sandbox_root / "cache"
|
|
4821
|
-
state_root = sandbox_root / "state"
|
|
4822
4852
|
home_claude_dir = sandbox_root / ".claude"
|
|
4823
|
-
claude_config_dir = config_root / "claude"
|
|
4824
4853
|
|
|
4825
4854
|
for path in (
|
|
4826
|
-
config_root,
|
|
4827
|
-
data_root,
|
|
4828
|
-
cache_root,
|
|
4829
|
-
state_root,
|
|
4830
4855
|
home_claude_dir,
|
|
4831
|
-
claude_config_dir,
|
|
4832
4856
|
):
|
|
4833
4857
|
path.mkdir(parents=True, exist_ok=True)
|
|
4834
4858
|
|
|
4835
|
-
source_claude_dir = Path.home() / ".claude"
|
|
4836
|
-
try:
|
|
4837
|
-
if source_claude_dir.is_dir():
|
|
4838
|
-
shutil.copytree(source_claude_dir, home_claude_dir, dirs_exist_ok=True)
|
|
4839
|
-
except Exception as exc:
|
|
4840
|
-
logger.debug(
|
|
4841
|
-
"Claude home mirror skipped %s -> %s: %s",
|
|
4842
|
-
source_claude_dir, home_claude_dir, exc,
|
|
4843
|
-
)
|
|
4844
|
-
|
|
4845
4859
|
source_files = [
|
|
4846
4860
|
(Path.home() / ".claude.json", sandbox_root / ".claude.json"),
|
|
4847
|
-
(Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
|
|
4848
4861
|
(Path.home() / ".claude" / "settings.json", home_claude_dir / "settings.json"),
|
|
4849
|
-
(Path.home() / ".claude" / "settings.json",
|
|
4862
|
+
(Path.home() / ".claude" / "settings.local.json", home_claude_dir / "settings.local.json"),
|
|
4850
4863
|
]
|
|
4851
4864
|
for source, dest in source_files:
|
|
4852
4865
|
try:
|
|
4853
|
-
if source.is_file()
|
|
4866
|
+
if not source.is_file():
|
|
4867
|
+
continue
|
|
4868
|
+
if dest.is_dir():
|
|
4869
|
+
shutil.rmtree(dest)
|
|
4870
|
+
elif dest.is_file() or dest.is_symlink():
|
|
4871
|
+
dest.unlink()
|
|
4872
|
+
if not dest.exists():
|
|
4854
4873
|
shutil.copy2(source, dest)
|
|
4855
4874
|
except Exception as exc:
|
|
4856
4875
|
logger.debug(
|
|
@@ -4860,19 +4879,11 @@ class ProcessManager:
|
|
|
4860
4879
|
|
|
4861
4880
|
env = {
|
|
4862
4881
|
"HOME": str(sandbox_root),
|
|
4863
|
-
"XDG_CONFIG_HOME": str(config_root),
|
|
4864
|
-
"XDG_DATA_HOME": str(data_root),
|
|
4865
|
-
"XDG_CACHE_HOME": str(cache_root),
|
|
4866
|
-
"XDG_STATE_HOME": str(state_root),
|
|
4867
4882
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
|
|
4868
4883
|
os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
|
4869
4884
|
),
|
|
4870
4885
|
}
|
|
4871
4886
|
|
|
4872
|
-
if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
|
|
4873
|
-
env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
|
|
4874
|
-
Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
4875
|
-
|
|
4876
4887
|
return env
|
|
4877
4888
|
|
|
4878
4889
|
@staticmethod
|
|
@@ -6276,6 +6287,7 @@ class TaskPoller:
|
|
|
6276
6287
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
6277
6288
|
self._on_auth_failure = lambda: None
|
|
6278
6289
|
self._empty_cycles = 0
|
|
6290
|
+
self.cancelled_ai_job_ids: list[str] = []
|
|
6279
6291
|
|
|
6280
6292
|
def note_activity(self, *, had_work: bool) -> None:
|
|
6281
6293
|
if had_work:
|
|
@@ -6336,12 +6348,20 @@ class TaskPoller:
|
|
|
6336
6348
|
)
|
|
6337
6349
|
if resp.status_code == 200:
|
|
6338
6350
|
self._on_success()
|
|
6339
|
-
|
|
6351
|
+
data = resp.json()
|
|
6352
|
+
self.cancelled_ai_job_ids = [
|
|
6353
|
+
str(job_id)
|
|
6354
|
+
for job_id in data.get("cancelled_job_ids", [])
|
|
6355
|
+
if job_id
|
|
6356
|
+
]
|
|
6357
|
+
return data.get("ai_jobs", [])
|
|
6340
6358
|
elif resp.status_code in (401, 403):
|
|
6341
6359
|
self._on_auth_failure()
|
|
6360
|
+
self.cancelled_ai_job_ids = []
|
|
6342
6361
|
return []
|
|
6343
6362
|
except Exception as e:
|
|
6344
6363
|
logger.debug("AIJob poll error: %s", e)
|
|
6364
|
+
self.cancelled_ai_job_ids = []
|
|
6345
6365
|
return []
|
|
6346
6366
|
|
|
6347
6367
|
|
|
@@ -7118,6 +7138,19 @@ class RuntimeDaemon:
|
|
|
7118
7138
|
async def _poll_and_execute(self) -> bool:
|
|
7119
7139
|
"""Poll for tasks from all connected servers and execute them."""
|
|
7120
7140
|
had_work = False
|
|
7141
|
+
|
|
7142
|
+
def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
|
|
7143
|
+
for job_id in conn.poller.cancelled_ai_job_ids:
|
|
7144
|
+
ai_task_key = f"aijob_{job_id}"
|
|
7145
|
+
active_task = self.active_tasks.get(ai_task_key)
|
|
7146
|
+
if not active_task or active_task.done():
|
|
7147
|
+
continue
|
|
7148
|
+
active_process = self.process_manager.active_processes.get(job_id)
|
|
7149
|
+
if active_process:
|
|
7150
|
+
_kill_proc(active_process)
|
|
7151
|
+
active_task.cancel()
|
|
7152
|
+
logger.info("[%s] Cancelled AIJob %s at server request", conn.label, job_id)
|
|
7153
|
+
|
|
7121
7154
|
# Clean up completed tasks
|
|
7122
7155
|
for task_id in list(self.active_tasks):
|
|
7123
7156
|
if self.active_tasks[task_id].done():
|
|
@@ -7130,24 +7163,21 @@ class RuntimeDaemon:
|
|
|
7130
7163
|
if conn.heartbeat:
|
|
7131
7164
|
conn.heartbeat.update(total_active, self._agents_as_dicts())
|
|
7132
7165
|
|
|
7133
|
-
# Check capacity
|
|
7134
|
-
if total_active >= self.max_concurrent:
|
|
7135
|
-
return True
|
|
7136
|
-
|
|
7137
7166
|
# Poll from all connected servers
|
|
7138
7167
|
for conn in self.connections:
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
ai_jobs
|
|
7144
|
-
|
|
7145
|
-
|
|
7168
|
+
has_capacity = total_active < self.max_concurrent
|
|
7169
|
+
tasks = await conn.poller.poll() if has_capacity else []
|
|
7170
|
+
# Always poll AI jobs so server-requested cancellations can stop a
|
|
7171
|
+
# process that is itself consuming the last available slot.
|
|
7172
|
+
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
7173
|
+
_cancel_server_requested_ai_jobs(conn)
|
|
7174
|
+
total_active = len(self.active_tasks)
|
|
7175
|
+
has_capacity = total_active < self.max_concurrent
|
|
7146
7176
|
|
|
7147
7177
|
conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
|
|
7148
7178
|
had_work = had_work or bool(tasks or ai_jobs)
|
|
7149
7179
|
|
|
7150
|
-
for task in tasks:
|
|
7180
|
+
for task in tasks if has_capacity else []:
|
|
7151
7181
|
if task.task_id in self.active_tasks:
|
|
7152
7182
|
continue # Already running
|
|
7153
7183
|
|
|
@@ -7163,7 +7193,7 @@ class RuntimeDaemon:
|
|
|
7163
7193
|
)
|
|
7164
7194
|
|
|
7165
7195
|
# Poll for AIJobs (workspace-mode tasks)
|
|
7166
|
-
for aj in ai_jobs:
|
|
7196
|
+
for aj in ai_jobs if has_capacity else []:
|
|
7167
7197
|
job_id = aj.get("job_id", "")
|
|
7168
7198
|
ai_task_key = f"aijob_{job_id}"
|
|
7169
7199
|
if ai_task_key in self.active_tasks:
|
|
@@ -7177,6 +7207,9 @@ class RuntimeDaemon:
|
|
|
7177
7207
|
self._execute_ai_job(aj, conn)
|
|
7178
7208
|
)
|
|
7179
7209
|
|
|
7210
|
+
total_active = len(self.active_tasks)
|
|
7211
|
+
has_capacity = total_active < self.max_concurrent
|
|
7212
|
+
|
|
7180
7213
|
return had_work
|
|
7181
7214
|
|
|
7182
7215
|
async def _workspace_checkout_health_error(self, workspace_path: Path) -> str | None:
|
|
@@ -7811,7 +7844,11 @@ class RuntimeDaemon:
|
|
|
7811
7844
|
|
|
7812
7845
|
# 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
|
|
7813
7846
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
7814
|
-
|
|
7847
|
+
requires_current_run_deliverable = task.node_type in ("analysis", "design", "fix") or (
|
|
7848
|
+
task.node_type == "testing"
|
|
7849
|
+
and bool((task.input_data or {}).get("verification_work_item"))
|
|
7850
|
+
)
|
|
7851
|
+
if result.status == "success" and requires_current_run_deliverable:
|
|
7815
7852
|
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
7816
7853
|
workspace_path,
|
|
7817
7854
|
default_branch=default_branch,
|
|
@@ -7874,7 +7911,11 @@ class RuntimeDaemon:
|
|
|
7874
7911
|
# This closes the mirror-sync gap: review agents update analysis.json
|
|
7875
7912
|
# (open_risks, phase_handoffs) and this step ensures the content reaches
|
|
7876
7913
|
# runtimes.py as an inline artifact regardless of push success.
|
|
7877
|
-
if
|
|
7914
|
+
if (
|
|
7915
|
+
result.status == "success"
|
|
7916
|
+
and task.node_type in ("review", "coding", "testing")
|
|
7917
|
+
and not bool((task.input_data or {}).get("verification_work_item"))
|
|
7918
|
+
):
|
|
7878
7919
|
analysis_dir = (
|
|
7879
7920
|
(task.input_data or {}).get("analysis_output_dir", "")
|
|
7880
7921
|
or ""
|
|
@@ -8078,7 +8119,40 @@ class RuntimeDaemon:
|
|
|
8078
8119
|
|
|
8079
8120
|
issues: list[str] = []
|
|
8080
8121
|
node_type = task.node_type
|
|
8081
|
-
|
|
8122
|
+
input_data = task.input_data or {}
|
|
8123
|
+
req_type = input_data.get("requirement_type", "feature")
|
|
8124
|
+
verification_work_item = bool(input_data.get("verification_work_item"))
|
|
8125
|
+
|
|
8126
|
+
if verification_work_item:
|
|
8127
|
+
forbidden_paths = sorted(
|
|
8128
|
+
path
|
|
8129
|
+
for path in ProcessManager._normalize_repo_paths(result.files_changed)
|
|
8130
|
+
if _is_requirement_analysis_path(path)
|
|
8131
|
+
)
|
|
8132
|
+
if forbidden_paths:
|
|
8133
|
+
issues.append(
|
|
8134
|
+
"Verification work item modified requirement analysis assets: "
|
|
8135
|
+
+ ", ".join(forbidden_paths[:5])
|
|
8136
|
+
)
|
|
8137
|
+
|
|
8138
|
+
if verification_work_item and node_type == "analysis":
|
|
8139
|
+
analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
|
|
8140
|
+
analysis_doc_path = analysis_doc_path.replace("\\", "/").lstrip("./")
|
|
8141
|
+
analysis_doc = workspace_path / analysis_doc_path
|
|
8142
|
+
if not analysis_doc_path or not analysis_doc.exists():
|
|
8143
|
+
issues.append(f"Verification analysis record missing: {analysis_doc_path}")
|
|
8144
|
+
elif analysis_doc.stat().st_size == 0:
|
|
8145
|
+
issues.append(f"Verification analysis record is empty: {analysis_doc_path}")
|
|
8146
|
+
return issues
|
|
8147
|
+
|
|
8148
|
+
if verification_work_item and node_type == "testing":
|
|
8149
|
+
test_report_path = str(input_data.get("verification_test_report_path", "") or "")
|
|
8150
|
+
test_report_path = test_report_path.replace("\\", "/").lstrip("./")
|
|
8151
|
+
test_report = workspace_path / test_report_path
|
|
8152
|
+
if not test_report_path or not test_report.exists():
|
|
8153
|
+
issues.append(f"Verification test report missing: {test_report_path}")
|
|
8154
|
+
elif test_report.stat().st_size == 0:
|
|
8155
|
+
issues.append(f"Verification test report is empty: {test_report_path}")
|
|
8082
8156
|
|
|
8083
8157
|
if node_type == "analysis":
|
|
8084
8158
|
# Use type profile to determine required analysis outputs
|
|
@@ -8249,7 +8323,7 @@ class RuntimeDaemon:
|
|
|
8249
8323
|
pass # analysis.json corrupt — already flagged elsewhere
|
|
8250
8324
|
|
|
8251
8325
|
# Testing-specific: validate structured test assets
|
|
8252
|
-
if node_type == "testing":
|
|
8326
|
+
if node_type == "testing" and not verification_work_item:
|
|
8253
8327
|
# Determine which checks to run for this requirement type.
|
|
8254
8328
|
#
|
|
8255
8329
|
# _skip_test_artifacts = True → skip ALL artifact checks
|
|
@@ -8844,21 +8918,26 @@ class RuntimeDaemon:
|
|
|
8844
8918
|
always receives the file contents via the completion report and gate
|
|
8845
8919
|
reviewers can see the analysis documents immediately.
|
|
8846
8920
|
"""
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8921
|
+
input_data = task.input_data or {}
|
|
8922
|
+
verification_work_item = bool(input_data.get("verification_work_item"))
|
|
8923
|
+
if verification_work_item:
|
|
8924
|
+
analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
|
|
8925
|
+
candidate_paths = [analysis_doc_path] if analysis_doc_path else []
|
|
8926
|
+
else:
|
|
8927
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
8928
|
+
doc_dir = input_data.get("analysis_output_dir", "") or input_data.get("output_dir", "")
|
|
8929
|
+
if not doc_dir:
|
|
8930
|
+
return
|
|
8931
|
+
base = workspace_path / doc_dir.lstrip("./")
|
|
8932
|
+
req_type = input_data.get("requirement_type", "feature")
|
|
8933
|
+
candidate_paths = [str(base / fname) for fname in _get_analysis_outputs_for_type(req_type)]
|
|
8854
8934
|
|
|
8855
|
-
base = workspace_path / doc_dir.lstrip("./")
|
|
8856
|
-
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
8857
|
-
_ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
|
|
8858
8935
|
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
8859
8936
|
|
|
8860
|
-
for
|
|
8861
|
-
fpath =
|
|
8937
|
+
for candidate_path in candidate_paths:
|
|
8938
|
+
fpath = Path(candidate_path)
|
|
8939
|
+
if not fpath.is_absolute():
|
|
8940
|
+
fpath = workspace_path / fpath
|
|
8862
8941
|
if not fpath.exists() or fpath.stat().st_size == 0:
|
|
8863
8942
|
continue
|
|
8864
8943
|
try:
|
|
@@ -8866,7 +8945,7 @@ class RuntimeDaemon:
|
|
|
8866
8945
|
if rel_path in existing_artifact_paths:
|
|
8867
8946
|
continue # already attached
|
|
8868
8947
|
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
8869
|
-
mime = "text/markdown" if
|
|
8948
|
+
mime = "text/markdown" if rel_path.endswith(".md") else "application/json"
|
|
8870
8949
|
result.artifacts.append({
|
|
8871
8950
|
"path": rel_path,
|
|
8872
8951
|
"content": content,
|
|
@@ -8876,7 +8955,7 @@ class RuntimeDaemon:
|
|
|
8876
8955
|
"Attached analysis artifact inline: %s (%d bytes)", rel_path, len(content)
|
|
8877
8956
|
)
|
|
8878
8957
|
except Exception as e:
|
|
8879
|
-
logger.warning("Failed to read analysis artifact %s: %s",
|
|
8958
|
+
logger.warning("Failed to read analysis artifact %s: %s", candidate_path, e)
|
|
8880
8959
|
|
|
8881
8960
|
async def _collect_design_artifacts(
|
|
8882
8961
|
self, workspace_path: Path, task: TaskInfo, result: TaskResult
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|