syncade 0.6.2__py3-none-any.whl
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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Pre-dispatch classifier for the review loop.
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`syncade.orchestrator.loop` to keep that module under the LOC cap;
|
|
4
|
+
re-exported from there so existing imports remain stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from syncade.config import SyncadeConfig
|
|
14
|
+
from syncade.diff_filter import (
|
|
15
|
+
concealed_destinations,
|
|
16
|
+
elide_binary_hunks,
|
|
17
|
+
filter_diff_for_reviewer,
|
|
18
|
+
unidentifiable_sections,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from syncade.snapshot import Snapshot
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _diff_will_dispatch(
|
|
26
|
+
snapshot: Snapshot,
|
|
27
|
+
config: SyncadeConfig,
|
|
28
|
+
repo_root: Path | None = None,
|
|
29
|
+
pr_doc_path: Path | None = None,
|
|
30
|
+
) -> bool:
|
|
31
|
+
"""Return False when the round will terminate before dispatching any subprocess.
|
|
32
|
+
|
|
33
|
+
A False result means no reviewer, judge, or producer will run, so commit-safety
|
|
34
|
+
guards (dirty-tree refusal, default-branch guard) are irrelevant for this run.
|
|
35
|
+
Four cases: diff is malformed (diff_malformed, exit 60), reviewer-facing diff
|
|
36
|
+
exceeds the size ceiling (diff_too_large, exit 60), diff is known-empty
|
|
37
|
+
(no_changes_to_review, exit 0), or an assembled reviewer prompt exceeds the provider
|
|
38
|
+
character ceiling (prompt_too_large, exit 60). All are pre-dispatch terminal decisions
|
|
39
|
+
in _run_one_round; classifying here avoids the dirty/default-branch guards firing
|
|
40
|
+
before the prompt-size refusal for runs that will never dispatch any reviewer.
|
|
41
|
+
|
|
42
|
+
The prompt_too_large check renders the full round-0 prompt and requires repo_root to load
|
|
43
|
+
the reviewer templates. When repo_root is None the check is skipped and the exact check
|
|
44
|
+
inside _run_one_round catches it instead (though only after commit-safety guards have
|
|
45
|
+
already run). pr_doc_path is the resolved PR doc path; when provided it is converted to
|
|
46
|
+
the worktree-local reference the real reviewer sees (relative path inside the repo, or a
|
|
47
|
+
collision-free .syncade-inputs/ path for out-of-repo docs). When omitted the placeholder
|
|
48
|
+
"<pr-doc>" is used, which can undercount prompt size if the template repeats {pr_doc_path}.
|
|
49
|
+
"""
|
|
50
|
+
# Only refuse on unidentifiable headers when there are strip targets: with an empty
|
|
51
|
+
# strip list filter_diff_for_reviewer keeps every section byte-for-byte, so an
|
|
52
|
+
# undecodable header is not a security concern (nothing is being hidden).
|
|
53
|
+
if config.review.strip_repo_context_files and unidentifiable_sections(snapshot.diff_text):
|
|
54
|
+
return False # diff_malformed path — refuses at exit 60, no producer runs
|
|
55
|
+
filtered = filter_diff_for_reviewer(snapshot.diff_text, config.review.strip_repo_context_files)
|
|
56
|
+
filtered_elided, _ = elide_binary_hunks(filtered)
|
|
57
|
+
if len(filtered_elided.encode("utf-8")) > config.loop.max_diff_bytes:
|
|
58
|
+
return False # diff_too_large path — refuses at exit 60, no producer runs
|
|
59
|
+
if (
|
|
60
|
+
snapshot.base_oid is not None
|
|
61
|
+
and not filtered_elided
|
|
62
|
+
# A boundary rename empties the filtered diff while concealing a reviewable
|
|
63
|
+
# destination; that run DOES dispatch, so the commit guards still apply.
|
|
64
|
+
and not concealed_destinations(snapshot.diff_text, config.review.strip_repo_context_files)
|
|
65
|
+
):
|
|
66
|
+
return False # no_changes_to_review path — exits 0, no producer runs
|
|
67
|
+
# Exact assembled-prompt check: render the full round-0 prompt and measure it directly.
|
|
68
|
+
# The former template+diff estimate omitted the JSON schema (~517 chars), adversarial-lens
|
|
69
|
+
# block (~3,316 chars), and prior-round sentinel — a lower bound that let the real rendered
|
|
70
|
+
# prompt exceed the provider ceiling (1,048,576 chars) while this predicate returned True,
|
|
71
|
+
# causing commit-safety guards to fire first (dirty-tree or default-branch refusal) instead
|
|
72
|
+
# of the intended prompt_too_large refusal. Fails open on template-load or render errors —
|
|
73
|
+
# the exact check in _run_one_round catches them (though only after commit-safety guards run).
|
|
74
|
+
if repo_root is not None:
|
|
75
|
+
from syncade.findings import get_findings_schema_string
|
|
76
|
+
from syncade.orchestrator.round import _NO_DIFF_SENTINEL
|
|
77
|
+
from syncade.orchestrator.round_no_changes import _CODEX_CHAR_CEILING
|
|
78
|
+
from syncade.prompts import load_reviewer_template_for, render_reviewer_prompt
|
|
79
|
+
|
|
80
|
+
# Compute the worktree-local PR doc reference the same way _build_reviewer_prompt
|
|
81
|
+
# does. Using the real path matters when the template repeats {pr_doc_path} — with
|
|
82
|
+
# the placeholder "<pr-doc>" (8 chars) the rendered size is always lower than the
|
|
83
|
+
# real prompt, so the guard can false-green for a prompt that later refuses.
|
|
84
|
+
if pr_doc_path is not None:
|
|
85
|
+
try:
|
|
86
|
+
_pr_doc_ref = str(pr_doc_path.relative_to(repo_root))
|
|
87
|
+
except ValueError:
|
|
88
|
+
_digest = hashlib.sha256(str(pr_doc_path).encode("utf-8")).hexdigest()[:16]
|
|
89
|
+
_pr_doc_ref = f".syncade-inputs/pr-doc-{_digest}-{pr_doc_path.name}"
|
|
90
|
+
else:
|
|
91
|
+
_pr_doc_ref = "<pr-doc>"
|
|
92
|
+
_diff_text = filtered_elided if filtered_elided else _NO_DIFF_SENTINEL
|
|
93
|
+
_json_schema = get_findings_schema_string()
|
|
94
|
+
for reviewer in config.reviewers:
|
|
95
|
+
try:
|
|
96
|
+
template = load_reviewer_template_for(
|
|
97
|
+
repo_root, provider=reviewer.provider, template=reviewer.template
|
|
98
|
+
)
|
|
99
|
+
rendered = render_reviewer_prompt(
|
|
100
|
+
template,
|
|
101
|
+
pr_doc_path=_pr_doc_ref,
|
|
102
|
+
diff=_diff_text,
|
|
103
|
+
master_plan_path=None,
|
|
104
|
+
json_schema=_json_schema,
|
|
105
|
+
adversarial_lens=reviewer.adversarial_lens,
|
|
106
|
+
bug_class_sweep=reviewer.bug_class_sweep,
|
|
107
|
+
)
|
|
108
|
+
except Exception: # noqa: BLE001 — fail open; exact check runs inside round
|
|
109
|
+
continue
|
|
110
|
+
if len(rendered) > _CODEX_CHAR_CEILING:
|
|
111
|
+
return False # prompt_too_large path — refuses at exit 60, no producer runs
|
|
112
|
+
return True
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Loop finalization.
|
|
2
|
+
|
|
3
|
+
``_finalize_run`` is the post-loop tail of ``run_review``: it writes the
|
|
4
|
+
loop-level artifacts (loop-summary.md / loop-manifest.json / handoff.md),
|
|
5
|
+
records the per-branch last-reviewed SHA, emits the working-tree-not-synced
|
|
6
|
+
note, performs the terminal worktree cleanup-vs-preserve decision, builds the
|
|
7
|
+
aggregate :class:`RunResult`, and returns it.
|
|
8
|
+
|
|
9
|
+
``completed_at`` is computed by the caller and passed in.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from syncade import run_status as _run_status
|
|
18
|
+
from syncade.exit_codes import (
|
|
19
|
+
CLARIFICATION_NEEDED,
|
|
20
|
+
FINDINGS_PRESENT,
|
|
21
|
+
MAX_ROUNDS_REACHED,
|
|
22
|
+
SUCCESS,
|
|
23
|
+
)
|
|
24
|
+
from syncade.git_object_id import is_full_git_object_id
|
|
25
|
+
from syncade.persistence import (
|
|
26
|
+
ProducerArtifactPaths,
|
|
27
|
+
persist_handoff,
|
|
28
|
+
persist_loop_manifest,
|
|
29
|
+
persist_loop_summary,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from .results import RunArtifacts, RunResult
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _warn_branch_advanced(*, branch: str, repo_root, logger) -> None:
|
|
36
|
+
"""Emit the working-tree-not-synced warning after a branch advance.
|
|
37
|
+
|
|
38
|
+
Shared by the normal finalize tail and the early ``test_worktree_error``
|
|
39
|
+
re-raise path so the operator hears the identical message on both (N3).
|
|
40
|
+
|
|
41
|
+
Uses ``logger.safety``, not ``logger.warning``: under ``--quiet`` this was suppressed
|
|
42
|
+
entirely (PR-h-13), and it is the ONLY thing standing between an operator and a
|
|
43
|
+
``git commit`` that silently reverts the producer's work — the tree still holds the
|
|
44
|
+
pre-advance content, so `git status` shows a fully staged revert.
|
|
45
|
+
"""
|
|
46
|
+
logger.safety(
|
|
47
|
+
f"orchestrator: branch refs/heads/{branch} "
|
|
48
|
+
f"has been advanced to the latest producer commit, "
|
|
49
|
+
f"but your working tree at {repo_root} is NOT "
|
|
50
|
+
f"automatically synced. To pick up the producer's fix in your "
|
|
51
|
+
f"working tree: `git reset --hard HEAD` (discards "
|
|
52
|
+
f"any local edits) or `git stash && git reset "
|
|
53
|
+
f"--hard HEAD && git stash pop` (preserves them). "
|
|
54
|
+
f"See loop-summary.md for the commit series and "
|
|
55
|
+
f"per-round artifacts."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cleanup_prior_round_managers(managers, *, current_round_dir) -> None:
|
|
60
|
+
"""Best-effort ``cleanup_all`` of every manager whose worktrees are NOT
|
|
61
|
+
under ``current_round_dir`` (N4).
|
|
62
|
+
|
|
63
|
+
The current round's worktrees are deliberately preserved for diagnostics
|
|
64
|
+
on the ``test_worktree_error`` early-raise path; only the now-superseded
|
|
65
|
+
prior-round reviewer/producer worktrees are reclaimed.
|
|
66
|
+
"""
|
|
67
|
+
for manager in managers:
|
|
68
|
+
run_dir = manager.run_dir
|
|
69
|
+
if run_dir == current_round_dir or current_round_dir in run_dir.parents:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
manager.cleanup_all()
|
|
73
|
+
except Exception:
|
|
74
|
+
# Best-effort: one manager's failure must not block the others
|
|
75
|
+
# or the re-raise of the captured WorktreeError.
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _finalize_run(
|
|
80
|
+
*,
|
|
81
|
+
run_dir,
|
|
82
|
+
run_id,
|
|
83
|
+
repo_root,
|
|
84
|
+
snapshot,
|
|
85
|
+
config,
|
|
86
|
+
pr_doc_path,
|
|
87
|
+
round_results,
|
|
88
|
+
round_artifacts_list,
|
|
89
|
+
final_exit_code,
|
|
90
|
+
termination_reason,
|
|
91
|
+
started_at,
|
|
92
|
+
completed_at,
|
|
93
|
+
branch_advanced_during_run,
|
|
94
|
+
managers_to_cleanup,
|
|
95
|
+
effective_worktree_base,
|
|
96
|
+
logger,
|
|
97
|
+
run_usages=None,
|
|
98
|
+
budget_ceiling=None,
|
|
99
|
+
) -> RunResult:
|
|
100
|
+
"""Write loop-level artifacts, build the aggregate :class:`RunResult`,
|
|
101
|
+
handle last-reviewed recording + worktree cleanup, and return the result.
|
|
102
|
+
|
|
103
|
+
``completed_at`` is supplied by the caller (``run_review``) so the patched
|
|
104
|
+
``datetime.now`` lookup stays in the rebound caller body.
|
|
105
|
+
"""
|
|
106
|
+
# --- Loop terminated --------------------------------------------
|
|
107
|
+
final_round_idx = len(round_results) - 1
|
|
108
|
+
last_round = round_results[-1]
|
|
109
|
+
|
|
110
|
+
# write the loop-level artifacts (top-level
|
|
111
|
+
# loop-summary.md + loop-manifest.json). Always written —
|
|
112
|
+
# operators inspecting a multi-round run want the aggregate
|
|
113
|
+
# view, and even single-pass runs benefit from a top-level
|
|
114
|
+
# summary that doesn't require traversing into round-0/.
|
|
115
|
+
#
|
|
116
|
+
# ``termination_reason`` is always set by here — every break
|
|
117
|
+
# path above assigns it. Defensive default just in case a
|
|
118
|
+
# future code path forgets to.
|
|
119
|
+
safe_termination_reason: str = termination_reason or "unknown"
|
|
120
|
+
try:
|
|
121
|
+
loop_summary_path = persist_loop_summary(
|
|
122
|
+
run_dir,
|
|
123
|
+
final_exit_code=final_exit_code,
|
|
124
|
+
final_round=final_round_idx,
|
|
125
|
+
termination_reason=safe_termination_reason,
|
|
126
|
+
rounds=round_results,
|
|
127
|
+
max_rounds=config.loop.max_rounds,
|
|
128
|
+
started_at=started_at,
|
|
129
|
+
completed_at=completed_at,
|
|
130
|
+
# pass repo_root so the loop-summary
|
|
131
|
+
# commit series can look up producer commit subjects via
|
|
132
|
+
# ``git log -1 --pretty=format:%s <ending_sha>``.
|
|
133
|
+
repo_root=repo_root,
|
|
134
|
+
# PR-v2-11: the configured ceiling(s), so the Budget section on a
|
|
135
|
+
# budget_exceeded run can name them alongside the tally.
|
|
136
|
+
budget_tokens=config.loop.budget_tokens,
|
|
137
|
+
budget_usd=config.loop.budget_usd,
|
|
138
|
+
# The ENFORCEMENT tally itself — fresh on --resume, so the reported Budget number
|
|
139
|
+
# is exactly what tripped, not original+resume re-summed from rehydrated rounds.
|
|
140
|
+
budget_usages=run_usages,
|
|
141
|
+
# Which ceiling crossed, so the Budget section names it (both set → first-to-trip).
|
|
142
|
+
budget_ceiling=budget_ceiling,
|
|
143
|
+
)
|
|
144
|
+
persist_loop_manifest(
|
|
145
|
+
run_dir,
|
|
146
|
+
final_exit_code=final_exit_code,
|
|
147
|
+
final_round=final_round_idx,
|
|
148
|
+
termination_reason=safe_termination_reason,
|
|
149
|
+
rounds=round_results,
|
|
150
|
+
max_rounds=config.loop.max_rounds,
|
|
151
|
+
started_at=started_at,
|
|
152
|
+
producer_provider=config.producer.provider,
|
|
153
|
+
producer_model=config.producer.model,
|
|
154
|
+
)
|
|
155
|
+
# structured operator handoff on exit 20 / exit 30
|
|
156
|
+
# paths. persist_handoff gates internally on exit code +
|
|
157
|
+
# active-blocker count so the call is safe to make
|
|
158
|
+
# unconditionally; returns None when the gate skips.
|
|
159
|
+
persist_handoff(
|
|
160
|
+
run_dir,
|
|
161
|
+
final_exit_code=final_exit_code,
|
|
162
|
+
final_round=final_round_idx,
|
|
163
|
+
termination_reason=safe_termination_reason,
|
|
164
|
+
rounds=round_results,
|
|
165
|
+
max_rounds=config.loop.max_rounds,
|
|
166
|
+
pr_doc_path=pr_doc_path,
|
|
167
|
+
repo_root=repo_root,
|
|
168
|
+
)
|
|
169
|
+
except Exception:
|
|
170
|
+
# Loop-level persistence failures shouldn't mask the
|
|
171
|
+
# actual exit code — the per-round artifacts are already
|
|
172
|
+
# on disk. Log via the existing channel and continue.
|
|
173
|
+
loop_summary_path = None
|
|
174
|
+
logger.warning(
|
|
175
|
+
"orchestrator: loop-level persistence failed (per-round "
|
|
176
|
+
"artifacts are still on disk; the top-level "
|
|
177
|
+
"loop-summary.md / loop-manifest.json / handoff.md may be "
|
|
178
|
+
"missing or partial)"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Build the aggregate RunArtifacts pointing flat fields at the
|
|
182
|
+
# LAST round (back-compat with single-pass RunArtifacts shape).
|
|
183
|
+
# producer_paths is the brief-documented flat list
|
|
184
|
+
# parallel to rounds[] — one entry per round, None when the
|
|
185
|
+
# round didn't run a producer (SHIP rounds + the final round
|
|
186
|
+
# under max_rounds_reached).
|
|
187
|
+
producer_paths_list: list[ProducerArtifactPaths | None] = [
|
|
188
|
+
r.producer_paths for r in round_artifacts_list
|
|
189
|
+
]
|
|
190
|
+
# the run-root findings.md (if it was written) lives
|
|
191
|
+
# at <run_dir>/findings.md regardless of round count.
|
|
192
|
+
run_root_findings = run_dir / "findings.md"
|
|
193
|
+
run_root_findings_path: Path | None = run_root_findings if run_root_findings.is_file() else None
|
|
194
|
+
artifacts = RunArtifacts(
|
|
195
|
+
run_dir=run_dir,
|
|
196
|
+
round_dir=last_round.artifacts.round_dir,
|
|
197
|
+
manifest_path=last_round.artifacts.manifest_path,
|
|
198
|
+
summary_path=last_round.artifacts.summary_path,
|
|
199
|
+
findings_md_path=last_round.artifacts.findings_md_path,
|
|
200
|
+
synthesizer_paths=last_round.artifacts.synthesizer_paths,
|
|
201
|
+
test_run_paths=last_round.artifacts.test_run_paths,
|
|
202
|
+
rounds=round_artifacts_list,
|
|
203
|
+
loop_summary_path=loop_summary_path,
|
|
204
|
+
producer_paths=producer_paths_list,
|
|
205
|
+
run_root_findings_md_path=run_root_findings_path,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
result = RunResult(
|
|
209
|
+
artifacts=artifacts,
|
|
210
|
+
snapshot=last_round.snapshot,
|
|
211
|
+
dispatch_result=last_round.dispatch_result,
|
|
212
|
+
exit_code=final_exit_code,
|
|
213
|
+
synth_result=last_round.synth_result,
|
|
214
|
+
test_result=last_round.test_result,
|
|
215
|
+
test_skip_reason=last_round.test_skip_reason,
|
|
216
|
+
test_worktree_error=last_round.test_worktree_error,
|
|
217
|
+
rounds=round_results,
|
|
218
|
+
final_round=final_round_idx,
|
|
219
|
+
termination_reason=termination_reason,
|
|
220
|
+
# Same values persist_loop_summary renders, so the terminal notice and the artifact
|
|
221
|
+
# cannot disagree about what was spent (PR-h-field-06).
|
|
222
|
+
budget_usages=list(run_usages or []),
|
|
223
|
+
budget_ceiling=budget_ceiling,
|
|
224
|
+
budget_tokens=config.loop.budget_tokens,
|
|
225
|
+
budget_usd=config.loop.budget_usd,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# When the final round had a test-leg worktree provisioning failure, that
|
|
229
|
+
# WorktreeError must be re-raised so the CLI maps to exit 60. The per-round
|
|
230
|
+
# persistence already wrote everything; this is just the bubble-up.
|
|
231
|
+
if last_round.test_worktree_error is not None:
|
|
232
|
+
# The branch-advance warning and the prior-round cleanup both live
|
|
233
|
+
# BELOW this raise, so replay the parts that still apply here:
|
|
234
|
+
# N3 — an earlier round's producer may have advanced the branch; the
|
|
235
|
+
# operator must hear that even on this exit-60 path.
|
|
236
|
+
if snapshot.branch is not None and branch_advanced_during_run:
|
|
237
|
+
_warn_branch_advanced(branch=snapshot.branch, repo_root=repo_root, logger=logger)
|
|
238
|
+
# N4 — the terminal cleanup below would otherwise reclaim every prior
|
|
239
|
+
# round's reviewer/producer worktrees. Do that here, preserving only
|
|
240
|
+
# the current (failed) round's worktrees for diagnostics.
|
|
241
|
+
_cleanup_prior_round_managers(
|
|
242
|
+
managers_to_cleanup,
|
|
243
|
+
current_round_dir=effective_worktree_base / run_id / f"round-{final_round_idx}",
|
|
244
|
+
)
|
|
245
|
+
logger.summary(result)
|
|
246
|
+
# Finalize with the mechanical reason before re-raising so the CLI's
|
|
247
|
+
# typed WorktreeError handler sees _active=None and is a no-op.
|
|
248
|
+
# Without this, status.json would be overwritten as
|
|
249
|
+
# exception:WorktreeError even though loop-manifest already recorded
|
|
250
|
+
# the mechanical termination_reason (worktree_error).
|
|
251
|
+
_run_status.finalize_active(safe_termination_reason, final_exit_code)
|
|
252
|
+
raise last_round.test_worktree_error
|
|
253
|
+
|
|
254
|
+
# --- record the per-branch last-reviewed SHA --------------
|
|
255
|
+
# On a COMPLETED verdict where reviewers actually ran (exits 0/20/30, but NOT
|
|
256
|
+
# no_changes_to_review or producer_emptied_diff which are exit-0 with zero reviewers
|
|
257
|
+
# dispatched), record the SHA a reviewer saw so `--scope since-last-review` bounds its
|
|
258
|
+
# diff to new work. Skipped on phase failures (40/50/60/70), decision_needed (10,
|
|
259
|
+
# resume-pending), detached HEAD (no branch), and empty-diff terminals where no
|
|
260
|
+
# reviewer actually ran (recording an unreviewed anchor would silently skip real work
|
|
261
|
+
# on the next since-last-review diff).
|
|
262
|
+
_skip_reasons = ("no_changes_to_review", "producer_emptied_diff")
|
|
263
|
+
if (
|
|
264
|
+
snapshot.branch is not None
|
|
265
|
+
and final_exit_code in (SUCCESS, MAX_ROUNDS_REACHED, FINDINGS_PRESENT)
|
|
266
|
+
and termination_reason not in _skip_reasons
|
|
267
|
+
):
|
|
268
|
+
# Record the SHA a reviewer ACTUALLY saw, not a re-read of the branch (R6).
|
|
269
|
+
# `last_round.snapshot` is the final round's — NOT the `snapshot` parameter, which
|
|
270
|
+
# is round 0's and differs on any multi-round run.
|
|
271
|
+
#
|
|
272
|
+
# Re-reading `refs/heads/<branch>` returned whatever the ref pointed at when the
|
|
273
|
+
# run ended, which is the reviewed SHA only because the TERMINAL round never runs a
|
|
274
|
+
# producer (loop_round_step.py special-cases `round_idx == max_rounds - 1`). When
|
|
275
|
+
# the ref moved anyway — a `producer_stalled` run whose fast-forward was refused, or
|
|
276
|
+
# an external commit landing mid-run — it recorded a SHA nobody reviewed, and
|
|
277
|
+
# `--scope since-last-review` then SKIPS that unreviewed work. Reading the value
|
|
278
|
+
# already in hand cannot drift.
|
|
279
|
+
_reviewed_sha = last_round.snapshot.commit_sha
|
|
280
|
+
if is_full_git_object_id(_reviewed_sha):
|
|
281
|
+
from syncade.persistence import persist_last_reviewed as _persist_last_reviewed
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
_persist_last_reviewed(
|
|
285
|
+
repo_root,
|
|
286
|
+
branch=snapshot.branch,
|
|
287
|
+
sha=_reviewed_sha,
|
|
288
|
+
run_id=run_id,
|
|
289
|
+
recorded_at_utc=completed_at.isoformat(),
|
|
290
|
+
)
|
|
291
|
+
except OSError as exc:
|
|
292
|
+
# Narrowed from `except Exception: pass` (audit rank 17). Only a real I/O
|
|
293
|
+
# failure is tolerable here — losing last-reviewed costs a wider next diff,
|
|
294
|
+
# never a wrong verdict, so it must not abort a completed run. Anything
|
|
295
|
+
# else (a bug in the writer, a bad SHA) is a defect and must surface.
|
|
296
|
+
#
|
|
297
|
+
# Printed directly to stderr, NOT via logger.warning, which is suppressed
|
|
298
|
+
# in --quiet mode. A persistence failure must be visible regardless of
|
|
299
|
+
# verbosity — the operator needs to know the anchor was not updated.
|
|
300
|
+
print(
|
|
301
|
+
f"[syncade] warning: could not record last-reviewed SHA for "
|
|
302
|
+
f"{snapshot.branch}: {exc}",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
flush=True,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# --- Do NOT touch the operator's working tree -----
|
|
308
|
+
# The design is explicit ("Notes for the implementing agent"):
|
|
309
|
+
#
|
|
310
|
+
# > do not use `git checkout` in the user's working tree
|
|
311
|
+
# > (don't touch their checkout). The worktree's shared
|
|
312
|
+
# > `.git` is the contract surface.
|
|
313
|
+
#
|
|
314
|
+
# Branch advance moves the ref; the operator syncs their working tree
|
|
315
|
+
# themselves using the documented command in the loop summary.
|
|
316
|
+
#
|
|
317
|
+
# The remaining UX gap (operator runs ``cat foo.py`` post-loop
|
|
318
|
+
# and sees old content) is addressed via documentation:
|
|
319
|
+
# loop-summary.md's "Next steps" block names the sync command
|
|
320
|
+
# explicitly on every SHIP termination. Operators handle the
|
|
321
|
+
# checkout themselves — same contract as a remote ``git push``
|
|
322
|
+
# to their branch from elsewhere.
|
|
323
|
+
if snapshot.branch is not None and branch_advanced_during_run:
|
|
324
|
+
_warn_branch_advanced(branch=snapshot.branch, repo_root=repo_root, logger=logger)
|
|
325
|
+
|
|
326
|
+
# terminal cleanup decision. Per the PRD,
|
|
327
|
+
# exits 10/20/30 keep worktrees for inspection; exit 0 +
|
|
328
|
+
# environmental failures (40/50/60/70) clean up. Each
|
|
329
|
+
# WorktreeManager was constructed with defer_cleanup=True so
|
|
330
|
+
# the per-round + per-producer ``with`` blocks didn't
|
|
331
|
+
# auto-clean; the orchestrator now decides based on
|
|
332
|
+
# final_exit_code.
|
|
333
|
+
preserve_worktrees = final_exit_code in (
|
|
334
|
+
CLARIFICATION_NEEDED,
|
|
335
|
+
MAX_ROUNDS_REACHED,
|
|
336
|
+
FINDINGS_PRESENT,
|
|
337
|
+
)
|
|
338
|
+
if not preserve_worktrees:
|
|
339
|
+
for manager in managers_to_cleanup:
|
|
340
|
+
try:
|
|
341
|
+
manager.cleanup_all()
|
|
342
|
+
except Exception:
|
|
343
|
+
# Best-effort: a cleanup failure on one manager
|
|
344
|
+
# doesn't block the others or block the final
|
|
345
|
+
# ``rmdir(<base>/<run_id>/)``. Surface via a
|
|
346
|
+
# generic warning rather than the per-manager
|
|
347
|
+
# stderr the WorktreeManager.cleanup_all already
|
|
348
|
+
# writes; we don't want to double-warn.
|
|
349
|
+
pass
|
|
350
|
+
else:
|
|
351
|
+
# Surface the preservation in the operator's terminal
|
|
352
|
+
# log so they know where to look.
|
|
353
|
+
if managers_to_cleanup:
|
|
354
|
+
logger.warning(
|
|
355
|
+
f"orchestrator: worktrees PRESERVED on disk for "
|
|
356
|
+
f"inspection (exit {final_exit_code} keeps them per "
|
|
357
|
+
f"PRD). Inspect at {effective_worktree_base / run_id}/."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# final cleanup of the per-run worktree-base parent. The
|
|
361
|
+
# per-round WorktreeManagers each cleaned up their round-N
|
|
362
|
+
# subdirs, but the shared ``<worktree_base>/<run_id>/`` parent
|
|
363
|
+
# they were nested under is still on disk.
|
|
364
|
+
#
|
|
365
|
+
# the per-round managers' cleanup_all rmdirs
|
|
366
|
+
# their own ``round-N/`` subdirs, BUT on a NO-SHIP round where
|
|
367
|
+
# the producer also ran, the reviewer-manager's cleanup_all
|
|
368
|
+
# tries to rmdir ``round-N/`` BEFORE the producer-manager
|
|
369
|
+
# cleans up its own ``round-N/producer-worktree/`` — the rmdir
|
|
370
|
+
# fails silently (dir not empty), the producer phase runs and
|
|
371
|
+
# cleans its subdir, but nothing goes back to retry the
|
|
372
|
+
# ``round-N/`` rmdir. Result: empty ``round-N/`` directories
|
|
373
|
+
# accumulate under ``<base>/<run_id>/`` even on successful
|
|
374
|
+
# runs, and the post-loop ``rmdir(<base>/<run_id>/)`` fails
|
|
375
|
+
# because of the empty children.
|
|
376
|
+
#
|
|
377
|
+
# Fix: walk the run_dir bottom-up and rmdir every empty subdir
|
|
378
|
+
# before the final ``rmdir(run_dir)``. Best-effort: any OSError
|
|
379
|
+
# is swallowed (caller may have left intentional state).
|
|
380
|
+
#
|
|
381
|
+
# skipped when ``preserve_worktrees`` is True — the
|
|
382
|
+
# whole point is to leave the dirs on disk for inspection.
|
|
383
|
+
shared_run_dir = effective_worktree_base / run_id
|
|
384
|
+
if not preserve_worktrees and shared_run_dir.exists():
|
|
385
|
+
# Walk bottom-up so deeper empty dirs get rmdir'd first,
|
|
386
|
+
# making their parents potentially empty for the next step.
|
|
387
|
+
for child_dir in sorted(
|
|
388
|
+
(p for p in shared_run_dir.rglob("*") if p.is_dir()),
|
|
389
|
+
key=lambda p: len(p.parts),
|
|
390
|
+
reverse=True,
|
|
391
|
+
):
|
|
392
|
+
try:
|
|
393
|
+
child_dir.rmdir()
|
|
394
|
+
except OSError:
|
|
395
|
+
# Not empty (operator left something) — skip.
|
|
396
|
+
pass
|
|
397
|
+
try:
|
|
398
|
+
shared_run_dir.rmdir()
|
|
399
|
+
except OSError:
|
|
400
|
+
# Not empty; leave it for inspection.
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
logger.summary(result)
|
|
404
|
+
return result
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Run-level commit-safety gates: everything a RUN passes before the loop may spend.
|
|
2
|
+
|
|
3
|
+
The run-level twin of :mod:`syncade.orchestrator.round_predispatch`, split out of ``loop`` in
|
|
4
|
+
PR-h-field-06 after three consecutive dogfoods flagged that module's size while budget state
|
|
5
|
+
was being threaded into it. It was 481 of a 500 code-LOC cap — passing, with 19 lines of
|
|
6
|
+
headroom, which is the position ``producer.py`` was in when the next change broke the gate.
|
|
7
|
+
|
|
8
|
+
**The ORDER is load-bearing**, exactly as it is for the round-level gates: the diff is
|
|
9
|
+
classified FIRST, because a run that cannot dispatch must not be refused for being on the
|
|
10
|
+
default branch or for a dirty tree — there is nothing for it to commit. The block is moved
|
|
11
|
+
intact rather than reassembled for that reason.
|
|
12
|
+
|
|
13
|
+
Refusals RAISE (``WorktreeError``); nothing returns early, which is what makes the extraction
|
|
14
|
+
faithful. The four values the loop needs come back on :class:`RunPreflight`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from syncade.worktree import WorktreeError
|
|
22
|
+
|
|
23
|
+
from .branch_guard import guard_default_branch
|
|
24
|
+
from .loop_dispatch_check import _diff_will_dispatch
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_preflight(
|
|
28
|
+
*,
|
|
29
|
+
config,
|
|
30
|
+
repo_root,
|
|
31
|
+
pr_doc_path,
|
|
32
|
+
snapshot,
|
|
33
|
+
state,
|
|
34
|
+
branch,
|
|
35
|
+
resume_plan,
|
|
36
|
+
logger,
|
|
37
|
+
force_dirty: bool,
|
|
38
|
+
allow_default_branch: bool,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Run every run-level gate, in order. Returns nothing; refusals RAISE.
|
|
41
|
+
|
|
42
|
+
Measured during the extraction: every value this block computes
|
|
43
|
+
(``_will_dispatch``, ``_dirty_state``, ``effective_max_rounds``, ``will_commit``,
|
|
44
|
+
``short_sha``) is used only by the gates themselves — the loop needs none of them
|
|
45
|
+
afterwards. That is what makes this a seam rather than a cut: nothing has to be threaded
|
|
46
|
+
back, so there is no carrier object and no chance of the two halves disagreeing.
|
|
47
|
+
"""
|
|
48
|
+
short_sha = snapshot.commit_sha[:12]
|
|
49
|
+
# --- Pre-classify diff before commit-safety guards (PR-h-02d) ---
|
|
50
|
+
# A known-empty or malformed diff terminates before any subprocess — no producer
|
|
51
|
+
# runs, so commit-only guards are irrelevant and must not refuse valid no-change runs.
|
|
52
|
+
_will_dispatch = _diff_will_dispatch(
|
|
53
|
+
snapshot, config, repo_root=repo_root, pr_doc_path=pr_doc_path
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# --- Pre-flight dirty-tree refusal in loop mode -----------------
|
|
57
|
+
# max_rounds > 1 → the loop will run a producer that commits to
|
|
58
|
+
# the operator's branch. A tracked-modified WIP would race
|
|
59
|
+
# against the producer's writes (the operator's working tree
|
|
60
|
+
# would interleave with new commits in confusing ways).
|
|
61
|
+
# Untracked-only is fine — those files don't enter any
|
|
62
|
+
# worktree, so they're invisible to both reviewers and the
|
|
63
|
+
# producer. The --force-dirty escape hatch is for operators
|
|
64
|
+
# who understand the consequences.
|
|
65
|
+
#
|
|
66
|
+
# max_rounds == 1 is warning-only because it runs reviewers + synth and
|
|
67
|
+
# exits; nothing writes to the operator branch.
|
|
68
|
+
#
|
|
69
|
+
# Resumed loop-mode runs are refused on the same grounds: a resume
|
|
70
|
+
# still runs the producer and commits to the branch, so a dirty WIP
|
|
71
|
+
# races just as it would on a fresh run. --force-dirty is the only
|
|
72
|
+
# escape (resume does not exempt itself).
|
|
73
|
+
# The refusal must use the EFFECTIVE cap: a resume rehydrates
|
|
74
|
+
# max_rounds to max(config, resume_plan.max_rounds) later (see
|
|
75
|
+
# loop_resume._rehydrate_resume_state), so reading the un-bumped config
|
|
76
|
+
# here would let `--resume --max-rounds 1` — or a config drifted to
|
|
77
|
+
# max_rounds=1 — on a multi-round run slip the gate and then race the
|
|
78
|
+
# producer's commit against the dirty WIP (H3).
|
|
79
|
+
effective_max_rounds = config.loop.max_rounds
|
|
80
|
+
if resume_plan is not None:
|
|
81
|
+
effective_max_rounds = max(effective_max_rounds, resume_plan.max_rounds)
|
|
82
|
+
_dirty_state = state in ("tracked", "both")
|
|
83
|
+
if _will_dispatch and effective_max_rounds > 1 and _dirty_state and not force_dirty:
|
|
84
|
+
raise WorktreeError(
|
|
85
|
+
f"uncommitted tracked changes (dirty_state={state!r}); "
|
|
86
|
+
f"loop mode (max_rounds={effective_max_rounds}) "
|
|
87
|
+
f"commits to your branch and would race against this "
|
|
88
|
+
f"WIP. Commit, stash, or pass --force-dirty to override. "
|
|
89
|
+
f"To run single-pass with warning-only tracked changes, set max_rounds=1 in "
|
|
90
|
+
f"[loop] or pass --max-rounds 1."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# --- Default-branch commit guard (PR-v2-26) ---------------------
|
|
94
|
+
# A committing run (loop mode) fast-forwards the CURRENT branch, so refuse the
|
|
95
|
+
# default branch unless the operator opted in, and announce the target branch
|
|
96
|
+
# BEFORE any dispatch. Placed at the run-entry choke so a direct run_review call
|
|
97
|
+
# and a --resume are covered too, not only the CLI wrapper.
|
|
98
|
+
# will_commit is False when no dispatch will happen: no producer runs on a no-change
|
|
99
|
+
# or malformed-diff run, so the default-branch guard and commit announcement are moot.
|
|
100
|
+
will_commit = _will_dispatch and effective_max_rounds > 1
|
|
101
|
+
guard_default_branch(
|
|
102
|
+
repo_root, snapshot.branch, allow=allow_default_branch, will_commit=will_commit
|
|
103
|
+
)
|
|
104
|
+
if will_commit:
|
|
105
|
+
# Printed directly to stderr, NOT via logger.event, so it survives --quiet — the
|
|
106
|
+
# same reason the auth block bypasses quiet. Which branch receives commits is a
|
|
107
|
+
# safety disclosure, and it matters MOST under `--quiet --allow-default-branch`.
|
|
108
|
+
print(
|
|
109
|
+
f"[syncade] producer commits will land on: {snapshot.branch or '(detached HEAD)'}",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Dirty-tree warnings fire on every round-0 snapshot regardless of
|
|
114
|
+
# max_rounds; the loop-mode refusal above is additive.
|
|
115
|
+
if state in ("tracked", "both"):
|
|
116
|
+
logger.warning(
|
|
117
|
+
f"working tree has uncommitted modifications to tracked "
|
|
118
|
+
f"files — reviewers will only see HEAD ({short_sha}); "
|
|
119
|
+
f"your local changes are invisible to them. Commit "
|
|
120
|
+
f"before running syncade if you want them reviewed."
|
|
121
|
+
)
|
|
122
|
+
if state in ("untracked", "both"):
|
|
123
|
+
count = snapshot.untracked_count
|
|
124
|
+
plural = "files" if count != 1 else "file"
|
|
125
|
+
logger.warning(
|
|
126
|
+
f"working tree has untracked files (not reviewed): "
|
|
127
|
+
f"{count} {plural}. These are invisible to reviewers, "
|
|
128
|
+
f"which is usually intentional. Run 'git status' to see them."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# --- Run-directory layout ---------------------------------------
|