Add schedule promote for atomic cadence promotion
Some checks failed
ci / test (push) Failing after 13m58s
Some checks failed
ci / test (push) Failing after 13m58s
Orchestrates cadence.yml, activity-definitions, fleet schedule.yml, and activity-core sync in one command. Supports --dry-run and --fleet-only for repairing partial promotions.
This commit is contained in:
@@ -1,17 +1,15 @@
|
|||||||
# Kaizen scheduled agent execution (ADR-005)
|
# Kaizen scheduled agent execution manifest (ADR-005)
|
||||||
# Engagement: coulomb-loop — stabilize phase (daily crons per ADR-003)
|
# Engagement: coulomb-loop bootstrap — weekly cadence
|
||||||
# Promoted 2026-06-18 after 3/3 bootstrap E2E cycles
|
# Regulator promotes cadence per customer engagement policy (ADR-003).
|
||||||
|
# Validate with: kaizen-agentic schedule validate
|
||||||
version: '1'
|
version: '1'
|
||||||
timezone: Europe/Berlin
|
timezone: Europe/Berlin
|
||||||
agents:
|
agents:
|
||||||
coach:
|
coach:
|
||||||
cadence: daily
|
cadence: weekly
|
||||||
cron: "0 9 * * *"
|
cron: 0 9 * * 1
|
||||||
enabled: true
|
enabled: true
|
||||||
optimization:
|
optimization:
|
||||||
cadence: daily
|
cadence: weekly
|
||||||
cron: "0 10 * * *"
|
cron: 0 10 * * 1
|
||||||
enabled: true
|
enabled: true
|
||||||
tdd-workflow:
|
|
||||||
cadence: monthly
|
|
||||||
enabled: false
|
|
||||||
|
|||||||
@@ -65,7 +65,21 @@ Requires `pip install 'kaizen-agentic[events]'` for `--emit-event`.
|
|||||||
|
|
||||||
## Cadence promotion
|
## Cadence promotion
|
||||||
|
|
||||||
Customer regulator (LOOP-WP-0004) approves promotion. Re-init schedules:
|
Customer regulator (LOOP-WP-0004) approves promotion. Use atomic promote to
|
||||||
|
align all three layers (cadence.yml, activity-definitions, fleet schedule.yml,
|
||||||
|
activity-core sync):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kaizen-agentic schedule promote \
|
||||||
|
--engagement-repo /path/to/customer-loop \
|
||||||
|
--engagement <slug> \
|
||||||
|
--to-phase operate \
|
||||||
|
--activity-core /path/to/activity-core
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run: `--dry-run`. Repair fleet drift after a partial promotion: `--fleet-only`.
|
||||||
|
|
||||||
|
Legacy per-layer commands still work:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kaizen-agentic schedule init --engagement <slug> --bootstrap-cadence daily --force
|
kaizen-agentic schedule init --engagement <slug> --bootstrap-cadence daily --force
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .integrations.event_bus import (
|
|||||||
from .integrations.helix import HelixCorrelationAdapter, enrich_helix_correlation
|
from .integrations.helix import HelixCorrelationAdapter, enrich_helix_correlation
|
||||||
from .metrics import MetricsStore, OptimizerStore, performance_summary_markdown
|
from .metrics import MetricsStore, OptimizerStore, performance_summary_markdown
|
||||||
from .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
|
from .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
|
||||||
|
from .engagement_promote import promote_engagement
|
||||||
from .schedule import (
|
from .schedule import (
|
||||||
ScheduleError,
|
ScheduleError,
|
||||||
default_schedule_yaml,
|
default_schedule_yaml,
|
||||||
@@ -1534,6 +1535,105 @@ def schedule_init(
|
|||||||
click.echo(" Validate with: kaizen-agentic schedule validate")
|
click.echo(" Validate with: kaizen-agentic schedule validate")
|
||||||
|
|
||||||
|
|
||||||
|
@schedule.command("promote")
|
||||||
|
@click.option(
|
||||||
|
"--engagement-repo",
|
||||||
|
"-e",
|
||||||
|
required=True,
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
help="Customer engagement repo (e.g. coulomb-loop)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--engagement",
|
||||||
|
default="coulomb-loop",
|
||||||
|
show_default=True,
|
||||||
|
help="Engagement slug written into fleet schedule.yml headers",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--to-phase",
|
||||||
|
type=click.Choice(["stabilize", "operate"]),
|
||||||
|
default=None,
|
||||||
|
help="Target phase (default: next phase after current)",
|
||||||
|
)
|
||||||
|
@click.option("--loop", default=None, help="Promote a single loop id only")
|
||||||
|
@click.option("--dry-run", is_flag=True, help="Print planned actions without writing")
|
||||||
|
@click.option("--skip-cadence", is_flag=True, help="Skip loops/*/cadence.yml updates")
|
||||||
|
@click.option(
|
||||||
|
"--skip-definitions", is_flag=True, help="Skip activity-definitions transforms"
|
||||||
|
)
|
||||||
|
@click.option("--skip-fleet", is_flag=True, help="Skip .kaizen/schedule.yml on roster")
|
||||||
|
@click.option("--skip-sync", is_flag=True, help="Skip activity-core definition sync")
|
||||||
|
@click.option(
|
||||||
|
"--fleet-only",
|
||||||
|
is_flag=True,
|
||||||
|
help="Only update fleet schedule.yml (+ sync unless --skip-sync)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--activity-core",
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="activity-core repo root (or ACTIVITY_CORE_ROOT env)",
|
||||||
|
)
|
||||||
|
def schedule_promote(
|
||||||
|
engagement_repo: Path,
|
||||||
|
engagement: str,
|
||||||
|
to_phase: Optional[str],
|
||||||
|
loop: Optional[str],
|
||||||
|
dry_run: bool,
|
||||||
|
skip_cadence: bool,
|
||||||
|
skip_definitions: bool,
|
||||||
|
skip_fleet: bool,
|
||||||
|
skip_sync: bool,
|
||||||
|
fleet_only: bool,
|
||||||
|
activity_core: Optional[Path],
|
||||||
|
):
|
||||||
|
"""Atomically promote cadence across cadence.yml, definitions, fleet, and sync."""
|
||||||
|
if fleet_only:
|
||||||
|
skip_cadence = True
|
||||||
|
skip_definitions = True
|
||||||
|
if to_phase is None:
|
||||||
|
to_phase = "operate"
|
||||||
|
|
||||||
|
result = promote_engagement(
|
||||||
|
engagement_repo,
|
||||||
|
engagement_slug=engagement,
|
||||||
|
to_phase=to_phase,
|
||||||
|
loop=loop,
|
||||||
|
dry_run=dry_run,
|
||||||
|
skip_cadence=skip_cadence,
|
||||||
|
skip_definitions=skip_definitions,
|
||||||
|
skip_fleet=skip_fleet,
|
||||||
|
skip_sync=skip_sync,
|
||||||
|
activity_core_root=activity_core,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
click.echo("Dry run — planned actions:")
|
||||||
|
else:
|
||||||
|
click.echo("Promotion complete — actions:")
|
||||||
|
|
||||||
|
by_layer: dict[str, list[str]] = {}
|
||||||
|
for action in result.actions:
|
||||||
|
by_layer.setdefault(action.layer, []).append(action.description)
|
||||||
|
|
||||||
|
for layer in ("cadence", "definitions", "fleet", "sync"):
|
||||||
|
items = by_layer.get(layer)
|
||||||
|
if items:
|
||||||
|
click.echo(f"\n[{layer}]")
|
||||||
|
for item in items:
|
||||||
|
click.echo(f" • {item}")
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
click.echo("\nWarnings / errors:", err=True)
|
||||||
|
for err in result.errors:
|
||||||
|
click.echo(f" ! {err}", err=True)
|
||||||
|
if not result.actions:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not result.actions and not result.errors:
|
||||||
|
click.echo("Nothing to do — layers already aligned.")
|
||||||
|
|
||||||
|
|
||||||
@schedule.command("list")
|
@schedule.command("list")
|
||||||
@click.option("--target", "-t", default=".", help="Project root (default: current)")
|
@click.option("--target", "-t", default=".", help="Project root (default: current)")
|
||||||
@click.option("--all", "show_all", is_flag=True, help="Include disabled entries")
|
@click.option("--all", "show_all", is_flag=True, help="Include disabled entries")
|
||||||
|
|||||||
555
src/kaizen_agentic/engagement_promote.py
Normal file
555
src/kaizen_agentic/engagement_promote.py
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
"""Atomic cadence promotion across engagement contract layers (ADR-003).
|
||||||
|
|
||||||
|
Orchestrates three layers that must stay aligned on promotion:
|
||||||
|
|
||||||
|
1. Customer policy — ``loops/*/cadence.yml`` in the engagement repo
|
||||||
|
2. Scheduler contract — ``activity-definitions/*.md`` → activity-core sync
|
||||||
|
3. Fleet opt-in — ``.kaizen/schedule.yml`` on roster target repos
|
||||||
|
|
||||||
|
See coulomb-loop ADR-002 / ADR-003 and supplier customer-engagement-playbook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .schedule import ScheduleError, engagement_schedule_yaml, schedule_path
|
||||||
|
|
||||||
|
PHASE_ORDER = ("bootstrap", "stabilize", "operate")
|
||||||
|
PHASE_FILE_PREFIX = {
|
||||||
|
"bootstrap": "hourly",
|
||||||
|
"stabilize": "daily",
|
||||||
|
"operate": "weekly",
|
||||||
|
}
|
||||||
|
CADENCE_ENUM = {
|
||||||
|
"bootstrap": "daily", # hourly crons keep daily enum for resolver filter
|
||||||
|
"stabilize": "daily",
|
||||||
|
"operate": "weekly",
|
||||||
|
}
|
||||||
|
|
||||||
|
LOOP_DIR_BY_ID = {
|
||||||
|
"kaizen-improvement-stack": "kaizen-stack",
|
||||||
|
"quality-escalation": "quality-escalation",
|
||||||
|
"registry-hygiene": "registry-hygiene",
|
||||||
|
"loop-regulator": "regulator",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activity-definition stems per loop (without phase prefix). Crons come from
|
||||||
|
# operate_target in cadence.yml when present, else these defaults.
|
||||||
|
DEFAULT_OPERATE_CRONS: dict[str, dict[str, str]] = {
|
||||||
|
"kaizen-improvement-stack": {
|
||||||
|
"metrics-optimize": "0 8 * * 1",
|
||||||
|
"coach-orientation": "0 9 * * 1",
|
||||||
|
"optimization-review": "0 10 * * 1",
|
||||||
|
},
|
||||||
|
"registry-hygiene": {
|
||||||
|
"registry-hygiene-sweep": "0 9 * * 1",
|
||||||
|
},
|
||||||
|
"quality-escalation": {
|
||||||
|
"metrics-health-sweep": "0 6 * * 1",
|
||||||
|
},
|
||||||
|
"loop-regulator": {
|
||||||
|
"loop-health-collector": "0 11 * * 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EVENT_DEFINITIONS = frozenset({"low-success-rate-review"})
|
||||||
|
|
||||||
|
|
||||||
|
class PromoteError(Exception):
|
||||||
|
"""Raised when promotion cannot proceed."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PromoteAction:
|
||||||
|
layer: str
|
||||||
|
description: str
|
||||||
|
path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PromoteResult:
|
||||||
|
actions: list[PromoteAction] = field(default_factory=list)
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not self.errors
|
||||||
|
|
||||||
|
|
||||||
|
def _next_phase(current: str) -> str | None:
|
||||||
|
try:
|
||||||
|
idx = PHASE_ORDER.index(current)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if idx + 1 >= len(PHASE_ORDER):
|
||||||
|
return None
|
||||||
|
return PHASE_ORDER[idx + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _loop_paths(engagement_repo: Path) -> dict[str, Path]:
|
||||||
|
loops_root = engagement_repo / "loops"
|
||||||
|
if not loops_root.is_dir():
|
||||||
|
raise PromoteError(f"loops/ not found under {engagement_repo}")
|
||||||
|
mapping: dict[str, Path] = {}
|
||||||
|
for loop_id, dirname in LOOP_DIR_BY_ID.items():
|
||||||
|
path = loops_root / dirname / "cadence.yml"
|
||||||
|
if path.is_file():
|
||||||
|
mapping[loop_id] = path
|
||||||
|
if not mapping:
|
||||||
|
raise PromoteError(f"no loops/*/cadence.yml found under {engagement_repo}")
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml(path: Path) -> dict[str, Any]:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise PromoteError(f"expected mapping in {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _write_yaml(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_markdown_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||||
|
if not text.startswith("---"):
|
||||||
|
raise PromoteError("activity definition missing YAML frontmatter")
|
||||||
|
parts = text.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
raise PromoteError("activity definition frontmatter not closed")
|
||||||
|
meta = yaml.safe_load(parts[1])
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
raise PromoteError("activity definition frontmatter must be a mapping")
|
||||||
|
body = parts[2].lstrip("\n")
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
|
||||||
|
def _render_markdown(meta: dict[str, Any], body: str) -> str:
|
||||||
|
header = yaml.safe_dump(meta, sort_keys=False)
|
||||||
|
return f"---\n{header}---\n\n{body}"
|
||||||
|
|
||||||
|
|
||||||
|
def _roster_repos(engagement_repo: Path) -> list[tuple[str, Path, list[str]]]:
|
||||||
|
roster_path = engagement_repo / "loops" / "kaizen-stack" / "roster.yaml"
|
||||||
|
if not roster_path.is_file():
|
||||||
|
return []
|
||||||
|
data = _load_yaml(roster_path)
|
||||||
|
active = data.get("active") or []
|
||||||
|
repos: list[tuple[str, Path, list[str]]] = []
|
||||||
|
for entry in active:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
slug = str(entry.get("slug", ""))
|
||||||
|
root = entry.get("root")
|
||||||
|
agents = entry.get("agents") or ["coach", "optimization"]
|
||||||
|
if slug and root:
|
||||||
|
repos.append((slug, Path(str(root)), list(agents)))
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
def _operate_crons(loop_id: str, cadence: dict[str, Any]) -> dict[str, str]:
|
||||||
|
target = cadence.get("operate_target")
|
||||||
|
crons = dict(DEFAULT_OPERATE_CRONS.get(loop_id, {}))
|
||||||
|
if not isinstance(target, dict):
|
||||||
|
return crons
|
||||||
|
if loop_id == "kaizen-improvement-stack":
|
||||||
|
chain = target.get("chain")
|
||||||
|
if isinstance(chain, dict):
|
||||||
|
for stem, cron_key in [
|
||||||
|
("metrics-optimize", "metrics"),
|
||||||
|
("coach-orientation", "coach"),
|
||||||
|
("optimization-review", "optimization"),
|
||||||
|
]:
|
||||||
|
if cron_key in chain:
|
||||||
|
crons[stem] = str(chain[cron_key])
|
||||||
|
elif loop_id == "registry-hygiene" and "cron" in target:
|
||||||
|
crons["registry-hygiene-sweep"] = str(target["cron"])
|
||||||
|
elif loop_id == "quality-escalation" and "sweep_cron" in target:
|
||||||
|
crons["metrics-health-sweep"] = str(target["sweep_cron"])
|
||||||
|
elif loop_id == "loop-regulator":
|
||||||
|
if "collector_cron" in target:
|
||||||
|
crons["loop-health-collector"] = str(target["collector_cron"])
|
||||||
|
return crons
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_operate_target(
|
||||||
|
cadence: dict[str, Any], loop_id: str, promoted_at: str
|
||||||
|
) -> None:
|
||||||
|
target = cadence.pop("operate_target", None)
|
||||||
|
cadence["phase"] = "operate"
|
||||||
|
cadence["promoted_at"] = promoted_at
|
||||||
|
if not isinstance(target, dict):
|
||||||
|
target = {}
|
||||||
|
|
||||||
|
if loop_id == "kaizen-improvement-stack":
|
||||||
|
chain = target.get("chain") or DEFAULT_OPERATE_CRONS[loop_id]
|
||||||
|
cadence["cron"] = chain.get("metrics", "0 8 * * 1")
|
||||||
|
cadence["chain"] = {
|
||||||
|
"metrics": chain.get("metrics", "0 8 * * 1"),
|
||||||
|
"coach": chain.get("coach", "0 9 * * 1"),
|
||||||
|
"optimization": chain.get("optimization", "0 10 * * 1"),
|
||||||
|
}
|
||||||
|
elif loop_id == "registry-hygiene":
|
||||||
|
cadence["cron"] = target.get("cron", "0 9 * * 1")
|
||||||
|
cadence["batch_size"] = target.get("batch_size", 2)
|
||||||
|
cadence["domain_rotation"] = target.get("domain_rotation", "weekly")
|
||||||
|
elif loop_id == "quality-escalation":
|
||||||
|
cadence["cron"] = target.get("sweep_cron", "0 6 * * 1")
|
||||||
|
cadence["sweep_fallback"] = "weekly-metrics-health-sweep"
|
||||||
|
cadence.pop("stabilize_target", None)
|
||||||
|
elif loop_id == "loop-regulator":
|
||||||
|
cron = target.get("collector_cron", "0 11 * * 1")
|
||||||
|
cadence["collector_cron"] = cron
|
||||||
|
cadence["regulator_session_cron"] = target.get("regulator_session_cron", cron)
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_definition(
|
||||||
|
path: Path,
|
||||||
|
from_prefix: str,
|
||||||
|
to_prefix: str,
|
||||||
|
cron: str,
|
||||||
|
cadence_enum: str,
|
||||||
|
) -> Path:
|
||||||
|
meta, body = _split_markdown_frontmatter(path.read_text(encoding="utf-8"))
|
||||||
|
old_id = str(meta.get("id", ""))
|
||||||
|
stem = path.stem.removeprefix(f"{from_prefix}-")
|
||||||
|
new_id = f"coulomb-{to_prefix}-{stem.replace('-', '-')}"
|
||||||
|
if old_id.startswith("coulomb-"):
|
||||||
|
new_id = old_id.replace(f"coulomb-{from_prefix}-", f"coulomb-{to_prefix}-", 1)
|
||||||
|
|
||||||
|
meta["id"] = new_id
|
||||||
|
if isinstance(meta.get("name"), str):
|
||||||
|
meta["name"] = meta["name"].replace(from_prefix.title(), to_prefix.title())
|
||||||
|
meta["name"] = re.sub(
|
||||||
|
rf"\b{from_prefix}\b", to_prefix, meta["name"], flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
trigger = meta.get("trigger")
|
||||||
|
if isinstance(trigger, dict) and trigger.get("type") == "cron":
|
||||||
|
trigger["cron_expression"] = cron
|
||||||
|
|
||||||
|
for source in meta.get("context_sources") or []:
|
||||||
|
if isinstance(source, dict):
|
||||||
|
params = source.get("params")
|
||||||
|
if isinstance(params, dict) and "cadence" in params:
|
||||||
|
params["cadence"] = cadence_enum
|
||||||
|
|
||||||
|
body = body.replace(f"{from_prefix}-", f"{to_prefix}-")
|
||||||
|
body = re.sub(rf"\b{from_prefix}\b", to_prefix, body, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
dest = path.with_name(f"{to_prefix}-{stem}.md")
|
||||||
|
dest.write_text(_render_markdown(meta, body), encoding="utf-8")
|
||||||
|
if dest != path:
|
||||||
|
path.unlink()
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_definitions(
|
||||||
|
engagement_repo: Path,
|
||||||
|
from_phase: str,
|
||||||
|
to_phase: str,
|
||||||
|
loop_crons: dict[str, dict[str, str]],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
result: PromoteResult,
|
||||||
|
) -> None:
|
||||||
|
from_prefix = PHASE_FILE_PREFIX[from_phase]
|
||||||
|
to_prefix = PHASE_FILE_PREFIX[to_phase]
|
||||||
|
cadence_enum = CADENCE_ENUM[to_phase]
|
||||||
|
defs_dir = engagement_repo / "activity-definitions"
|
||||||
|
if not defs_dir.is_dir():
|
||||||
|
raise PromoteError(f"activity-definitions/ not found under {engagement_repo}")
|
||||||
|
|
||||||
|
for loop_id, crons in loop_crons.items():
|
||||||
|
for stem, cron in crons.items():
|
||||||
|
src = defs_dir / f"{from_prefix}-{stem}.md"
|
||||||
|
if not src.is_file():
|
||||||
|
result.errors.append(f"missing definition file: {src}")
|
||||||
|
continue
|
||||||
|
dest = defs_dir / f"{to_prefix}-{stem}.md"
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="definitions",
|
||||||
|
description=f"{src.name} → {dest.name} (cron {cron})",
|
||||||
|
path=str(src),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
_transform_definition(src, from_prefix, to_prefix, cron, cadence_enum)
|
||||||
|
|
||||||
|
# Update event-definition fallback references
|
||||||
|
for name in EVENT_DEFINITIONS:
|
||||||
|
path = defs_dir / f"{name}.md"
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
new_text = text.replace(
|
||||||
|
f"{from_prefix}-metrics-health-sweep",
|
||||||
|
f"{to_prefix}-metrics-health-sweep",
|
||||||
|
)
|
||||||
|
new_text = new_text.replace(
|
||||||
|
f"`0 6 * * *`",
|
||||||
|
f"`{loop_crons.get('quality-escalation', {}).get('metrics-health-sweep', '0 6 * * 1')}`",
|
||||||
|
)
|
||||||
|
if new_text != text:
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="definitions",
|
||||||
|
description=f"update fallback reference in {path.name}",
|
||||||
|
path=str(path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
path.write_text(new_text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_cadence_files(
|
||||||
|
engagement_repo: Path,
|
||||||
|
to_phase: str,
|
||||||
|
*,
|
||||||
|
loop_filter: str | None,
|
||||||
|
dry_run: bool,
|
||||||
|
result: PromoteResult,
|
||||||
|
) -> dict[str, dict[str, str]]:
|
||||||
|
if to_phase != "operate":
|
||||||
|
raise PromoteError(f"promotion to '{to_phase}' not implemented yet")
|
||||||
|
|
||||||
|
promoted_at = date.today().isoformat()
|
||||||
|
loop_crons: dict[str, dict[str, str]] = {}
|
||||||
|
for loop_id, path in _loop_paths(engagement_repo).items():
|
||||||
|
if loop_filter and loop_filter != loop_id:
|
||||||
|
continue
|
||||||
|
data = _load_yaml(path)
|
||||||
|
current = str(data.get("phase", ""))
|
||||||
|
if current == to_phase:
|
||||||
|
loop_crons[loop_id] = _operate_crons(loop_id, data)
|
||||||
|
continue
|
||||||
|
if current != "stabilize":
|
||||||
|
result.errors.append(
|
||||||
|
f"{loop_id}: cannot promote from phase '{current}' to '{to_phase}'"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
loop_crons[loop_id] = _operate_crons(loop_id, data)
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="cadence",
|
||||||
|
description=f"{path.relative_to(engagement_repo)}: {current} → {to_phase}",
|
||||||
|
path=str(path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
_apply_operate_target(data, loop_id, promoted_at)
|
||||||
|
_write_yaml(path, data)
|
||||||
|
|
||||||
|
roster_path = engagement_repo / "loops" / "kaizen-stack" / "roster.yaml"
|
||||||
|
if roster_path.is_file() and (
|
||||||
|
loop_filter is None or loop_filter == "kaizen-improvement-stack"
|
||||||
|
):
|
||||||
|
roster = _load_yaml(roster_path)
|
||||||
|
if roster.get("phase") != to_phase:
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="cadence",
|
||||||
|
description=f"roster.yaml phase → {to_phase}",
|
||||||
|
path=str(roster_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
roster["phase"] = to_phase
|
||||||
|
_write_yaml(roster_path, roster)
|
||||||
|
|
||||||
|
return loop_crons
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_roster_phase(
|
||||||
|
engagement_repo: Path,
|
||||||
|
to_phase: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
result: PromoteResult,
|
||||||
|
) -> None:
|
||||||
|
roster_path = engagement_repo / "loops" / "kaizen-stack" / "roster.yaml"
|
||||||
|
if not roster_path.is_file():
|
||||||
|
return
|
||||||
|
roster = _load_yaml(roster_path)
|
||||||
|
if roster.get("phase") == to_phase:
|
||||||
|
return
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="cadence",
|
||||||
|
description=f"roster.yaml phase → {to_phase}",
|
||||||
|
path=str(roster_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
roster["phase"] = to_phase
|
||||||
|
_write_yaml(roster_path, roster)
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_fleet(
|
||||||
|
engagement_repo: Path,
|
||||||
|
engagement_slug: str,
|
||||||
|
to_phase: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
result: PromoteResult,
|
||||||
|
) -> None:
|
||||||
|
if to_phase != "operate":
|
||||||
|
return
|
||||||
|
_sync_roster_phase(engagement_repo, to_phase, dry_run=dry_run, result=result)
|
||||||
|
for slug, root, agents in _roster_repos(engagement_repo):
|
||||||
|
if not root.is_dir():
|
||||||
|
result.errors.append(f"fleet repo root missing: {root} ({slug})")
|
||||||
|
continue
|
||||||
|
sched = schedule_path(root)
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="fleet",
|
||||||
|
description=f"{slug}: .kaizen/schedule.yml → weekly ({', '.join(agents)})",
|
||||||
|
path=str(sched),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
yaml_text = engagement_schedule_yaml(
|
||||||
|
engagement_slug,
|
||||||
|
agents=agents,
|
||||||
|
bootstrap_cadence="weekly",
|
||||||
|
)
|
||||||
|
sched.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sched.write_text(yaml_text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_activity_core(
|
||||||
|
engagement_repo: Path,
|
||||||
|
activity_core_root: Path | None,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
result: PromoteResult,
|
||||||
|
) -> None:
|
||||||
|
ac_root = activity_core_root or Path(os.environ.get("ACTIVITY_CORE_ROOT", ""))
|
||||||
|
if not ac_root or not (ac_root / "src" / "activity_core").is_dir():
|
||||||
|
result.errors.append(
|
||||||
|
"activity-core root not found (set --activity-core or ACTIVITY_CORE_ROOT)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not os.environ.get("ACTCORE_DB_URL"):
|
||||||
|
result.errors.append("ACTCORE_DB_URL not set — skip sync or configure database")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = ["uv", "run", "python", "-m", "activity_core.sync_activity_definitions"]
|
||||||
|
result.actions.append(
|
||||||
|
PromoteAction(
|
||||||
|
layer="sync",
|
||||||
|
description="activity-core sync_activity_definitions",
|
||||||
|
path=str(ac_root),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["ACTIVITY_DEFINITION_DIRS"] = str(engagement_repo.resolve())
|
||||||
|
subprocess.run(cmd, cwd=ac_root, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def promote_engagement(
|
||||||
|
engagement_repo: Path,
|
||||||
|
*,
|
||||||
|
engagement_slug: str = "coulomb-loop",
|
||||||
|
to_phase: str | None = None,
|
||||||
|
loop: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
skip_cadence: bool = False,
|
||||||
|
skip_definitions: bool = False,
|
||||||
|
skip_fleet: bool = False,
|
||||||
|
skip_sync: bool = False,
|
||||||
|
activity_core_root: Path | None = None,
|
||||||
|
) -> PromoteResult:
|
||||||
|
"""Run atomic cadence promotion (or dry-run plan)."""
|
||||||
|
engagement_repo = engagement_repo.resolve()
|
||||||
|
result = PromoteResult()
|
||||||
|
|
||||||
|
if not engagement_repo.is_dir():
|
||||||
|
result.errors.append(f"engagement repo not found: {engagement_repo}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Resolve target phase from first loop if not specified
|
||||||
|
if to_phase is None:
|
||||||
|
for path in _loop_paths(engagement_repo).values():
|
||||||
|
data = _load_yaml(path)
|
||||||
|
nxt = _next_phase(str(data.get("phase", "bootstrap")))
|
||||||
|
if nxt:
|
||||||
|
to_phase = nxt
|
||||||
|
break
|
||||||
|
if to_phase is None:
|
||||||
|
result.errors.append("could not infer target phase (all loops at operate?)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
loop_crons: dict[str, dict[str, str]] = {}
|
||||||
|
from_phase = (
|
||||||
|
PHASE_ORDER[PHASE_ORDER.index(to_phase) - 1]
|
||||||
|
if to_phase in PHASE_ORDER[1:]
|
||||||
|
else "stabilize"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not skip_cadence:
|
||||||
|
loop_crons = _promote_cadence_files(
|
||||||
|
engagement_repo,
|
||||||
|
to_phase,
|
||||||
|
loop_filter=loop,
|
||||||
|
dry_run=dry_run,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for loop_id, path in _loop_paths(engagement_repo).items():
|
||||||
|
if loop and loop != loop_id:
|
||||||
|
continue
|
||||||
|
loop_crons[loop_id] = _operate_crons(loop_id, _load_yaml(path))
|
||||||
|
|
||||||
|
if (
|
||||||
|
not skip_definitions
|
||||||
|
and from_phase in PHASE_FILE_PREFIX
|
||||||
|
and to_phase in PHASE_FILE_PREFIX
|
||||||
|
):
|
||||||
|
if from_phase != to_phase:
|
||||||
|
_promote_definitions(
|
||||||
|
engagement_repo,
|
||||||
|
from_phase,
|
||||||
|
to_phase,
|
||||||
|
loop_crons,
|
||||||
|
dry_run=dry_run,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not skip_fleet:
|
||||||
|
_promote_fleet(
|
||||||
|
engagement_repo,
|
||||||
|
engagement_slug,
|
||||||
|
to_phase,
|
||||||
|
dry_run=dry_run,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not skip_sync:
|
||||||
|
_sync_activity_core(
|
||||||
|
engagement_repo,
|
||||||
|
activity_core_root,
|
||||||
|
dry_run=dry_run,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
except (PromoteError, ScheduleError, subprocess.CalledProcessError) as exc:
|
||||||
|
result.errors.append(str(exc))
|
||||||
|
|
||||||
|
return result
|
||||||
161
tests/test_engagement_promote.py
Normal file
161
tests/test_engagement_promote.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Tests for atomic engagement cadence promotion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from kaizen_agentic.cli import cli
|
||||||
|
from kaizen_agentic.engagement_promote import promote_engagement
|
||||||
|
from kaizen_agentic.schedule import schedule_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cadence(
|
||||||
|
repo: Path,
|
||||||
|
loop_dir: str,
|
||||||
|
loop_id: str,
|
||||||
|
phase: str,
|
||||||
|
*,
|
||||||
|
with_operate_target: bool = True,
|
||||||
|
) -> None:
|
||||||
|
data: dict = {"loop": loop_id, "phase": phase, "regulator_approval": "approved"}
|
||||||
|
if phase == "stabilize" and with_operate_target:
|
||||||
|
if loop_id == "kaizen-improvement-stack":
|
||||||
|
data["operate_target"] = {
|
||||||
|
"phase": "operate",
|
||||||
|
"chain": {
|
||||||
|
"metrics": "0 8 * * 1",
|
||||||
|
"coach": "0 9 * * 1",
|
||||||
|
"optimization": "0 10 * * 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif loop_id == "registry-hygiene":
|
||||||
|
data["operate_target"] = {
|
||||||
|
"phase": "operate",
|
||||||
|
"cron": "0 9 * * 1",
|
||||||
|
"batch_size": 2,
|
||||||
|
}
|
||||||
|
path = repo / "loops" / loop_dir / "cadence.yml"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_definition(repo: Path, name: str, cron: str, cadence: str) -> None:
|
||||||
|
defs_dir = repo / "activity-definitions"
|
||||||
|
defs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stem = name.removeprefix("daily-")
|
||||||
|
content = f"""---
|
||||||
|
id: coulomb-daily-{stem}
|
||||||
|
name: Daily test {stem}
|
||||||
|
enabled: true
|
||||||
|
trigger:
|
||||||
|
type: cron
|
||||||
|
cron_expression: "{cron}"
|
||||||
|
context_sources:
|
||||||
|
- type: kaizen
|
||||||
|
query: discover_kaizen_scheduled_repos
|
||||||
|
params:
|
||||||
|
cadence: {cadence}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Daily {stem}
|
||||||
|
"""
|
||||||
|
(repo / "activity-definitions" / name).write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _engagement_fixture(tmp_path: Path) -> Path:
|
||||||
|
repo = tmp_path / "coulomb-loop"
|
||||||
|
repo.mkdir()
|
||||||
|
_write_cadence(repo, "kaizen-stack", "kaizen-improvement-stack", "stabilize")
|
||||||
|
_write_cadence(repo, "registry-hygiene", "registry-hygiene", "stabilize")
|
||||||
|
_write_definition(repo, "daily-coach-orientation.md", "0 9 * * *", "daily")
|
||||||
|
_write_definition(repo, "daily-metrics-optimize.md", "0 8 * * *", "daily")
|
||||||
|
_write_definition(repo, "daily-optimization-review.md", "0 10 * * *", "daily")
|
||||||
|
_write_definition(repo, "daily-registry-hygiene-sweep.md", "0 7 * * *", "daily")
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"version": "1",
|
||||||
|
"loop": "kaizen-improvement-stack",
|
||||||
|
"phase": "bootstrap",
|
||||||
|
"active": [
|
||||||
|
{
|
||||||
|
"slug": "pilot-a",
|
||||||
|
"root": str(tmp_path / "pilot-a"),
|
||||||
|
"agents": ["coach", "optimization"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
roster_path = repo / "loops" / "kaizen-stack" / "roster.yaml"
|
||||||
|
roster_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
roster_path.write_text(yaml.safe_dump(roster, sort_keys=False), encoding="utf-8")
|
||||||
|
(tmp_path / "pilot-a").mkdir()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngagementPromote:
|
||||||
|
def test_dry_run_lists_all_layers(self, tmp_path: Path) -> None:
|
||||||
|
repo = _engagement_fixture(tmp_path)
|
||||||
|
result = promote_engagement(
|
||||||
|
repo,
|
||||||
|
to_phase="operate",
|
||||||
|
dry_run=True,
|
||||||
|
skip_sync=True,
|
||||||
|
)
|
||||||
|
layers = {a.layer for a in result.actions}
|
||||||
|
assert "cadence" in layers
|
||||||
|
assert "definitions" in layers
|
||||||
|
assert "fleet" in layers
|
||||||
|
assert result.ok
|
||||||
|
|
||||||
|
def test_promote_updates_cadence_and_definitions(self, tmp_path: Path) -> None:
|
||||||
|
repo = _engagement_fixture(tmp_path)
|
||||||
|
result = promote_engagement(
|
||||||
|
repo,
|
||||||
|
to_phase="operate",
|
||||||
|
skip_fleet=True,
|
||||||
|
skip_sync=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
cadence = yaml.safe_load(
|
||||||
|
(repo / "loops" / "kaizen-stack" / "cadence.yml").read_text()
|
||||||
|
)
|
||||||
|
assert cadence["phase"] == "operate"
|
||||||
|
assert "operate_target" not in cadence
|
||||||
|
assert (repo / "activity-definitions" / "weekly-coach-orientation.md").is_file()
|
||||||
|
assert not (
|
||||||
|
repo / "activity-definitions" / "daily-coach-orientation.md"
|
||||||
|
).is_file()
|
||||||
|
|
||||||
|
def test_fleet_only_writes_schedule(self, tmp_path: Path) -> None:
|
||||||
|
repo = _engagement_fixture(tmp_path)
|
||||||
|
# Pre-promote cadence so fleet-only path applies
|
||||||
|
promote_engagement(repo, to_phase="operate", skip_fleet=True, skip_sync=True)
|
||||||
|
result = promote_engagement(
|
||||||
|
repo,
|
||||||
|
to_phase="operate",
|
||||||
|
skip_cadence=True,
|
||||||
|
skip_definitions=True,
|
||||||
|
skip_sync=True,
|
||||||
|
)
|
||||||
|
sched = schedule_path(tmp_path / "pilot-a")
|
||||||
|
assert sched.is_file()
|
||||||
|
assert "cadence: weekly" in sched.read_text()
|
||||||
|
|
||||||
|
def test_cli_fleet_only(self, tmp_path: Path) -> None:
|
||||||
|
repo = _engagement_fixture(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"schedule",
|
||||||
|
"promote",
|
||||||
|
"--engagement-repo",
|
||||||
|
str(repo),
|
||||||
|
"--fleet-only",
|
||||||
|
"--skip-sync",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "[fleet]" in result.output
|
||||||
15
uv.lock
generated
15
uv.lock
generated
@@ -805,7 +805,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaizen-agentic"
|
name = "kaizen-agentic"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||||
@@ -835,6 +835,9 @@ dev = [
|
|||||||
{ name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
{ name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||||
{ name = "pytest-cov", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
{ name = "pytest-cov", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||||
]
|
]
|
||||||
|
events = [
|
||||||
|
{ name = "nats-py" },
|
||||||
|
]
|
||||||
test = [
|
test = [
|
||||||
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
|
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
|
||||||
@@ -852,6 +855,7 @@ requires-dist = [
|
|||||||
{ name = "click", specifier = ">=8.0.0" },
|
{ name = "click", specifier = ">=8.0.0" },
|
||||||
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=5.0.0" },
|
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=5.0.0" },
|
||||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
|
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
|
||||||
|
{ name = "nats-py", marker = "extra == 'events'", specifier = ">=2.6.0" },
|
||||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=6.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=6.0.0" },
|
||||||
@@ -1149,6 +1153,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nats-py"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user