devcouncil 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -0,0 +1,673 @@
1
+ """SkillOpt: a text-space optimization loop for DevCouncil skill + guidance documents.
2
+
3
+ This is a DevCouncil-native implementation of the loop popularized by Microsoft
4
+ SkillOpt (https://github.com/microsoft/SkillOpt): a document is treated as the
5
+ trainable state of a frozen agent and improved over epochs by
6
+
7
+ rollout -> reflect -> aggregate -> propose-edits -> update -> validate -> evaluate
8
+
9
+ Unlike weight-space training, the only thing that changes is markdown text. DevCouncil
10
+ has **two** such artifacts that steer a coding agent: the *guidance* (an agent
11
+ profile's prompt preamble) and the *skill* document. They are optimized **together,
12
+ simultaneously** — each epoch the optimizer proposes a single batch of bounded edits
13
+ that may touch either document, the combined candidate runs the rollout, and the
14
+ *whole* candidate is accepted only when it strictly improves a held-out validation
15
+ score (the validation gate). Co-optimizing them in one loop keeps guidance and skill
16
+ mutually consistent instead of drifting apart across two separate runs.
17
+
18
+ Each epoch a *target* agent runs the current documents on the training tasks
19
+ (rollout), the trajectories are scored (reflect), the low-scoring cases are aggregated
20
+ into feedback, and an *optimizer* model proposes a small number of add/delete/replace
21
+ edits across both documents. Rejected edits are remembered so the optimizer doesn't
22
+ re-propose them.
23
+
24
+ Both the rollout and the edit proposer are pluggable callables so the loop is fully
25
+ testable without a live model; :func:`make_llm_rollout` and :func:`make_llm_optimizer`
26
+ provide the default :class:`~devcouncil.llm.router.ModelRouter`-backed implementations.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import hashlib
33
+ import json
34
+ import logging
35
+ from collections.abc import Awaitable, Callable
36
+ from dataclasses import asdict, dataclass, field
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Literal
40
+
41
+ from pydantic import BaseModel, Field
42
+
43
+ # Reuse the small list-coercion helper from the agent-profile optimizer so both
44
+ # optimizers share one eval-dataset field contract (required_terms, forbidden_terms…).
45
+ from devcouncil.optimization.gepa_agent import _string_list
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # The two co-optimized document slots. ``guidance`` is the agent profile preamble that
50
+ # steers the agent; ``skill`` is the engineering skill document. Edits without an
51
+ # explicit target default to ``skill``.
52
+ GUIDANCE: Literal["guidance"] = "guidance"
53
+ SKILL: Literal["skill"] = "skill"
54
+ DOC_TARGETS = (GUIDANCE, SKILL)
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Edit model — the optimizer's bounded action space (add / delete / replace),
58
+ # targeting either the guidance or the skill document.
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ class SkillEdit(BaseModel):
63
+ """One bounded edit to a co-optimized document body.
64
+
65
+ ``target`` selects which document the edit applies to (``guidance`` or ``skill``).
66
+ ``replace`` swaps the first occurrence of ``find`` with ``text``; ``delete``
67
+ removes the first occurrence of ``find``; ``add`` inserts ``text`` after the
68
+ first occurrence of ``find`` (or appends it when ``find`` is empty). Anchoring on
69
+ existing text keeps every edit local and reviewable, which is what lets the
70
+ validation gate attribute a score change to a specific batch of edits.
71
+ """
72
+
73
+ op: Literal["add", "delete", "replace"]
74
+ target: Literal["guidance", "skill"] = SKILL
75
+ find: str = ""
76
+ text: str = ""
77
+ reason: str = ""
78
+
79
+
80
+ class SkillEditProposal(BaseModel):
81
+ """A batch of edits proposed by the optimizer for one epoch, spanning both docs."""
82
+
83
+ edits: list[SkillEdit] = Field(default_factory=list)
84
+ rationale: str = ""
85
+
86
+
87
+ def _edit_signature(edit: SkillEdit) -> str:
88
+ """Stable identity for the rejected-edit buffer (target + op + anchor + payload).
89
+
90
+ Fields are JSON-encoded rather than space-joined so distinct edits can't collide
91
+ on an ambiguous field boundary (e.g. find='a', text='b c' vs find='a b', text='c').
92
+ A collision would let a rejected edit silently suppress a different, untried one.
93
+ """
94
+ raw = json.dumps([edit.target, edit.op, edit.find.strip(), edit.text.strip()])
95
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
96
+
97
+
98
+ def _batch_signature(edits: list[SkillEdit]) -> str:
99
+ """Order-independent identity for a *combination* of edits.
100
+
101
+ When the validation gate rejects a multi-edit batch, only this combined signature is
102
+ blacklisted — not each component — so a genuinely good edit that was merely dragged
103
+ below the gate by a bad partner stays eligible to be re-proposed in a different
104
+ combination. The select step uses it to avoid re-applying the identical losing batch."""
105
+ raw = json.dumps(sorted(_edit_signature(edit) for edit in edits))
106
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
107
+
108
+
109
+ def _apply_one(body: str, edit: SkillEdit) -> str | None:
110
+ """Apply a single edit to one document body; return new body or None if no-op."""
111
+ find = edit.find
112
+ if edit.op == "add":
113
+ text = edit.text.strip("\n")
114
+ if not text:
115
+ return None
116
+ if find and find in body:
117
+ idx = body.index(find) + len(find)
118
+ candidate = body[:idx] + "\n\n" + text + body[idx:]
119
+ elif not find:
120
+ candidate = body.rstrip("\n") + "\n\n" + text + "\n"
121
+ else:
122
+ return None # anchor requested but absent -> skip
123
+ elif edit.op == "delete":
124
+ if not find or find not in body:
125
+ return None
126
+ candidate = body.replace(find, "", 1)
127
+ else: # replace
128
+ if not find or find not in body:
129
+ return None
130
+ candidate = body.replace(find, edit.text, 1)
131
+ return None if candidate == body else candidate
132
+
133
+
134
+ def _is_permanently_redundant(edit: SkillEdit, docs: dict[str, str]) -> bool:
135
+ """Whether an edit no-ops for a reason that can never change as the docs evolve.
136
+
137
+ Used to decide what the no-op branch may add to the rejected-edit buffer: an empty
138
+ append or an identity replace will *always* no-op and is safe to blacklist forever.
139
+ An edit that no-ops only because its ``find`` anchor isn't present *yet* is NOT
140
+ redundant — a later accepted edit may introduce that anchor — so it stays eligible.
141
+ """
142
+ body = docs.get(edit.target)
143
+ if body is None:
144
+ return False
145
+ if edit.op == "add":
146
+ return not edit.text.strip("\n") # only an empty append is permanently a no-op
147
+ if edit.op == "replace":
148
+ return bool(edit.find) and edit.find in body and edit.find == edit.text
149
+ return False # a delete only no-ops when its anchor is absent (may appear later)
150
+
151
+
152
+ def apply_edits(docs: dict[str, str], edits: list[SkillEdit]) -> tuple[dict[str, str], list[SkillEdit]]:
153
+ """Apply ``edits`` to the right document in ``docs``, skipping ones that no-op.
154
+
155
+ Returns updated copies of the document bodies and the subset of edits that
156
+ actually changed something. A hallucinated anchor (or an edit targeting a missing
157
+ document) is skipped rather than corrupting the documents.
158
+ """
159
+ updated = dict(docs)
160
+ applied: list[SkillEdit] = []
161
+ for edit in edits:
162
+ if edit.target not in updated:
163
+ continue
164
+ result = _apply_one(updated[edit.target], edit)
165
+ if result is None:
166
+ continue
167
+ updated[edit.target] = result
168
+ applied.append(edit)
169
+ return updated, applied
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Loop configuration and records.
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class SkillOptConfig:
179
+ """Hyper-parameters for the SkillOpt loop.
180
+
181
+ ``max_edits_per_epoch`` is the textual learning-rate budget — the cap on how many
182
+ edits may land in a single epoch (across both documents combined). ``min_improvement``
183
+ is the validation gate margin; the default ``0.0`` means *strictly greater*
184
+ validation score is required to accept a candidate. ``rollout_concurrency`` bounds how
185
+ many rollouts in one evaluation run concurrently (rollouts are independent LLM calls;
186
+ results are still collected in task order so the mean and feedback stay deterministic).
187
+ """
188
+
189
+ epochs: int = 5
190
+ max_edits_per_epoch: int = 3
191
+ val_fraction: float = 0.5
192
+ min_improvement: float = 0.0
193
+ seed: int = 0
194
+ rollout_concurrency: int = 8
195
+ # Stop after this many consecutive *unproductive* epochs (a no-op proposal or a batch
196
+ # already known-rejected). A stuck optimizer otherwise burns one LLM call per remaining
197
+ # epoch for zero gain. Gate-rejected epochs are productive (they grow the rejected set
198
+ # and force exploration) and do NOT count toward this patience.
199
+ noop_patience: int = 2
200
+
201
+
202
+ @dataclass
203
+ class EpochRecord:
204
+ epoch: int
205
+ train_score: float
206
+ val_score_before: float
207
+ val_score_after: float | None
208
+ proposed_edits: int
209
+ applied_edits: int
210
+ edited_targets: list[str]
211
+ accepted: bool
212
+ note: str = ""
213
+
214
+
215
+ @dataclass
216
+ class SkillOptResult:
217
+ skill_name: str
218
+ seed_docs: dict[str, str]
219
+ best_docs: dict[str, str]
220
+ seed_val_score: float
221
+ best_val_score: float
222
+ epochs: list[EpochRecord] = field(default_factory=list)
223
+ accepted_edit_count: int = 0
224
+ rejected_edit_count: int = 0
225
+ train_size: int = 0
226
+ val_size: int = 0
227
+ artifact_path: Path | None = None
228
+ applied: bool = False
229
+
230
+ @property
231
+ def improved(self) -> bool:
232
+ return self.best_val_score > self.seed_val_score
233
+
234
+ @property
235
+ def best_skill_body(self) -> str:
236
+ return self.best_docs.get(SKILL, "")
237
+
238
+ @property
239
+ def best_guidance_body(self) -> str:
240
+ return self.best_docs.get(GUIDANCE, "")
241
+
242
+
243
+ # Pluggable hooks. ``RolloutFn(docs, task) -> trajectory text`` where ``docs`` maps
244
+ # each document name to its current body; the trajectory is then handed to
245
+ # ``ScoreFn(task, trajectory) -> score`` which should return a value in [0, 1] (the loop
246
+ # clamps to that range, so a custom scorer with a wider range can't break the gate).
247
+ RolloutFn = Callable[[dict[str, str], dict[str, Any]], Awaitable[str]]
248
+ ScoreFn = Callable[[dict[str, Any], str], float]
249
+ OptimizerFn = Callable[[dict[str, str], list[dict[str, Any]], list[str], str], Awaitable[SkillEditProposal]]
250
+
251
+
252
+ def _clamp01(value: float) -> float:
253
+ return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Default scoring — term coverage on the rollout trajectory.
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def default_score(task: dict[str, Any], trajectory: str) -> float:
262
+ """Score a rollout by required/forbidden term coverage and expected substrings.
263
+
264
+ Returns a neutral ``0.5`` when a task carries no scoring signal so an unlabeled
265
+ task never silently drags the mean to zero.
266
+ """
267
+ text = trajectory.lower()
268
+ required = _string_list(task, "required_terms", "expected_terms", "must_include")
269
+ forbidden = _string_list(task, "forbidden_terms", "must_avoid")
270
+ expected = _string_list(task, "expected", "expected_output", "expected_substrings")
271
+
272
+ if not (required or forbidden or expected):
273
+ return 0.5
274
+ if not trajectory.strip():
275
+ return 0.0
276
+
277
+ parts: list[tuple[float, float]] = [] # (score, weight)
278
+ if required:
279
+ hits = sum(1 for term in required if term.lower() in text)
280
+ parts.append((hits / len(required), 0.6))
281
+ if expected:
282
+ hits = sum(1 for term in expected if term.lower() in text)
283
+ parts.append((hits / len(expected), 0.3))
284
+ if forbidden:
285
+ hits = sum(1 for term in forbidden if term.lower() in text)
286
+ parts.append((1.0 - hits / len(forbidden), 0.2))
287
+
288
+ total_weight = sum(weight for _, weight in parts)
289
+ score = sum(value * weight for value, weight in parts) / total_weight
290
+ return max(0.0, min(1.0, score))
291
+
292
+
293
+ def _task_prompt(task: dict[str, Any]) -> str:
294
+ for key in ("prompt", "task", "goal", "instruction", "question"):
295
+ value = task.get(key)
296
+ if isinstance(value, str) and value.strip():
297
+ return value.strip()
298
+ return ""
299
+
300
+
301
+ def _split_dataset(
302
+ dataset: list[dict[str, Any]], val_fraction: float, seed: int
303
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
304
+ """Deterministically split into (train, val).
305
+
306
+ A stable hash of each example id (salted by ``seed``) decides the side, so the
307
+ same dataset + seed always yields the same split without importing ``random`` or
308
+ depending on input order. At least one example is always kept on each side.
309
+ """
310
+ if len(dataset) < 2:
311
+ return dataset, dataset
312
+ val: list[dict[str, Any]] = []
313
+ train: list[dict[str, Any]] = []
314
+ for index, item in enumerate(dataset):
315
+ key = f"{seed}:{item.get('id', index)}".encode("utf-8")
316
+ bucket = int.from_bytes(hashlib.sha256(key).digest()[:4], "big") / 0xFFFFFFFF
317
+ (val if bucket < val_fraction else train).append(item)
318
+ # Keep at least one example on each side. Both fallbacks *move* (pop) rather than
319
+ # copy, so the same example never lands in both splits — a copy would leak a
320
+ # validation example into training and quietly defeat the held-out gate.
321
+ if not val:
322
+ val.append(train.pop())
323
+ if not train:
324
+ train.append(val.pop())
325
+ return train, val
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # The loop.
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ async def _evaluate(
334
+ docs: dict[str, str],
335
+ tasks: list[dict[str, Any]],
336
+ rollout: RolloutFn,
337
+ score: ScoreFn,
338
+ concurrency: int = 8,
339
+ ) -> tuple[float, list[tuple[dict[str, Any], str, float]]]:
340
+ # Rollouts are independent LLM round-trips, so run them concurrently under a semaphore.
341
+ # ``gather`` preserves input order, so trajectories realign with ``tasks`` and the mean
342
+ # and downstream ``_reflect`` ordering remain deterministic regardless of finish order.
343
+ if not tasks:
344
+ return 0.0, []
345
+ sem = asyncio.Semaphore(max(1, concurrency))
346
+
347
+ async def _one(task: dict[str, Any]) -> str:
348
+ async with sem:
349
+ try:
350
+ return await rollout(docs, task)
351
+ except Exception:
352
+ # A single flaky rollout (transient network error, a raising custom
353
+ # RolloutFn) must not abort the whole run and discard every accepted
354
+ # improvement. Degrade to an empty trajectory — deterministically scored
355
+ # 0.0 for a labeled task — instead of propagating.
356
+ logger.warning("rollout failed for task %s; scoring it 0.0", task.get("id"), exc_info=True)
357
+ return ""
358
+
359
+ trajectories = await asyncio.gather(*(_one(task) for task in tasks))
360
+ # Clamp into the documented [0, 1] contract so a misbehaving custom ScoreFn can't break
361
+ # the loop's control flow (the >= 1.0 early-stop and the _reflect "perfect" threshold).
362
+ rollouts = [
363
+ (task, traj, _clamp01(score(task, traj))) for task, traj in zip(tasks, trajectories)
364
+ ]
365
+ mean = sum(item[2] for item in rollouts) / len(rollouts)
366
+ return mean, rollouts
367
+
368
+
369
+ def _term_gaps(task: dict[str, Any], trajectory: str) -> dict[str, list[str]]:
370
+ """Per-case scoring gaps: which expected terms are missing and which forbidden ones
371
+ are present in the trajectory. This is the *actionable* signal — it tells the
372
+ optimizer exactly what to add or remove — versus dumping the full term lists. Derived
373
+ from the same term contract the default scorer uses; empty for custom datasets with no
374
+ term fields (those fall back to the trajectory excerpt)."""
375
+ text = trajectory.lower()
376
+ required = _string_list(task, "required_terms", "expected_terms", "must_include")
377
+ expected = _string_list(task, "expected", "expected_output", "expected_substrings")
378
+ forbidden = _string_list(task, "forbidden_terms", "must_avoid")
379
+ return {
380
+ "missing_required": [t for t in required if t.lower() not in text],
381
+ "missing_expected": [t for t in expected if t.lower() not in text],
382
+ "present_forbidden": [t for t in forbidden if t.lower() in text],
383
+ }
384
+
385
+
386
+ def _reflect(rollouts: list[tuple[dict[str, Any], str, float]]) -> list[dict[str, Any]]:
387
+ """Aggregate the weakest rollouts into compact feedback for the optimizer.
388
+
389
+ Worst-scoring first, so the optimizer spends its bounded edit budget on the
390
+ cases that are actually failing. Each case carries the concrete scoring gaps
391
+ (missing/forbidden terms) so the optimizer can target them directly.
392
+ """
393
+ feedback: list[dict[str, Any]] = []
394
+ for task, trajectory, value in sorted(rollouts, key=lambda item: item[2]):
395
+ if value >= 1.0:
396
+ continue
397
+ case = {
398
+ "task": _task_prompt(task),
399
+ "score": round(value, 3),
400
+ "observed_failure": task.get("observed_failure", ""),
401
+ "desired_behavior": task.get("desired_behavior", ""),
402
+ "trajectory_excerpt": trajectory.strip()[:400],
403
+ }
404
+ gaps = _term_gaps(task, trajectory)
405
+ case.update({key: terms for key, terms in gaps.items() if terms}) # only non-empty gaps
406
+ feedback.append(case)
407
+ return feedback
408
+
409
+
410
+ async def optimize_skill(
411
+ *,
412
+ skill_name: str,
413
+ docs: dict[str, str],
414
+ dataset: list[dict[str, Any]],
415
+ rollout: RolloutFn,
416
+ optimizer: OptimizerFn,
417
+ score: ScoreFn = default_score,
418
+ config: SkillOptConfig | None = None,
419
+ ) -> SkillOptResult:
420
+ """Co-optimize a skill document and its guidance preamble over the SkillOpt loop.
421
+
422
+ ``docs`` maps document names (typically :data:`GUIDANCE` and :data:`SKILL`) to their
423
+ current bodies; both are improved **simultaneously**. ``rollout`` runs the target
424
+ agent with a candidate set of documents on one task; ``optimizer`` turns aggregated
425
+ feedback into a :class:`SkillEditProposal` whose edits may target either document.
426
+ Neither needs a live model — inject fakes to test the loop, or use
427
+ :func:`make_llm_rollout` / :func:`make_llm_optimizer` for the router-backed defaults.
428
+ """
429
+ cfg = config or SkillOptConfig()
430
+ seed_docs = dict(docs)
431
+ train, val = _split_dataset(dataset, cfg.val_fraction, cfg.seed)
432
+
433
+ seed_val_score, _ = await _evaluate(seed_docs, val, rollout, score, cfg.rollout_concurrency)
434
+ best_docs = dict(seed_docs)
435
+ best_val_score = seed_val_score
436
+
437
+ rejected: set[str] = set()
438
+ rejected_count = 0
439
+ accepted_count = 0
440
+ epochs: list[EpochRecord] = []
441
+ # Memoized train evaluation for the current best_docs. The train rollouts are the
442
+ # dominant cost of the loop, and best_docs only changes when the validation gate
443
+ # accepts a candidate — so a rejected/no-op epoch would otherwise re-run a
444
+ # byte-identical train _evaluate. Cache (train_score, feedback) and invalidate it
445
+ # exactly where best_docs is reassigned below.
446
+ cached_train: tuple[float, list[dict[str, Any]]] | None = None
447
+ consecutive_noops = 0
448
+
449
+ for epoch in range(1, cfg.epochs + 1):
450
+ if best_val_score >= 1.0:
451
+ break # nothing left to gain on the validation set
452
+
453
+ if cached_train is None:
454
+ train_score, train_rollouts = await _evaluate(
455
+ best_docs, train, rollout, score, cfg.rollout_concurrency
456
+ )
457
+ cached_train = (train_score, _reflect(train_rollouts))
458
+ train_score, feedback = cached_train
459
+ if not feedback:
460
+ epochs.append(
461
+ EpochRecord(epoch, train_score, best_val_score, None, 0, 0, [], False, "no failing train tasks")
462
+ )
463
+ break
464
+
465
+ proposal = await optimizer(best_docs, feedback, sorted(rejected), skill_name)
466
+ # Select: drop already-rejected edits, then clamp to the learning-rate budget.
467
+ fresh = [e for e in proposal.edits if _edit_signature(e) not in rejected]
468
+ budgeted = fresh[: cfg.max_edits_per_epoch]
469
+
470
+ candidate_docs, applied = apply_edits(best_docs, budgeted)
471
+ # A multi-edit batch already rejected by the gate must not be re-applied verbatim;
472
+ # its components stay individually eligible (see _batch_signature), so the optimizer
473
+ # can recombine them, but the exact losing combination is skipped.
474
+ known_rejected_batch = len(applied) > 1 and _batch_signature(applied) in rejected
475
+ if not applied or candidate_docs == best_docs or known_rejected_batch:
476
+ # Unproductive epoch. Blacklist only permanently-redundant single edits (empty
477
+ # append / identity replace); an edit whose anchor is merely absent now may
478
+ # apply once a later epoch introduces it, so keep it eligible.
479
+ if not known_rejected_batch:
480
+ for edit in budgeted:
481
+ if _is_permanently_redundant(edit, best_docs):
482
+ rejected.add(_edit_signature(edit))
483
+ note = "known-rejected batch" if known_rejected_batch else "no-op proposal"
484
+ epochs.append(
485
+ EpochRecord(epoch, train_score, best_val_score, None, len(proposal.edits), 0, [], False, note)
486
+ )
487
+ consecutive_noops += 1
488
+ if consecutive_noops >= cfg.noop_patience:
489
+ break # optimizer is stuck producing nothing applicable
490
+ continue
491
+
492
+ consecutive_noops = 0 # an applied batch (accepted or gate-rejected) is productive
493
+ val_after, _ = await _evaluate(candidate_docs, val, rollout, score, cfg.rollout_concurrency)
494
+ accepted = val_after > best_val_score + cfg.min_improvement
495
+ edited_targets = sorted({str(edit.target) for edit in applied})
496
+ if accepted:
497
+ score_before = best_val_score
498
+ best_docs = candidate_docs
499
+ best_val_score = val_after
500
+ cached_train = None # best_docs changed -> the train eval must be recomputed
501
+ accepted_count += len(applied)
502
+ note = "accepted"
503
+ else:
504
+ score_before = best_val_score
505
+ # Gate rejected the batch. Attribute the blame at the right granularity: a lone
506
+ # edit is blacklisted directly; a multi-edit batch blacklists only its
507
+ # *combination* so a good edit isn't banned for a bad partner's sake.
508
+ if len(applied) == 1:
509
+ rejected.add(_edit_signature(applied[0]))
510
+ else:
511
+ rejected.add(_batch_signature(applied))
512
+ rejected_count += len(applied)
513
+ note = "rejected by validation gate"
514
+
515
+ epochs.append(
516
+ EpochRecord(
517
+ epoch, train_score, score_before, val_after,
518
+ len(proposal.edits), len(applied), edited_targets, accepted, note,
519
+ )
520
+ )
521
+
522
+ return SkillOptResult(
523
+ skill_name=skill_name,
524
+ seed_docs=seed_docs,
525
+ best_docs=best_docs,
526
+ seed_val_score=seed_val_score,
527
+ best_val_score=best_val_score,
528
+ epochs=epochs,
529
+ accepted_edit_count=accepted_count,
530
+ rejected_edit_count=rejected_count,
531
+ train_size=len(train),
532
+ val_size=len(val),
533
+ )
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Router-backed default rollout + optimizer.
538
+ # ---------------------------------------------------------------------------
539
+
540
+ DEFAULT_OBJECTIVE = (
541
+ "Improve the guidance preamble and the engineering skill document together so a "
542
+ "coding agent following them produces correct, in-scope, well-verified work on the "
543
+ "evaluation tasks. Keep both documents compact and actionable; prefer small, "
544
+ "targeted edits over rewrites, and keep guidance and skill mutually consistent."
545
+ )
546
+
547
+
548
+ class _AgentAnswer(BaseModel):
549
+ answer: str = ""
550
+
551
+
552
+ def _compose_context(docs: dict[str, str]) -> str:
553
+ blocks = []
554
+ guidance = docs.get(GUIDANCE, "").strip()
555
+ skill = docs.get(SKILL, "").strip()
556
+ if guidance:
557
+ blocks.append(f"<guidance>\n{guidance}\n</guidance>")
558
+ if skill:
559
+ blocks.append(f"<skill>\n{skill}\n</skill>")
560
+ for name, body in docs.items():
561
+ if name in DOC_TARGETS or not body.strip():
562
+ continue
563
+ blocks.append(f"<{name}>\n{body.strip()}\n</{name}>")
564
+ return "\n\n".join(blocks)
565
+
566
+
567
+ def _pick_role(role_config: dict[str, Any], preferred: tuple[str, ...]) -> str:
568
+ for role in preferred:
569
+ if role in role_config:
570
+ return role
571
+ if role_config:
572
+ return next(iter(role_config))
573
+ raise ValueError("Router has no configured roles for SkillOpt.")
574
+
575
+
576
+ def make_llm_rollout(
577
+ router: Any,
578
+ *,
579
+ preferred_roles: tuple[str, ...] = ("skill_target", "arbiter", "planner_a"),
580
+ ) -> RolloutFn:
581
+ """Build a rollout that runs the target agent (an LLM) with both documents in context."""
582
+ role = _pick_role(router.role_config, preferred_roles)
583
+
584
+ async def rollout(docs: dict[str, str], task: dict[str, Any]) -> str:
585
+ system = (
586
+ "You are a coding agent. Follow this guidance and engineering skill exactly "
587
+ "when responding.\n\n" + _compose_context(docs)
588
+ )
589
+ messages = [
590
+ {"role": "system", "content": system},
591
+ {"role": "user", "content": _task_prompt(task) or "Complete the task."},
592
+ ]
593
+ result = await router.complete_structured(
594
+ role, messages, _AgentAnswer, fallback=_AgentAnswer(answer="")
595
+ )
596
+ return result.answer
597
+
598
+ return rollout
599
+
600
+
601
+ def make_llm_optimizer(
602
+ router: Any,
603
+ *,
604
+ objective: str = DEFAULT_OBJECTIVE,
605
+ preferred_roles: tuple[str, ...] = ("skill_optimizer", "arbiter", "critic_a"),
606
+ ) -> OptimizerFn:
607
+ """Build an optimizer that proposes bounded edits across guidance + skill at once."""
608
+ role = _pick_role(router.role_config, preferred_roles)
609
+
610
+ async def optimizer(
611
+ docs: dict[str, str], feedback: list[dict[str, Any]], rejected: list[str], skill_name: str
612
+ ) -> SkillEditProposal:
613
+ system = (
614
+ "You optimize the documents that steer a coding agent. "
615
+ f"{objective}\n\n"
616
+ "You may edit two documents simultaneously: 'guidance' (the agent preamble) and "
617
+ "'skill' (the engineering skill). Set each edit's 'target' accordingly. Propose a "
618
+ "SMALL set of bounded edits. Each edit anchors on existing text via 'find'. Use "
619
+ "op=replace to swap 'find' for 'text', op=add to insert 'text' after 'find' (empty "
620
+ "'find' appends), op=delete to remove 'find'. Do not rewrite a whole document."
621
+ )
622
+ user = json.dumps(
623
+ {
624
+ "skill_name": skill_name,
625
+ "documents": {name: docs.get(name, "") for name in DOC_TARGETS if name in docs},
626
+ "failing_cases": feedback,
627
+ "previously_rejected_edit_ids": rejected,
628
+ },
629
+ indent=2,
630
+ )
631
+ messages = [
632
+ {"role": "system", "content": system},
633
+ {"role": "user", "content": user},
634
+ ]
635
+ return await router.complete_structured(
636
+ role, messages, SkillEditProposal, fallback=SkillEditProposal()
637
+ )
638
+
639
+ return optimizer
640
+
641
+
642
+ # ---------------------------------------------------------------------------
643
+ # Artifact persistence.
644
+ # ---------------------------------------------------------------------------
645
+
646
+
647
+ def default_artifact_path(project_root: Path, skill_name: str) -> Path:
648
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
649
+ safe = skill_name.replace("/", "-").replace("\\", "-")
650
+ return project_root / ".devcouncil" / "optimizations" / f"{timestamp}-{safe}-skillopt.json"
651
+
652
+
653
+ def write_result_artifact(path: Path, result: SkillOptResult, *, objective: str, dataset_path: str) -> None:
654
+ path.parent.mkdir(parents=True, exist_ok=True)
655
+ payload = {
656
+ "optimizer": "devcouncil.skillopt",
657
+ "skill": result.skill_name,
658
+ "objective": objective,
659
+ "dataset_path": dataset_path,
660
+ "train_size": result.train_size,
661
+ "val_size": result.val_size,
662
+ "seed_val_score": result.seed_val_score,
663
+ "best_val_score": result.best_val_score,
664
+ "improved": result.improved,
665
+ "accepted_edit_count": result.accepted_edit_count,
666
+ "rejected_edit_count": result.rejected_edit_count,
667
+ "epochs": [asdict(record) for record in result.epochs],
668
+ "seed_docs": result.seed_docs,
669
+ "best_docs": result.best_docs,
670
+ "applied": result.applied,
671
+ "created_at": datetime.now(timezone.utc).isoformat(),
672
+ }
673
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")