harneloop 0.0.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.
- harneloop/__init__.py +3 -0
- harneloop/__main__.py +5 -0
- harneloop/adapters.py +76 -0
- harneloop/attempts.py +259 -0
- harneloop/candidate.py +216 -0
- harneloop/cli.py +635 -0
- harneloop/diagnostics.py +65 -0
- harneloop/environment.py +191 -0
- harneloop/errors.py +2 -0
- harneloop/evidence.py +133 -0
- harneloop/intake.py +163 -0
- harneloop/interactive.py +379 -0
- harneloop/locking.py +52 -0
- harneloop/onboarding.py +176 -0
- harneloop/operational_map.py +119 -0
- harneloop/packaging.py +48 -0
- harneloop/paths.py +56 -0
- harneloop/preferences.py +209 -0
- harneloop/runs.py +177 -0
- harneloop/setup_flow.py +229 -0
- harneloop/state.py +308 -0
- harneloop/target.py +93 -0
- harneloop/templates.py +102 -0
- harneloop/unit.py +154 -0
- harneloop/validation.py +46 -0
- harneloop/versioning.py +264 -0
- harneloop/yamlio.py +41 -0
- harneloop-0.0.2.dist-info/METADATA +424 -0
- harneloop-0.0.2.dist-info/RECORD +33 -0
- harneloop-0.0.2.dist-info/WHEEL +5 -0
- harneloop-0.0.2.dist-info/entry_points.txt +2 -0
- harneloop-0.0.2.dist-info/licenses/LICENSE +201 -0
- harneloop-0.0.2.dist-info/top_level.txt +1 -0
harneloop/__init__.py
ADDED
harneloop/__main__.py
ADDED
harneloop/adapters.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .errors import HarneloopError
|
|
6
|
+
from .state import read_state
|
|
7
|
+
from .versioning import ensure_unit
|
|
8
|
+
from .yamlio import read_yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SUPPORTED_ADAPTERS = {"generic", "codex", "cursor"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def collect_agent_markdown(unit_root: Path) -> str:
|
|
15
|
+
sections: list[str] = []
|
|
16
|
+
unit_agent = unit_root / "UNIT_AGENT.md"
|
|
17
|
+
if unit_agent.exists():
|
|
18
|
+
sections.append(unit_agent.read_text(encoding="utf-8").strip())
|
|
19
|
+
|
|
20
|
+
agent_facing = unit_root / "agent-facing"
|
|
21
|
+
if agent_facing.exists():
|
|
22
|
+
for path in sorted(agent_facing.rglob("*.md")):
|
|
23
|
+
sections.append(path.read_text(encoding="utf-8").strip())
|
|
24
|
+
|
|
25
|
+
return "\n\n---\n\n".join(section for section in sections if section)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def export_body(unit_root: Path, adapter: str) -> str:
|
|
29
|
+
unit_meta = read_yaml(unit_root / "unit.yaml")
|
|
30
|
+
state = read_state(unit_root)
|
|
31
|
+
instructions = collect_agent_markdown(unit_root)
|
|
32
|
+
current_version = unit_meta.get("current_version") or "none"
|
|
33
|
+
|
|
34
|
+
return "\n".join(
|
|
35
|
+
[
|
|
36
|
+
f"# {unit_meta.get('name', unit_meta.get('id', 'Harneloop Unit'))}",
|
|
37
|
+
"",
|
|
38
|
+
f"Export adapter: `{adapter}`",
|
|
39
|
+
f"Unit id: `{unit_meta.get('id', 'unknown')}`",
|
|
40
|
+
f"Current version: `{current_version}`",
|
|
41
|
+
f"Lifecycle state: `{state.get('state', 'unknown')}`",
|
|
42
|
+
"",
|
|
43
|
+
"## Harness Instructions",
|
|
44
|
+
"",
|
|
45
|
+
instructions or "No agent-facing instructions have been promoted yet.",
|
|
46
|
+
"",
|
|
47
|
+
"## Operating Rules",
|
|
48
|
+
"",
|
|
49
|
+
"- Treat these instructions as the promoted harness for this task family.",
|
|
50
|
+
"- Do not edit the source harness unit directly from this export.",
|
|
51
|
+
"- Improvements should be proposed as Harneloop candidates and promoted through evidence gates.",
|
|
52
|
+
"- Runtime traces, raw artifacts, local caches, and secrets are not part of this export.",
|
|
53
|
+
]
|
|
54
|
+
) + "\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def export_unit(unit_root: Path, adapter: str, output: Path | None = None) -> Path:
|
|
58
|
+
unit_root = unit_root.resolve()
|
|
59
|
+
ensure_unit(unit_root)
|
|
60
|
+
if adapter not in SUPPORTED_ADAPTERS:
|
|
61
|
+
supported = ", ".join(sorted(SUPPORTED_ADAPTERS))
|
|
62
|
+
raise HarneloopError(f"Unsupported adapter `{adapter}`. Expected one of: {supported}")
|
|
63
|
+
|
|
64
|
+
export_root = (output or (unit_root / "exports" / adapter)).resolve()
|
|
65
|
+
export_root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
body = export_body(unit_root, adapter)
|
|
67
|
+
|
|
68
|
+
if adapter == "codex":
|
|
69
|
+
(export_root / "AGENTS.md").write_text(body, encoding="utf-8", newline="\n")
|
|
70
|
+
elif adapter == "cursor":
|
|
71
|
+
cursor_body = "---\ndescription: Harneloop exported harness unit\nalwaysApply: false\n---\n\n" + body
|
|
72
|
+
(export_root / "harneloop-unit.mdc").write_text(cursor_body, encoding="utf-8", newline="\n")
|
|
73
|
+
else:
|
|
74
|
+
(export_root / "UNIT_AGENT.md").write_text(body, encoding="utf-8", newline="\n")
|
|
75
|
+
|
|
76
|
+
return export_root
|
harneloop/attempts.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import HarneloopError
|
|
7
|
+
from .locking import file_lock, harness_lock_path
|
|
8
|
+
from .runs import read_run, write_run
|
|
9
|
+
from .state import now_iso, update_state
|
|
10
|
+
from .versioning import ensure_unit
|
|
11
|
+
from .yamlio import read_yaml, write_yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_list(value: list[str] | tuple[str, ...] | None) -> list[str]:
|
|
15
|
+
return [item for item in (value or []) if item]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def attempts_root(unit_root: Path) -> Path:
|
|
19
|
+
return unit_root / "attempts"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def next_attempt_id(unit_root: Path) -> str:
|
|
23
|
+
root = attempts_root(unit_root)
|
|
24
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
existing: list[int] = []
|
|
26
|
+
for path in root.iterdir():
|
|
27
|
+
if path.is_dir() and path.name.startswith("attempt-"):
|
|
28
|
+
try:
|
|
29
|
+
existing.append(int(path.name.removeprefix("attempt-")))
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
return f"attempt-{(max(existing, default=0) + 1):04d}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def attempt_path(unit_root: Path, attempt_id: str) -> Path:
|
|
36
|
+
path = attempts_root(unit_root) / attempt_id
|
|
37
|
+
if not path.exists():
|
|
38
|
+
raise HarneloopError(f"Attempt plan does not exist: {attempt_id}")
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_attempt(unit_root: Path, attempt_id: str) -> dict[str, Any]:
|
|
43
|
+
return read_yaml(attempt_path(unit_root, attempt_id) / "attempt.yaml")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_attempt(unit_root: Path, attempt_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
write_yaml(attempt_path(unit_root, attempt_id) / "attempt.yaml", data)
|
|
48
|
+
write_observations_markdown(unit_root, attempt_id, data)
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_attempt_plan(
|
|
53
|
+
unit_root: Path,
|
|
54
|
+
goal: str,
|
|
55
|
+
method: str,
|
|
56
|
+
action: list[str] | tuple[str, ...] | None = None,
|
|
57
|
+
expected_artifact: list[str] | tuple[str, ...] | None = None,
|
|
58
|
+
success_check: list[str] | tuple[str, ...] | None = None,
|
|
59
|
+
note: list[str] | tuple[str, ...] | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
unit_root = unit_root.resolve()
|
|
62
|
+
ensure_unit(unit_root)
|
|
63
|
+
if not goal.strip():
|
|
64
|
+
raise HarneloopError("Attempt goal cannot be empty")
|
|
65
|
+
if not method.strip():
|
|
66
|
+
raise HarneloopError("Attempt method cannot be empty")
|
|
67
|
+
|
|
68
|
+
with file_lock(harness_lock_path(unit_root, "attempts")):
|
|
69
|
+
attempt_id = next_attempt_id(unit_root)
|
|
70
|
+
root = attempts_root(unit_root) / attempt_id
|
|
71
|
+
root.mkdir(parents=True, exist_ok=False)
|
|
72
|
+
data: dict[str, Any] = {
|
|
73
|
+
"schema_version": "0.1",
|
|
74
|
+
"id": attempt_id,
|
|
75
|
+
"goal": goal,
|
|
76
|
+
"method": method,
|
|
77
|
+
"actions": normalize_list(action),
|
|
78
|
+
"expected_artifacts": normalize_list(expected_artifact),
|
|
79
|
+
"success_checks": normalize_list(success_check),
|
|
80
|
+
"notes": normalize_list(note),
|
|
81
|
+
"observations": [],
|
|
82
|
+
"conclusions": [],
|
|
83
|
+
"created_at": now_iso(),
|
|
84
|
+
}
|
|
85
|
+
write_attempt(unit_root, attempt_id, data)
|
|
86
|
+
update_state(
|
|
87
|
+
unit_root,
|
|
88
|
+
reason="attempt_plan_created",
|
|
89
|
+
next_action=f"Execute `{attempt_id}` with the target agent/tools, then start a run and capture artifacts.",
|
|
90
|
+
)
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_attempt_observation(
|
|
95
|
+
unit_root: Path,
|
|
96
|
+
attempt_id: str,
|
|
97
|
+
summary: str,
|
|
98
|
+
outcome: str = "unknown",
|
|
99
|
+
run_id: str | None = None,
|
|
100
|
+
finding: list[str] | tuple[str, ...] | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
if not summary.strip():
|
|
103
|
+
raise HarneloopError("Observation summary cannot be empty")
|
|
104
|
+
unit_root = unit_root.resolve()
|
|
105
|
+
with file_lock(harness_lock_path(unit_root, f"attempt-{attempt_id}")):
|
|
106
|
+
data = read_attempt(unit_root, attempt_id)
|
|
107
|
+
observations = data.get("observations") or []
|
|
108
|
+
observation = {
|
|
109
|
+
"id": f"observation-{len(observations) + 1:04d}",
|
|
110
|
+
"summary": summary,
|
|
111
|
+
"outcome": outcome,
|
|
112
|
+
"run_id": run_id,
|
|
113
|
+
"findings": normalize_list(finding),
|
|
114
|
+
"created_at": now_iso(),
|
|
115
|
+
}
|
|
116
|
+
data["observations"] = [*observations, observation]
|
|
117
|
+
write_attempt(unit_root, attempt_id, data)
|
|
118
|
+
update_state(
|
|
119
|
+
unit_root,
|
|
120
|
+
reason="attempt_observation_added",
|
|
121
|
+
next_action="Use observations to create evidence, candidate changes, or another attempt plan.",
|
|
122
|
+
)
|
|
123
|
+
return observation
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
VALID_CONCLUSION_OUTCOMES = {"pass", "partial", "fail", "inconclusive"}
|
|
127
|
+
VALID_CONCLUSION_DECISIONS = {"accept", "create_candidate", "rerun", "request_input", "stop"}
|
|
128
|
+
VALID_CONFIDENCE = {"low", "medium", "high"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def conclude_attempt(
|
|
132
|
+
unit_root: Path,
|
|
133
|
+
attempt_id: str,
|
|
134
|
+
*,
|
|
135
|
+
run_id: str,
|
|
136
|
+
outcome: str,
|
|
137
|
+
decision: str,
|
|
138
|
+
summary: str,
|
|
139
|
+
confidence: str,
|
|
140
|
+
question: str | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
if outcome not in VALID_CONCLUSION_OUTCOMES:
|
|
143
|
+
raise HarneloopError(f"Invalid attempt outcome: {outcome}")
|
|
144
|
+
if decision not in VALID_CONCLUSION_DECISIONS:
|
|
145
|
+
raise HarneloopError(f"Invalid attempt decision: {decision}")
|
|
146
|
+
if confidence not in VALID_CONFIDENCE:
|
|
147
|
+
raise HarneloopError(f"Invalid conclusion confidence: {confidence}")
|
|
148
|
+
if not summary.strip():
|
|
149
|
+
raise HarneloopError("Attempt conclusion summary cannot be empty")
|
|
150
|
+
if decision == "request_input" and not (question or "").strip():
|
|
151
|
+
raise HarneloopError("The `request_input` decision requires a concrete question")
|
|
152
|
+
if decision == "accept" and outcome != "pass":
|
|
153
|
+
raise HarneloopError("Only a passing attempt can be accepted without further work")
|
|
154
|
+
|
|
155
|
+
unit_root = unit_root.resolve()
|
|
156
|
+
run = read_run(unit_root, run_id)
|
|
157
|
+
if run.get("status") == "running":
|
|
158
|
+
raise HarneloopError(f"Run must be finished before evaluation: {run_id}")
|
|
159
|
+
if run.get("attempt_id") != attempt_id:
|
|
160
|
+
raise HarneloopError(f"Run `{run_id}` is not linked to attempt `{attempt_id}`")
|
|
161
|
+
|
|
162
|
+
with file_lock(harness_lock_path(unit_root, f"attempt-{attempt_id}")):
|
|
163
|
+
data = read_attempt(unit_root, attempt_id)
|
|
164
|
+
expected = data.get("expected_artifacts") or []
|
|
165
|
+
captured = {artifact.get("kind") for artifact in (run.get("artifacts") or [])}
|
|
166
|
+
missing = [kind for kind in expected if kind not in captured]
|
|
167
|
+
if outcome == "pass" and missing:
|
|
168
|
+
missing_text = ", ".join(missing)
|
|
169
|
+
raise HarneloopError(f"Passing conclusion is missing expected artifacts: {missing_text}")
|
|
170
|
+
|
|
171
|
+
conclusions = data.get("conclusions") or []
|
|
172
|
+
conclusion = {
|
|
173
|
+
"id": f"conclusion-{len(conclusions) + 1:04d}",
|
|
174
|
+
"run_id": run_id,
|
|
175
|
+
"outcome": outcome,
|
|
176
|
+
"decision": decision,
|
|
177
|
+
"summary": summary.strip(),
|
|
178
|
+
"confidence": confidence,
|
|
179
|
+
"captured_artifact_kinds": sorted(kind for kind in captured if kind),
|
|
180
|
+
"missing_expected_artifacts": missing,
|
|
181
|
+
"question": (question or "").strip() or None,
|
|
182
|
+
"created_at": now_iso(),
|
|
183
|
+
}
|
|
184
|
+
data["conclusions"] = [*conclusions, conclusion]
|
|
185
|
+
write_attempt(unit_root, attempt_id, data)
|
|
186
|
+
|
|
187
|
+
run["evaluation_status"] = "completed"
|
|
188
|
+
run["evaluation_outcome"] = outcome
|
|
189
|
+
write_run(unit_root, run_id, run)
|
|
190
|
+
|
|
191
|
+
if decision == "accept":
|
|
192
|
+
update_state(
|
|
193
|
+
unit_root,
|
|
194
|
+
state="satisfied",
|
|
195
|
+
reason="current_harness_accepted",
|
|
196
|
+
next_action="No candidate is needed for the current evidence. Resume only for broader tests or a changed goal.",
|
|
197
|
+
)
|
|
198
|
+
elif decision == "create_candidate":
|
|
199
|
+
update_state(
|
|
200
|
+
unit_root,
|
|
201
|
+
state="active",
|
|
202
|
+
reason="candidate_change_needed",
|
|
203
|
+
next_action=f"Create a candidate from conclusion `{conclusion['id']}` and retest it.",
|
|
204
|
+
)
|
|
205
|
+
elif decision == "rerun":
|
|
206
|
+
update_state(
|
|
207
|
+
unit_root,
|
|
208
|
+
state="active",
|
|
209
|
+
reason="attempt_rerun_needed",
|
|
210
|
+
next_action="Revise the attempt or evidence plan and run it again before changing the harness.",
|
|
211
|
+
)
|
|
212
|
+
elif decision == "request_input":
|
|
213
|
+
update_state(
|
|
214
|
+
unit_root,
|
|
215
|
+
state="waiting",
|
|
216
|
+
reason="user_input_required",
|
|
217
|
+
next_action=question.strip(),
|
|
218
|
+
resume_condition="The user provides or delegates the requested context or evidence.",
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
update_state(
|
|
222
|
+
unit_root,
|
|
223
|
+
state="stopped",
|
|
224
|
+
reason="attempt_conclusion_stopped",
|
|
225
|
+
next_action="Resume only when the documented stopping reason changes.",
|
|
226
|
+
)
|
|
227
|
+
return conclusion
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_observations_markdown(unit_root: Path, attempt_id: str, data: dict[str, Any]) -> None:
|
|
231
|
+
lines = [
|
|
232
|
+
f"# Observations For {attempt_id}",
|
|
233
|
+
"",
|
|
234
|
+
f"Goal: {data.get('goal')}",
|
|
235
|
+
"",
|
|
236
|
+
]
|
|
237
|
+
observations = data.get("observations") or []
|
|
238
|
+
if not observations:
|
|
239
|
+
lines.append("No observations recorded yet.")
|
|
240
|
+
for observation in observations:
|
|
241
|
+
lines.extend(
|
|
242
|
+
[
|
|
243
|
+
f"## {observation.get('id')}",
|
|
244
|
+
"",
|
|
245
|
+
f"- Outcome: `{observation.get('outcome')}`",
|
|
246
|
+
f"- Run: `{observation.get('run_id') or 'none'}`",
|
|
247
|
+
f"- Summary: {observation.get('summary')}",
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
findings = observation.get("findings") or []
|
|
251
|
+
if findings:
|
|
252
|
+
lines.extend(["", "Findings:"])
|
|
253
|
+
lines.extend(f"- {item}" for item in findings)
|
|
254
|
+
lines.append("")
|
|
255
|
+
(attempt_path(unit_root, attempt_id) / "OBSERVATIONS.md").write_text(
|
|
256
|
+
"\n".join(lines).rstrip() + "\n",
|
|
257
|
+
encoding="utf-8",
|
|
258
|
+
newline="\n",
|
|
259
|
+
)
|
harneloop/candidate.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .errors import HarneloopError
|
|
6
|
+
from .locking import file_lock, harness_lock_path
|
|
7
|
+
from .state import now_iso, update_state
|
|
8
|
+
from .yamlio import read_yaml, write_yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CANDIDATE_PLANES = {"target_harness", "evaluation", "infrastructure", "mixed"}
|
|
12
|
+
VALIDATION_TIERS = ("structural", "targeted", "representative", "full")
|
|
13
|
+
VALIDATION_TIER_RANK = {tier: index for index, tier in enumerate(VALIDATION_TIERS)}
|
|
14
|
+
OPEN_CANDIDATE_STATUSES = {"draft", "accumulating", "ready", "validating", "needs_rebase"}
|
|
15
|
+
TERMINAL_CANDIDATE_STATUSES = {"promoted", "rejected"}
|
|
16
|
+
ALLOWED_STATUS_TRANSITIONS = {
|
|
17
|
+
"draft": {"accumulating", "ready", "rejected"},
|
|
18
|
+
"accumulating": {"ready", "rejected"},
|
|
19
|
+
"ready": {"accumulating", "validating", "rejected"},
|
|
20
|
+
"validating": {"accumulating", "ready", "rejected"},
|
|
21
|
+
"needs_rebase": {"rejected"},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_plane(plane: str) -> str:
|
|
26
|
+
if plane not in CANDIDATE_PLANES:
|
|
27
|
+
supported = ", ".join(sorted(CANDIDATE_PLANES))
|
|
28
|
+
raise HarneloopError(f"Unknown candidate plane `{plane}`. Expected one of: {supported}")
|
|
29
|
+
return plane
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_tier(validation_tier: str) -> str:
|
|
33
|
+
if validation_tier not in VALIDATION_TIER_RANK:
|
|
34
|
+
supported = ", ".join(VALIDATION_TIERS)
|
|
35
|
+
raise HarneloopError(f"Unknown validation tier `{validation_tier}`. Expected one of: {supported}")
|
|
36
|
+
return validation_tier
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def candidate_path(unit_root: Path, candidate_id: str) -> Path:
|
|
40
|
+
path = unit_root.resolve() / "candidates" / candidate_id
|
|
41
|
+
if not path.exists():
|
|
42
|
+
raise HarneloopError(f"Candidate does not exist: {candidate_id}")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_candidate(unit_root: Path, candidate_id: str) -> dict[str, object]:
|
|
47
|
+
return read_yaml(candidate_path(unit_root, candidate_id) / "candidate.yaml")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_candidates(unit_root: Path, include_closed: bool = True) -> list[dict[str, object]]:
|
|
51
|
+
candidates_root = unit_root.resolve() / "candidates"
|
|
52
|
+
if not candidates_root.exists():
|
|
53
|
+
return []
|
|
54
|
+
records = [read_yaml(path / "candidate.yaml") for path in sorted(candidates_root.glob("cand-*")) if path.is_dir()]
|
|
55
|
+
records = [record for record in records if record]
|
|
56
|
+
if not include_closed:
|
|
57
|
+
records = [record for record in records if record.get("status") in OPEN_CANDIDATE_STATUSES]
|
|
58
|
+
return records
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def active_candidate_ids(unit_root: Path) -> list[str]:
|
|
62
|
+
return [str(record["id"]) for record in list_candidates(unit_root, include_closed=False)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sync_candidate_state(unit_root: Path, focused_candidate: str | None, reason: str, next_action: str) -> None:
|
|
66
|
+
active = active_candidate_ids(unit_root)
|
|
67
|
+
focused = focused_candidate if focused_candidate in active else (active[-1] if active else None)
|
|
68
|
+
update_state(
|
|
69
|
+
unit_root,
|
|
70
|
+
state="active",
|
|
71
|
+
active_candidates=active,
|
|
72
|
+
active_candidate=focused,
|
|
73
|
+
reason=reason,
|
|
74
|
+
next_action=next_action,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def next_candidate_id(unit_root: Path) -> str:
|
|
79
|
+
candidates_root = unit_root / "candidates"
|
|
80
|
+
candidates_root.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
existing = []
|
|
82
|
+
for path in candidates_root.iterdir():
|
|
83
|
+
if path.is_dir() and path.name.startswith("cand-"):
|
|
84
|
+
try:
|
|
85
|
+
existing.append(int(path.name.removeprefix("cand-")))
|
|
86
|
+
except ValueError:
|
|
87
|
+
continue
|
|
88
|
+
return f"cand-{(max(existing, default=0) + 1):04d}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_candidate(
|
|
92
|
+
unit_root: Path,
|
|
93
|
+
summary: str,
|
|
94
|
+
kind: str = "mixed",
|
|
95
|
+
*,
|
|
96
|
+
plane: str | None = None,
|
|
97
|
+
validation_tier: str = "targeted",
|
|
98
|
+
) -> Path:
|
|
99
|
+
unit_root = unit_root.resolve()
|
|
100
|
+
if not (unit_root / "unit.yaml").exists():
|
|
101
|
+
raise HarneloopError(f"Not a Harneloop harness unit: {unit_root}")
|
|
102
|
+
selected_plane = validate_plane(plane or (kind if kind in CANDIDATE_PLANES else "mixed"))
|
|
103
|
+
selected_tier = validate_tier(validation_tier)
|
|
104
|
+
|
|
105
|
+
with file_lock(harness_lock_path(unit_root, "candidates")):
|
|
106
|
+
candidate_id = next_candidate_id(unit_root)
|
|
107
|
+
candidate_root = unit_root / "candidates" / candidate_id
|
|
108
|
+
candidate_root.mkdir(parents=True, exist_ok=False)
|
|
109
|
+
for directory in ["changes", "validation", "evidence"]:
|
|
110
|
+
(candidate_root / directory).mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
unit_meta = read_yaml(unit_root / "unit.yaml")
|
|
113
|
+
write_yaml(
|
|
114
|
+
candidate_root / "candidate.yaml",
|
|
115
|
+
{
|
|
116
|
+
"schema_version": "0.2",
|
|
117
|
+
"id": candidate_id,
|
|
118
|
+
"base_version": unit_meta.get("current_version"),
|
|
119
|
+
"kind": kind,
|
|
120
|
+
"plane": selected_plane,
|
|
121
|
+
"validation_tier": selected_tier,
|
|
122
|
+
"status": "accumulating",
|
|
123
|
+
"summary": summary,
|
|
124
|
+
"created_at": now_iso(),
|
|
125
|
+
"changes": {"mode": "overlay", "path": "changes/"},
|
|
126
|
+
"evidence_required": ["rationale", "validation_notes"],
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
(candidate_root / "rationale.md").write_text(
|
|
130
|
+
f"# Rationale\n\n{summary}\n", encoding="utf-8", newline="\n"
|
|
131
|
+
)
|
|
132
|
+
(candidate_root / "notes.md").write_text("# Notes\n", encoding="utf-8", newline="\n")
|
|
133
|
+
|
|
134
|
+
_sync_candidate_state(
|
|
135
|
+
unit_root,
|
|
136
|
+
focused_candidate=candidate_id,
|
|
137
|
+
reason="candidate_created",
|
|
138
|
+
next_action=f"Accumulate related changes in `{candidate_id}` and mark it ready when its validation is sufficient.",
|
|
139
|
+
)
|
|
140
|
+
return candidate_root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def set_candidate_status(unit_root: Path, candidate_id: str, status: str) -> dict[str, object]:
|
|
144
|
+
if status not in set().union(*ALLOWED_STATUS_TRANSITIONS.values()):
|
|
145
|
+
supported = ", ".join(sorted(set().union(*ALLOWED_STATUS_TRANSITIONS.values())))
|
|
146
|
+
raise HarneloopError(f"Unsupported candidate status `{status}`. Expected one of: {supported}")
|
|
147
|
+
unit_root = unit_root.resolve()
|
|
148
|
+
with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
|
|
149
|
+
path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
|
|
150
|
+
record = read_yaml(path)
|
|
151
|
+
current = str(record.get("status", "draft"))
|
|
152
|
+
allowed = ALLOWED_STATUS_TRANSITIONS.get(current, set())
|
|
153
|
+
if status not in allowed:
|
|
154
|
+
raise HarneloopError(f"Candidate `{candidate_id}` cannot transition from `{current}` to `{status}`")
|
|
155
|
+
record["status"] = status
|
|
156
|
+
record["status_updated_at"] = now_iso()
|
|
157
|
+
write_yaml(path, record)
|
|
158
|
+
|
|
159
|
+
_sync_candidate_state(
|
|
160
|
+
unit_root,
|
|
161
|
+
focused_candidate=candidate_id if status != "rejected" else None,
|
|
162
|
+
reason=f"candidate_{status}",
|
|
163
|
+
next_action=(
|
|
164
|
+
f"Validate and promote `{candidate_id}` when its required evidence is attached."
|
|
165
|
+
if status in {"ready", "validating"}
|
|
166
|
+
else "Continue the remaining candidate work or create another coherent candidate."
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
return record
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def rebase_candidate(unit_root: Path, candidate_id: str) -> dict[str, object]:
|
|
173
|
+
unit_root = unit_root.resolve()
|
|
174
|
+
with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
|
|
175
|
+
path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
|
|
176
|
+
record = read_yaml(path)
|
|
177
|
+
status = str(record.get("status", "draft"))
|
|
178
|
+
if status in TERMINAL_CANDIDATE_STATUSES:
|
|
179
|
+
raise HarneloopError(f"Closed candidate cannot be rebased: {candidate_id}")
|
|
180
|
+
current_version = read_yaml(unit_root / "unit.yaml").get("current_version")
|
|
181
|
+
previous_base = record.get("base_version")
|
|
182
|
+
if previous_base == current_version and status != "needs_rebase":
|
|
183
|
+
raise HarneloopError(f"Candidate `{candidate_id}` already uses the current base version")
|
|
184
|
+
history = list(record.get("rebase_history") or [])
|
|
185
|
+
history.append({"from": previous_base, "to": current_version, "rebased_at": now_iso()})
|
|
186
|
+
record["base_version"] = current_version
|
|
187
|
+
record["status"] = "accumulating"
|
|
188
|
+
record["rebase_history"] = history
|
|
189
|
+
record["status_updated_at"] = now_iso()
|
|
190
|
+
write_yaml(path, record)
|
|
191
|
+
|
|
192
|
+
_sync_candidate_state(
|
|
193
|
+
unit_root,
|
|
194
|
+
focused_candidate=candidate_id,
|
|
195
|
+
reason="candidate_rebased",
|
|
196
|
+
next_action=f"Revalidate `{candidate_id}` against base version `{current_version or 'none'}` before promotion.",
|
|
197
|
+
)
|
|
198
|
+
return record
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def mark_parallel_candidates_needing_rebase(unit_root: Path, promoted_id: str, new_version: str) -> list[str]:
|
|
202
|
+
stale: list[str] = []
|
|
203
|
+
for record in list_candidates(unit_root, include_closed=False):
|
|
204
|
+
candidate_id = str(record.get("id"))
|
|
205
|
+
if candidate_id == promoted_id or record.get("base_version") == new_version:
|
|
206
|
+
continue
|
|
207
|
+
with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
|
|
208
|
+
path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
|
|
209
|
+
current = read_yaml(path)
|
|
210
|
+
if current.get("status") not in OPEN_CANDIDATE_STATUSES:
|
|
211
|
+
continue
|
|
212
|
+
current["status"] = "needs_rebase"
|
|
213
|
+
current["status_updated_at"] = now_iso()
|
|
214
|
+
write_yaml(path, current)
|
|
215
|
+
stale.append(candidate_id)
|
|
216
|
+
return stale
|