synlynk 0.27.5__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.
- synlynk/__init__.py +3672 -0
- synlynk/__main__.py +4 -0
- synlynk/_constants.py +374 -0
- synlynk/_fast_cli.py +56 -0
- synlynk/_lazy.py +15 -0
- synlynk/addon.py +26 -0
- synlynk/advisory.py +131 -0
- synlynk/agent_cli.py +365 -0
- synlynk/agent_store.py +421 -0
- synlynk/approval_gate.py +41 -0
- synlynk/attribution.py +232 -0
- synlynk/autonomy.py +71 -0
- synlynk/backlog.py +1007 -0
- synlynk/backlog_extractor.py +126 -0
- synlynk/backup.py +325 -0
- synlynk/baseline.py +179 -0
- synlynk/board.py +344 -0
- synlynk/board_governance.py +214 -0
- synlynk/brief.py +67 -0
- synlynk/canon.py +230 -0
- synlynk/capability.py +258 -0
- synlynk/capability_backfill.py +196 -0
- synlynk/capability_baseline.json +38 -0
- synlynk/capability_classifier.py +72 -0
- synlynk/capability_pack.py +173 -0
- synlynk/capability_probe.py +339 -0
- synlynk/capability_report.py +336 -0
- synlynk/capability_roles.py +19 -0
- synlynk/capability_sweep.py +694 -0
- synlynk/capability_watch.py +368 -0
- synlynk/capability_window.py +250 -0
- synlynk/charter_injection.py +114 -0
- synlynk/charter_schema.py +207 -0
- synlynk/charters.py +664 -0
- synlynk/ci_paths.py +47 -0
- synlynk/circuit_breaker.py +332 -0
- synlynk/cli.py +3991 -0
- synlynk/coldstart.py +788 -0
- synlynk/completion_tracker.py +127 -0
- synlynk/concierge.py +33 -0
- synlynk/config_schema.py +105 -0
- synlynk/connectors.py +109 -0
- synlynk/container_exec.py +199 -0
- synlynk/context.py +625 -0
- synlynk/context_compaction.py +189 -0
- synlynk/context_validator.py +30 -0
- synlynk/cost_audit.py +1071 -0
- synlynk/costs.py +1447 -0
- synlynk/daemon.py +1902 -0
- synlynk/db.py +5700 -0
- synlynk/db_schema.py +642 -0
- synlynk/discovery.py +120 -0
- synlynk/discovery_semantic.py +11 -0
- synlynk/dispatch.py +6199 -0
- synlynk/dispatch_pipeline.py +128 -0
- synlynk/doctor.py +1947 -0
- synlynk/events.py +527 -0
- synlynk/examples.py +13 -0
- synlynk/fencing.py +55 -0
- synlynk/first_win.py +36 -0
- synlynk/first_win_loop.py +73 -0
- synlynk/fleet.py +667 -0
- synlynk/fleet_templates.py +112 -0
- synlynk/gap_scanner.py +59 -0
- synlynk/gateway.py +162 -0
- synlynk/gc_cmd.py +144 -0
- synlynk/gh_role.py +67 -0
- synlynk/gh_shim.py +211 -0
- synlynk/gh_sync.py +389 -0
- synlynk/gh_sync_backfill.py +71 -0
- synlynk/gh_sync_cli.py +51 -0
- synlynk/gh_verify.py +579 -0
- synlynk/git_ref_lock.py +39 -0
- synlynk/github_app_auth.py +317 -0
- synlynk/github_repo.py +17 -0
- synlynk/goal_synthesizer.py +240 -0
- synlynk/governs_cli.py +403 -0
- synlynk/governs_compressed.py +87 -0
- synlynk/governs_engine.py +261 -0
- synlynk/governs_fsm.py +140 -0
- synlynk/governs_gate.py +191 -0
- synlynk/governs_resolver.py +72 -0
- synlynk/graph_cache.py +278 -0
- synlynk/greenfield_blueprints.py +214 -0
- synlynk/handover.py +217 -0
- synlynk/harness_adapters/__init__.py +1 -0
- synlynk/harness_adapters/agy.py +45 -0
- synlynk/harness_adapters/base.py +54 -0
- synlynk/harness_adapters/claude.py +42 -0
- synlynk/harness_adapters/codex.py +66 -0
- synlynk/harness_adapters/generic_api.py +71 -0
- synlynk/harness_adapters/grok.py +56 -0
- synlynk/harness_adapters/local.py +69 -0
- synlynk/harness_adapters/muse.py +65 -0
- synlynk/harness_adapters/registry.py +31 -0
- synlynk/harness_adapters/request.py +37 -0
- synlynk/heal.py +383 -0
- synlynk/heal_cycles.py +183 -0
- synlynk/heal_orphans.py +127 -0
- synlynk/hud.py +474 -0
- synlynk/identity_roles.py +52 -0
- synlynk/impact.py +325 -0
- synlynk/install.py +43 -0
- synlynk/install_manifest.py +83 -0
- synlynk/instructions.py +1276 -0
- synlynk/jev.py +100 -0
- synlynk/job_status_projection.py +362 -0
- synlynk/job_truth.py +655 -0
- synlynk/jobs.py +6843 -0
- synlynk/launch.py +190 -0
- synlynk/launch_dag.py +485 -0
- synlynk/launch_templates.py +353 -0
- synlynk/lifecycle.py +194 -0
- synlynk/lineage.py +238 -0
- synlynk/local_agent.py +859 -0
- synlynk/local_agent_seed.py +55 -0
- synlynk/local_http_auth.py +107 -0
- synlynk/local_pilot.py +409 -0
- synlynk/local_pilot_ledger.py +574 -0
- synlynk/local_pilot_runner.py +361 -0
- synlynk/logs.py +116 -0
- synlynk/maintainer.py +235 -0
- synlynk/marketing.py +592 -0
- synlynk/media.py +207 -0
- synlynk/merge_class.py +35 -0
- synlynk/merge_oracle.py +90 -0
- synlynk/mesh.py +579 -0
- synlynk/migrations/__init__.py +16 -0
- synlynk/migrations/m0016_cost_entries_pr_number.py +25 -0
- synlynk/migrations/m0017_daemon_job_purpose.py +33 -0
- synlynk/migrations/m0018_job_provenance_attestations.py +39 -0
- synlynk/migrations/m0019_policy_gate_events.py +25 -0
- synlynk/migrations/m0020_quota_snapshots.py +27 -0
- synlynk/migrations/m0021_harness_model_catalog.py +82 -0
- synlynk/migrations/m0022_subscriptions_harness_name.py +37 -0
- synlynk/migrations/m0023_cleanse_execution_log_models.py +45 -0
- synlynk/migrations/m0024_local_pilot_ledger.py +119 -0
- synlynk/migrations/m0025_local_pilot_review_selection.py +20 -0
- synlynk/migrations/m0026_local_pilot_resume_authorization.py +59 -0
- synlynk/migrations/m0027_local_pilot_resume_lifecycle_serialization.py +32 -0
- synlynk/migrations/m0028_deprecate_execution_log_models.py +39 -0
- synlynk/migrations/m0029_roadmap_arcs_milestone_eligibility.py +69 -0
- synlynk/migrations/m0030_github_sync_map.py +31 -0
- synlynk/migrations/m0031_quota_burn_rate_weights.py +21 -0
- synlynk/migrations/runner.py +75 -0
- synlynk/models.py +1131 -0
- synlynk/multirepo_graph.py +145 -0
- synlynk/notifiers/__init__.py +0 -0
- synlynk/notifiers/slack.py +48 -0
- synlynk/observatory.py +337 -0
- synlynk/onboarding_state.py +188 -0
- synlynk/pack.py +305 -0
- synlynk/packs/agency.yaml +1 -0
- synlynk/packs/software-product.yaml +1 -0
- synlynk/packs/studio.yaml +1 -0
- synlynk/parity.py +532 -0
- synlynk/platform_ops.py +1013 -0
- synlynk/platform_status.py +191 -0
- synlynk/pm_agent.py +288 -0
- synlynk/policy.py +379 -0
- synlynk/policy_cli.py +88 -0
- synlynk/policy_gates.py +111 -0
- synlynk/pr_check.py +75 -0
- synlynk/pr_multiplier.py +144 -0
- synlynk/pr_rebase.py +68 -0
- synlynk/preflight.py +225 -0
- synlynk/probe.py +1869 -0
- synlynk/probe_binding.py +182 -0
- synlynk/process_lifecycle.py +326 -0
- synlynk/product_store.py +322 -0
- synlynk/provenance.py +198 -0
- synlynk/py.typed +0 -0
- synlynk/pytest_shim.py +63 -0
- synlynk/qa_gate.py +151 -0
- synlynk/quota.py +1182 -0
- synlynk/quota_capture.py +676 -0
- synlynk/readiness.py +481 -0
- synlynk/rebase.py +452 -0
- synlynk/regen_guard.py +90 -0
- synlynk/registry.py +47 -0
- synlynk/relay.py +402 -0
- synlynk/relay_hub.py +1190 -0
- synlynk/release_authority.py +133 -0
- synlynk/release_marketing.py +460 -0
- synlynk/release_readme.py +600 -0
- synlynk/release_signals.py +168 -0
- synlynk/repo_classifier.py +111 -0
- synlynk/rollback.py +327 -0
- synlynk/runners/__init__.py +6 -0
- synlynk/runners/base.py +24 -0
- synlynk/runners/fly.py +69 -0
- synlynk/runners/local.py +74 -0
- synlynk/runners/manager.py +91 -0
- synlynk/runtime.py +30 -0
- synlynk/sandbox.py +65 -0
- synlynk/scan.py +2124 -0
- synlynk/scheduler.py +315 -0
- synlynk/selftest.py +1658 -0
- synlynk/sentinel.py +1274 -0
- synlynk/session.py +108 -0
- synlynk/sfia.py +192 -0
- synlynk/spike.py +124 -0
- synlynk/standalone_venv.py +101 -0
- synlynk/state_inventory.py +374 -0
- synlynk/state_registry.py +271 -0
- synlynk/state_repair.py +266 -0
- synlynk/status.py +536 -0
- synlynk/story_claims.py +25 -0
- synlynk/story_governance.py +68 -0
- synlynk/story_provisioning.py +233 -0
- synlynk/support_engineer.py +669 -0
- synlynk/surface.py +113 -0
- synlynk/swarm.py +73 -0
- synlynk/taxonomy.py +708 -0
- synlynk/taxonomy_standards.py +133 -0
- synlynk/team.py +1292 -0
- synlynk/testbed/__init__.py +4 -0
- synlynk/testbed/cli.py +207 -0
- synlynk/testbed/driver.py +281 -0
- synlynk/testbed/identities.py +101 -0
- synlynk/testbed/installer.py +148 -0
- synlynk/testbed/invariants.py +65 -0
- synlynk/testbed/scenarios.py +128 -0
- synlynk/testbed/state_db_soak.py +544 -0
- synlynk/tool_installer.py +175 -0
- synlynk/topology_discovery.py +300 -0
- synlynk/tpm_hooks.py +99 -0
- synlynk/tpm_sweep.py +157 -0
- synlynk/tui.py +166 -0
- synlynk/types_registry.py +149 -0
- synlynk/uninstall.py +34 -0
- synlynk/upgrade.py +388 -0
- synlynk/ux_nudges.py +30 -0
- synlynk/uxcore.py +778 -0
- synlynk/verify_effects.py +414 -0
- synlynk/version_policy.py +58 -0
- synlynk/viz/__init__.py +78 -0
- synlynk/viz/activity.py +313 -0
- synlynk/viz/architect.py +760 -0
- synlynk/viz/banner.py +26 -0
- synlynk/viz/board.py +231 -0
- synlynk/viz/cache.py +89 -0
- synlynk/viz/canvases.py +2607 -0
- synlynk/viz/chrome.py +68 -0
- synlynk/viz/constants.py +15 -0
- synlynk/viz/data.py +898 -0
- synlynk/viz/efficiency.py +1241 -0
- synlynk/viz/effort.py +427 -0
- synlynk/viz/format.py +59 -0
- synlynk/viz/gantt.py +1215 -0
- synlynk/viz/gantt_model.py +44 -0
- synlynk/viz/graphify.py +496 -0
- synlynk/viz/heartbeats.py +192 -0
- synlynk/viz/home.py +686 -0
- synlynk/viz/journeys.py +560 -0
- synlynk/viz/logical_expand.py +189 -0
- synlynk/viz/nav.py +78 -0
- synlynk/viz/observatory.py +360 -0
- synlynk/viz/onboarding.py +529 -0
- synlynk/viz/overview.py +786 -0
- synlynk/viz/ports.py +31 -0
- synlynk/viz/roles.py +49 -0
- synlynk/viz/server.py +952 -0
- synlynk/viz/source.py +62 -0
- synlynk/viz/write_routes.py +17 -0
- synlynk/viz_views.py +987 -0
- synlynk/vizor_daemon.py +1906 -0
- synlynk/vizor_ensure.py +22 -0
- synlynk/wal_ledger.py +156 -0
- synlynk/watch.py +63 -0
- synlynk/wave6.py +65 -0
- synlynk/wizard.py +1358 -0
- synlynk/workspace.py +94 -0
- synlynk/workspace_agent.py +54 -0
- synlynk/worktree.py +592 -0
- synlynk/worktree_lease.py +359 -0
- synlynk/worktree_prune.py +259 -0
- synlynk/worktree_sparse.py +238 -0
- synlynk-0.27.5.dist-info/METADATA +179 -0
- synlynk-0.27.5.dist-info/RECORD +284 -0
- synlynk-0.27.5.dist-info/WHEEL +5 -0
- synlynk-0.27.5.dist-info/entry_points.txt +2 -0
- synlynk-0.27.5.dist-info/licenses/LICENSE +21 -0
- synlynk-0.27.5.dist-info/top_level.txt +1 -0
synlynk/__init__.py
ADDED
|
@@ -0,0 +1,3672 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import Optional, Tuple, Union
|
|
6
|
+
import sqlite3 as _sqlite3
|
|
7
|
+
import importlib as _importlib
|
|
8
|
+
|
|
9
|
+
from synlynk._constants import (
|
|
10
|
+
HARNESS_CAPABILITY_BASELINES,
|
|
11
|
+
HARNESS_TIMEOUT_PATTERNS,
|
|
12
|
+
QUOTA_PATTERNS,
|
|
13
|
+
VERSION,
|
|
14
|
+
_INSTALL_SCRIPT_URL,
|
|
15
|
+
NEXT_GEN_FLEET,
|
|
16
|
+
EXTENDED_FLEET,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = VERSION
|
|
20
|
+
|
|
21
|
+
_IS_TESTING = "pytest" in sys.modules or any("pytest" in str(arg) for arg in sys.argv)
|
|
22
|
+
_FAST_CLI = not _IS_TESTING and (
|
|
23
|
+
os.environ.get("SYNLYNK_CLI_ENTRYPOINT") == "1" or (
|
|
24
|
+
os.path.basename(sys.argv[0]) in {"synlynk", "synlynk.py"}
|
|
25
|
+
or (os.path.basename(sys.argv[0]) == "__main__.py" and "synlynk" in str(sys.argv[0]))
|
|
26
|
+
) or any(flag in sys.argv[1:] for flag in ("-h", "--help", "--version"))
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _vizor_daemon_process() -> bool:
|
|
31
|
+
"""True only for the machine-scoped Vizor daemon or its ensure tick.
|
|
32
|
+
|
|
33
|
+
Those processes start with cwd set to the daemon home, which is not a git
|
|
34
|
+
checkout. Skipping product-root resolution here must not weaken gh:#1831
|
|
35
|
+
for any other import.
|
|
36
|
+
"""
|
|
37
|
+
if os.environ.get("SYNLYNK_VIZOR_DAEMON") == "1":
|
|
38
|
+
return True
|
|
39
|
+
# python -m synlynk.vizor_daemon sets argv[0] to the module file, so the
|
|
40
|
+
# basename covers that invocation. A later argv entry that only names the
|
|
41
|
+
# module is not this process (gh:#1831).
|
|
42
|
+
names = {"vizor_daemon.py", "vizor_ensure.py"}
|
|
43
|
+
argv0 = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
44
|
+
return argv0 in names
|
|
45
|
+
|
|
46
|
+
if not _FAST_CLI:
|
|
47
|
+
import argparse
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from synlynk.db import _try_sync_arc_by_version
|
|
50
|
+
|
|
51
|
+
# The compatibility surface below historically relied on these names being
|
|
52
|
+
# available from the package module. Keep that behavior for imports/tests,
|
|
53
|
+
# but do not pay for the modules on the subprocess CLI fast path.
|
|
54
|
+
if not _FAST_CLI:
|
|
55
|
+
import json
|
|
56
|
+
import shutil
|
|
57
|
+
import subprocess
|
|
58
|
+
import tempfile
|
|
59
|
+
import threading
|
|
60
|
+
import time
|
|
61
|
+
import urllib.request
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_LEGACY_MODULES = (
|
|
65
|
+
"upgrade", "sentinel", "probe", "fencing", "dispatch", "quota", "costs",
|
|
66
|
+
"capability_roles", "taxonomy", "doctor", "team", "heal", "support_engineer",
|
|
67
|
+
"backlog", "backlog_extractor", "charter_injection", "context", "jobs",
|
|
68
|
+
"story_provisioning", "daemon", "instructions", "scan", "hud", "platform_status",
|
|
69
|
+
"logs", "wizard", "launch", "circuit_breaker", "capability_probe", "wal_ledger",
|
|
70
|
+
"worktree_lease", "governs_compressed",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_legacy_imports():
|
|
75
|
+
"""Load the historical package-level exports on demand.
|
|
76
|
+
|
|
77
|
+
The package has long re-exported nearly every CLI implementation symbol.
|
|
78
|
+
Keep that API, but avoid importing the entire command graph while argparse
|
|
79
|
+
is only constructing or displaying the parser.
|
|
80
|
+
"""
|
|
81
|
+
# Restore module globals used by the older package-level helpers when a
|
|
82
|
+
# non-observability command leaves the fast CLI path.
|
|
83
|
+
for module_name in ("json", "shutil", "subprocess", "tempfile", "threading", "time"):
|
|
84
|
+
module = _importlib.import_module(module_name)
|
|
85
|
+
globals()[module_name] = module
|
|
86
|
+
globals()["urllib"] = _importlib.import_module("urllib")
|
|
87
|
+
_importlib.import_module("urllib.request")
|
|
88
|
+
launch_templates = _importlib.import_module("synlynk.launch_templates")
|
|
89
|
+
db_schema = _importlib.import_module("synlynk.db_schema")
|
|
90
|
+
globals()["LAUNCH_TASK_TEMPLATES"] = launch_templates.LAUNCH_TASK_TEMPLATES
|
|
91
|
+
globals()["_DB_SCHEMA"] = db_schema._DB_SCHEMA
|
|
92
|
+
globals()["_DB_SCORES_VIEW"] = db_schema._DB_SCORES_VIEW
|
|
93
|
+
|
|
94
|
+
for module_name in _LEGACY_MODULES:
|
|
95
|
+
module = _importlib.import_module(f"synlynk.{module_name}")
|
|
96
|
+
globals().update({
|
|
97
|
+
name: value
|
|
98
|
+
for name, value in vars(module).items()
|
|
99
|
+
if not name.startswith("__") and name not in globals()
|
|
100
|
+
})
|
|
101
|
+
# Importing a submodule also installs it as a package attribute. Restore
|
|
102
|
+
# the historical function export where the names intentionally collide.
|
|
103
|
+
globals()["upgrade"] = _importlib.import_module("synlynk.upgrade").upgrade
|
|
104
|
+
globals()["cmd_ecosystem_status"] = _importlib.import_module("synlynk.status").cmd_status
|
|
105
|
+
globals()["cmd_viz"] = _importlib.import_module("synlynk.viz").cmd_viz
|
|
106
|
+
globals()["cmd_schedule"] = _importlib.import_module("synlynk.scheduler").cmd_schedule
|
|
107
|
+
globals()["main"], globals()["cmd_watch"] = (
|
|
108
|
+
_importlib.import_module("synlynk.cli").main,
|
|
109
|
+
_importlib.import_module("synlynk.cli").cmd_watch,
|
|
110
|
+
)
|
|
111
|
+
db = _importlib.import_module("synlynk.db")
|
|
112
|
+
globals().update({
|
|
113
|
+
name: value
|
|
114
|
+
for name, value in vars(db).items()
|
|
115
|
+
if not name.startswith("__") and name not in globals()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main(argv=None):
|
|
120
|
+
"""Lazy compatibility entry point for installed console scripts."""
|
|
121
|
+
cli_tokens = list(argv) if argv is not None else sys.argv[1:]
|
|
122
|
+
if _FAST_CLI and cli_tokens and cli_tokens[0] in {"jobs", "status"}:
|
|
123
|
+
from synlynk._fast_cli import run_fast_command
|
|
124
|
+
|
|
125
|
+
if run_fast_command(cli_tokens) is True:
|
|
126
|
+
return None
|
|
127
|
+
return _importlib.import_module("synlynk.cli").main(argv)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def __getattr__(name):
|
|
131
|
+
"""Lazily preserve compatibility exports removed from the fast path."""
|
|
132
|
+
if name in {"LAUNCH_TASK_TEMPLATES", "_DB_SCHEMA", "_DB_SCORES_VIEW"}:
|
|
133
|
+
module_name = "synlynk.launch_templates" if name == "LAUNCH_TASK_TEMPLATES" else "synlynk.db_schema"
|
|
134
|
+
value = getattr(_importlib.import_module(module_name), name)
|
|
135
|
+
globals()[name] = value
|
|
136
|
+
return value
|
|
137
|
+
raise AttributeError(name)
|
|
138
|
+
|
|
139
|
+
CYCLE_COLORS = {
|
|
140
|
+
"dream": "#a78bfa",
|
|
141
|
+
"design": "#60a5fa",
|
|
142
|
+
"plan": "#34d399",
|
|
143
|
+
"build": "#fbbf24",
|
|
144
|
+
"ship": "#f87171",
|
|
145
|
+
"sustain": "#94a3b8",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
CYCLE_DESCRIPTIONS = {
|
|
149
|
+
"dream": "What's worth building\nIdeate, assess, identify opportunities\n",
|
|
150
|
+
"design": "Brainstorm -> spec -> UX\nTurn ideas into a concrete brief\n",
|
|
151
|
+
"plan": "Implementation plan, story breakdown, agent wave schedule\n",
|
|
152
|
+
"build": "Dispatch agents, run jobs, iterate on diffs\n",
|
|
153
|
+
"ship": "Cut release, changelog, publish\n",
|
|
154
|
+
"sustain": "Monitor, patch, community, docs, support\n",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
CYCLE_DEFAULT_AGENTS = {
|
|
158
|
+
"dream": ["claude"],
|
|
159
|
+
"design": ["claude"],
|
|
160
|
+
"plan": ["claude"],
|
|
161
|
+
"build": ["agy", "codex", "grok"],
|
|
162
|
+
"ship": ["claude"],
|
|
163
|
+
"sustain": ["claude", "agy", "codex", "grok"],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
CORE_TEMPLATE_IDS = {"arch-review", "product-assessment", "lifecycle-setup"}
|
|
167
|
+
|
|
168
|
+
# Capability scores within this gap are considered ties → break on cost (#140).
|
|
169
|
+
_CAPABILITY_COST_TIE_GAP = 0.15
|
|
170
|
+
|
|
171
|
+
def _launch_visible_template_ids() -> set:
|
|
172
|
+
tier1_primary_ids = {
|
|
173
|
+
entry["command"]
|
|
174
|
+
for entry in entries_for_tier(1)
|
|
175
|
+
if entry["prominence"] == "primary"
|
|
176
|
+
}
|
|
177
|
+
return CORE_TEMPLATE_IDS | tier1_primary_ids
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _launch_visible_templates() -> list:
|
|
181
|
+
from synlynk.launch_templates import LAUNCH_TASK_TEMPLATES
|
|
182
|
+
|
|
183
|
+
visible_ids = _launch_visible_template_ids()
|
|
184
|
+
return [template for template in LAUNCH_TASK_TEMPLATES if template["id"] in visible_ids]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _template_matches(template: dict, scan: dict) -> bool:
|
|
188
|
+
"""Returns True if the template's trigger condition is met by scan."""
|
|
189
|
+
condition = template.get("trigger_condition")
|
|
190
|
+
if condition is None:
|
|
191
|
+
return True
|
|
192
|
+
try:
|
|
193
|
+
return bool(condition(scan))
|
|
194
|
+
except Exception:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _select_launch_tasks(scan: dict) -> list:
|
|
199
|
+
"""Returns ordered list of 3-5 matching templates (core first, bonus sorted by specificity)."""
|
|
200
|
+
eligible = [t for t in _launch_visible_templates() if _template_matches(t, scan)]
|
|
201
|
+
core = [t for t in eligible if t["id"] in CORE_TEMPLATE_IDS]
|
|
202
|
+
bonus = [t for t in eligible if t["id"] not in CORE_TEMPLATE_IDS]
|
|
203
|
+
return (core + bonus)[:5]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _render_prompt(template: dict, scan: dict) -> str:
|
|
207
|
+
"""Substitutes {variables} in prompt_template from scan data. Missing vars become ''."""
|
|
208
|
+
import datetime as _datetime
|
|
209
|
+
import re as _re
|
|
210
|
+
|
|
211
|
+
repos = scan.get("repos", [])
|
|
212
|
+
primary = repos[0] if repos else {}
|
|
213
|
+
tests = scan.get("tests") or {}
|
|
214
|
+
source = scan.get("source") or []
|
|
215
|
+
complexity = scan.get("complexity") or {}
|
|
216
|
+
git = scan.get("git") or {}
|
|
217
|
+
gap_functions = tests.get("gap_functions", [])
|
|
218
|
+
if gap_functions:
|
|
219
|
+
gap_functions_text = ", ".join(
|
|
220
|
+
g.get("name", str(g)) if isinstance(g, dict) else str(g)
|
|
221
|
+
for g in gap_functions
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
gap_functions_text = "none"
|
|
225
|
+
typed_pct = (
|
|
226
|
+
int(sum(f.get("typed_pct", 0) for f in source) / max(len(source), 1))
|
|
227
|
+
if source
|
|
228
|
+
else int(scan.get("typed_pct", 0) or 0)
|
|
229
|
+
)
|
|
230
|
+
largest_file = max(source, key=lambda f: f.get("lines", 0), default={})
|
|
231
|
+
largest_fn = {}
|
|
232
|
+
for file_info in source:
|
|
233
|
+
for fn in file_info.get("largest_fns", []):
|
|
234
|
+
if fn.get("lines", 0) > largest_fn.get("lines", 0):
|
|
235
|
+
largest_fn = {
|
|
236
|
+
"name": fn.get("name", ""),
|
|
237
|
+
"lines": fn.get("lines", 0),
|
|
238
|
+
"file": file_info.get("path", ""),
|
|
239
|
+
}
|
|
240
|
+
hotspots = complexity.get("hotspots", [])
|
|
241
|
+
top_hotspot = max(hotspots, key=lambda h: h.get("lines", 0), default={})
|
|
242
|
+
churn = git.get("churn", [])
|
|
243
|
+
hot_file = churn[0].get("path", "") if churn else ""
|
|
244
|
+
commit_count = churn[0].get("commits", 0) if churn else 0
|
|
245
|
+
variables = {
|
|
246
|
+
"workspace": scan.get("workspace_name", ""),
|
|
247
|
+
"stack": ", ".join(primary.get("stack_labels", [])) or "unknown",
|
|
248
|
+
"repo_name": primary.get("name", ""),
|
|
249
|
+
"topology": scan.get("topology", "single"),
|
|
250
|
+
"test_count": str(scan.get("test_ratio", 0)),
|
|
251
|
+
"gap_functions": gap_functions_text,
|
|
252
|
+
"typed_pct": str(typed_pct),
|
|
253
|
+
"largest_file": largest_file.get("path", ""),
|
|
254
|
+
"largest_file_lines": str(largest_file.get("lines", 0)),
|
|
255
|
+
"largest_file_fns": str(largest_file.get("functions", 0)),
|
|
256
|
+
"largest_fn": largest_fn.get("name", ""),
|
|
257
|
+
"largest_fn_lines": str(largest_fn.get("lines", 0)),
|
|
258
|
+
"top_hotspot": top_hotspot.get("fn") or os.path.basename(top_hotspot.get("path", "")),
|
|
259
|
+
"top_hotspot_lines": str(top_hotspot.get("lines", 0)),
|
|
260
|
+
"hot_file": hot_file,
|
|
261
|
+
"commit_count": str(commit_count),
|
|
262
|
+
"date": _datetime.date.today().isoformat(),
|
|
263
|
+
"agent": template.get("agent", "claude"),
|
|
264
|
+
}
|
|
265
|
+
text = template.get("prompt_template", "")
|
|
266
|
+
|
|
267
|
+
def _replace(match):
|
|
268
|
+
key = match.group(1)
|
|
269
|
+
return variables.get(key, "")
|
|
270
|
+
|
|
271
|
+
return _re.sub(r"\{(\w+)\}", _replace, text)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
TASK_STATUSES = {
|
|
275
|
+
"[ ]": "active",
|
|
276
|
+
"[x]": "done",
|
|
277
|
+
"[-]": "deferred",
|
|
278
|
+
"[~]": "superseded",
|
|
279
|
+
"[>]": "absorbed",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _project_root() -> str:
|
|
284
|
+
"""Return the shared repo root for the current git worktree.
|
|
285
|
+
|
|
286
|
+
Raises RuntimeError if git-common-dir resolution fails, unless
|
|
287
|
+
SYNLYNK_ALLOW_CWD_FALLBACK=1 is set (see gh:#1831 — a silent CWD
|
|
288
|
+
fallback here previously minted duplicate per-directory legacy
|
|
289
|
+
state.db shards for the same project).
|
|
290
|
+
"""
|
|
291
|
+
import subprocess
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
common = subprocess.check_output(
|
|
295
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
296
|
+
stderr=subprocess.DEVNULL,
|
|
297
|
+
).decode().strip()
|
|
298
|
+
if common:
|
|
299
|
+
return os.path.abspath(os.path.join(common, ".."))
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
if os.environ.get("SYNLYNK_ALLOW_CWD_FALLBACK") == "1":
|
|
302
|
+
return os.getcwd()
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
"could not resolve git-common-dir; refusing to silently fall back "
|
|
305
|
+
"to the current working directory (this previously minted stray "
|
|
306
|
+
"per-directory state shards — see gh:#1831). Set "
|
|
307
|
+
"SYNLYNK_ALLOW_CWD_FALLBACK=1 to override."
|
|
308
|
+
) from exc
|
|
309
|
+
return os.getcwd()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _working_tree_root() -> str:
|
|
313
|
+
"""Return the root of the current checkout, not its shared Git directory."""
|
|
314
|
+
import subprocess
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
root = subprocess.check_output(
|
|
318
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
319
|
+
stderr=subprocess.DEVNULL,
|
|
320
|
+
).decode().strip()
|
|
321
|
+
if root:
|
|
322
|
+
return os.path.abspath(root)
|
|
323
|
+
raise ValueError("git returned an empty worktree root")
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
if os.environ.get("SYNLYNK_ALLOW_CWD_FALLBACK") == "1":
|
|
326
|
+
return os.getcwd()
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
"could not resolve git worktree root; refusing to write generated "
|
|
329
|
+
"project docs relative to an unknown checkout"
|
|
330
|
+
) from exc
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _get_project_root() -> str:
|
|
334
|
+
"""Backwards-compatible alias for callers that expect a root helper."""
|
|
335
|
+
return _project_root()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _resolve_db_path() -> str:
|
|
339
|
+
"""Resolve the product graph, migrating the legacy path once if needed."""
|
|
340
|
+
if os.environ.get("SYNLYNK_STATE_DB_PATH"):
|
|
341
|
+
return os.environ["SYNLYNK_STATE_DB_PATH"]
|
|
342
|
+
|
|
343
|
+
from synlynk.product_store import identity_slug_from_config, migrate_state_db_if_needed, state_db_path
|
|
344
|
+
from synlynk.state_registry import canonical_path
|
|
345
|
+
|
|
346
|
+
root = _project_root()
|
|
347
|
+
slug = identity_slug_from_config(root)
|
|
348
|
+
try:
|
|
349
|
+
path = canonical_path(
|
|
350
|
+
slug,
|
|
351
|
+
state_db_path(slug),
|
|
352
|
+
allow_unregistered_existing=os.environ.get("SYNLYNK_ALLOW_REGISTRY_RECOVERY") == "1",
|
|
353
|
+
)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
if "no canonical registry entry" in str(exc):
|
|
356
|
+
if not os.path.exists(os.path.join(root, ".synlynk", "config.json")):
|
|
357
|
+
return str(state_db_path(slug))
|
|
358
|
+
raise type(exc)(
|
|
359
|
+
f"{exc}; run SYNLYNK_ALLOW_REGISTRY_RECOVERY=1 synlynk state register "
|
|
360
|
+
f"--slug {slug} --path {state_db_path(slug)}"
|
|
361
|
+
) from exc
|
|
362
|
+
raise
|
|
363
|
+
if not _IS_TESTING:
|
|
364
|
+
try:
|
|
365
|
+
migrate_state_db_if_needed(root)
|
|
366
|
+
except (PermissionError, OSError):
|
|
367
|
+
pass
|
|
368
|
+
return str(path)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _is_git_worktree() -> bool:
|
|
372
|
+
"""Return whether the current directory is a linked git worktree."""
|
|
373
|
+
import subprocess
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
git_dir_output = subprocess.check_output(
|
|
377
|
+
["git", "rev-parse", "--path-format=absolute", "--git-dir"],
|
|
378
|
+
stderr=subprocess.DEVNULL,
|
|
379
|
+
)
|
|
380
|
+
common_dir_output = subprocess.check_output(
|
|
381
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
382
|
+
stderr=subprocess.DEVNULL,
|
|
383
|
+
)
|
|
384
|
+
git_dir = git_dir_output.decode() if isinstance(git_dir_output, bytes) else str(git_dir_output)
|
|
385
|
+
common_dir = common_dir_output.decode() if isinstance(common_dir_output, bytes) else str(common_dir_output)
|
|
386
|
+
git_dir = git_dir.strip()
|
|
387
|
+
common_dir = common_dir.strip()
|
|
388
|
+
return os.path.realpath(git_dir) != os.path.realpath(common_dir)
|
|
389
|
+
except (OSError, subprocess.CalledProcessError):
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Resolve once at import time so ordinary test doubles for subprocess calls do
|
|
394
|
+
# not accidentally change DB-path safety decisions mid-test.
|
|
395
|
+
# The linked-worktree guard only protects pytest from accidentally touching the
|
|
396
|
+
# shared product ledger. The CLI fast path has no pytest state to isolate and
|
|
397
|
+
# must not pay for two git subprocesses before a read-only command starts.
|
|
398
|
+
_INITIAL_GIT_WORKTREE = False if (_FAST_CLI or _vizor_daemon_process()) else _is_git_worktree()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _test_isolation_db_path() -> str:
|
|
402
|
+
"""Return a process-local DB path for pytest worktree execution."""
|
|
403
|
+
import hashlib
|
|
404
|
+
import tempfile
|
|
405
|
+
|
|
406
|
+
key = hashlib.md5(os.path.abspath(os.getcwd()).encode()).hexdigest()[:12]
|
|
407
|
+
return os.path.join(tempfile.gettempdir(), "synlynk-test-db", f"{key}-{os.getpid()}.db")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _should_isolate_worktree_db(db_path: str) -> bool:
|
|
411
|
+
"""Limit the worktree guard to test execution unless explicitly disabled."""
|
|
412
|
+
if os.environ.get("SYNLYNK_ALLOW_SHARED_STATE_DB") == "1":
|
|
413
|
+
return False
|
|
414
|
+
if not (_INITIAL_GIT_WORKTREE and os.environ.get("PYTEST_CURRENT_TEST")):
|
|
415
|
+
return False
|
|
416
|
+
# Explicit test DB_PATH overrides (including tmp_path fixtures) are already
|
|
417
|
+
# isolated and must keep working in a worktree. Only the canonical product
|
|
418
|
+
# ledger location is guarded.
|
|
419
|
+
canonical_root = os.path.abspath(os.path.expanduser("~/.synlynk/projects"))
|
|
420
|
+
return os.path.abspath(db_path).startswith(canonical_root + os.sep)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# The Vizor daemon home is not a checkout. Point DB_PATH at devnull so import
|
|
424
|
+
# cannot mint a product ledger there (gh:#1831). Nothing in the daemon opens it.
|
|
425
|
+
if _FAST_CLI:
|
|
426
|
+
DB_PATH = os.environ.get("SYNLYNK_STATE_DB_PATH")
|
|
427
|
+
elif _vizor_daemon_process():
|
|
428
|
+
DB_PATH = os.devnull
|
|
429
|
+
else:
|
|
430
|
+
DB_PATH = _resolve_db_path()
|
|
431
|
+
# The configured path is intentionally distinct from the path selected by the
|
|
432
|
+
# last successful connection. Away sandboxes may need the local fallback.
|
|
433
|
+
ACTIVE_DB_PATH = None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _migrate_db_with_schema_retry(conn: _sqlite3.Connection) -> None:
|
|
437
|
+
"""Retry the one SQLite error caused by a concurrent schema refresh.
|
|
438
|
+
|
|
439
|
+
Two short-lived CLI connections can both bootstrap an older state DB. One
|
|
440
|
+
connection may commit an additive migration after the other prepared a
|
|
441
|
+
schema statement, which SQLite reports as ``database schema has changed``.
|
|
442
|
+
The migration is idempotent; retrying it refreshes that connection's schema
|
|
443
|
+
cache. Other operational failures remain fail-closed.
|
|
444
|
+
"""
|
|
445
|
+
from synlynk.db import _migrate_db
|
|
446
|
+
|
|
447
|
+
for attempt in range(3):
|
|
448
|
+
try:
|
|
449
|
+
_migrate_db(conn)
|
|
450
|
+
return
|
|
451
|
+
except _sqlite3.OperationalError as exc:
|
|
452
|
+
if "database schema has changed" not in str(exc).lower() or attempt == 2:
|
|
453
|
+
raise
|
|
454
|
+
try:
|
|
455
|
+
conn.rollback()
|
|
456
|
+
except _sqlite3.Error:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def migrate_cost_entries_for_compaction(db_path: str) -> None:
|
|
461
|
+
conn = _sqlite3.connect(db_path)
|
|
462
|
+
try:
|
|
463
|
+
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(cost_entries)")}
|
|
464
|
+
if "compaction_event_type" not in existing_cols:
|
|
465
|
+
conn.execute("ALTER TABLE cost_entries ADD COLUMN compaction_event_type TEXT")
|
|
466
|
+
conn.commit()
|
|
467
|
+
finally:
|
|
468
|
+
conn.close()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def log_cost_entry(db_path: str, harness: str, tokens_in: int, tokens_out: int,
|
|
472
|
+
total_cost_usd: float, compaction_event_type=None) -> None:
|
|
473
|
+
"""Record a compaction-related cost row through the single sanctioned
|
|
474
|
+
cost_entries write path (db.py's _insert_cost_row), not raw SQL here."""
|
|
475
|
+
import datetime
|
|
476
|
+
from synlynk.db import _insert_cost_row
|
|
477
|
+
|
|
478
|
+
_insert_cost_row(
|
|
479
|
+
session_date=datetime.date.today().isoformat(),
|
|
480
|
+
agent=harness,
|
|
481
|
+
harness=harness,
|
|
482
|
+
input_tokens=tokens_in,
|
|
483
|
+
output_tokens=tokens_out,
|
|
484
|
+
total_cost_usd=total_cost_usd,
|
|
485
|
+
compaction_event_type=compaction_event_type,
|
|
486
|
+
db_path=db_path,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _get_db(
|
|
493
|
+
db_path: str = None,
|
|
494
|
+
read_only: bool = False,
|
|
495
|
+
*,
|
|
496
|
+
migrate: bool = True,
|
|
497
|
+
) -> _sqlite3.Connection:
|
|
498
|
+
"""Return a SQLite connection to state.db.
|
|
499
|
+
|
|
500
|
+
``read_only=True`` opens the selected ledger through SQLite's read-only
|
|
501
|
+
URI mode and deliberately skips directory creation, WAL setup, and
|
|
502
|
+
migrations. Inspection callers use this mode so status cannot mutate or
|
|
503
|
+
silently substitute the product ledger.
|
|
504
|
+
|
|
505
|
+
SYNLYNK_STATE_DB_PATH, if set, is used verbatim and takes precedence over
|
|
506
|
+
everything below: no nested-worktree guard, no fallback chain. A caller
|
|
507
|
+
setting this env var has made a deliberate choice about ledger location
|
|
508
|
+
(e.g. a sandbox restricted to the repo workspace root, where neither the
|
|
509
|
+
home path nor the tmpdir fallback is reachable). If that path is itself
|
|
510
|
+
unwritable, the resulting exception propagates uncaught — an explicit
|
|
511
|
+
override that fails should surface loudly, not be silently re-routed.
|
|
512
|
+
See #681.
|
|
513
|
+
|
|
514
|
+
Fallback to ./.synlynk/state.db is disabled by default. A local ledger is
|
|
515
|
+
a different source of truth and must never be selected silently when the
|
|
516
|
+
product ledger is unavailable. Sandboxed callers that explicitly accept
|
|
517
|
+
an isolated, non-canonical ledger may set
|
|
518
|
+
SYNLYNK_ALLOW_STATE_DB_FALLBACK=1. Dispatched-agent sandboxes commonly
|
|
519
|
+
mount $HOME read-only; that surfaces as OSError(EROFS) from os.makedirs
|
|
520
|
+
(not PermissionError) or as sqlite3.OperationalError from connect when
|
|
521
|
+
the directory already exists. See #648 and the Sev1 state-integrity
|
|
522
|
+
incident #1655.
|
|
523
|
+
|
|
524
|
+
Primary product ledger must not live under job/feature worktrees when the
|
|
525
|
+
home path is the intended path (#330 / fleet S2a). If an operator
|
|
526
|
+
explicitly enables the sandbox fallback, it uses a path that never lands
|
|
527
|
+
under worktrees (tmpdir when cwd is a job/feature worktree) so the
|
|
528
|
+
nested_state matrix stays clean.
|
|
529
|
+
|
|
530
|
+
During pytest execution from a linked worktree, the canonical shared path is
|
|
531
|
+
redirected to a process-local temporary DB to protect the live ledger. Set
|
|
532
|
+
SYNLYNK_ALLOW_SHARED_STATE_DB=1 only for tests that explicitly exercise
|
|
533
|
+
shared-DB behavior.
|
|
534
|
+
"""
|
|
535
|
+
global ACTIVE_DB_PATH, DB_PATH
|
|
536
|
+
ACTIVE_DB_PATH = None
|
|
537
|
+
|
|
538
|
+
if DB_PATH is None:
|
|
539
|
+
DB_PATH = _resolve_db_path()
|
|
540
|
+
|
|
541
|
+
def _check_write_capability(path: str) -> None:
|
|
542
|
+
"""Check access to *path* without opening SQLite or changing it."""
|
|
543
|
+
import tempfile
|
|
544
|
+
|
|
545
|
+
parent = os.path.dirname(path) or "."
|
|
546
|
+
if os.path.exists(path):
|
|
547
|
+
fd = os.open(path, os.O_RDWR)
|
|
548
|
+
os.close(fd)
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
# A new database has no file to open yet. Probe the parent directory
|
|
552
|
+
# with a disposable file instead of creating the candidate database
|
|
553
|
+
# before its write capability has been established.
|
|
554
|
+
fd, probe = tempfile.mkstemp(dir=parent, prefix=".synlynk-write-probe-")
|
|
555
|
+
os.close(fd)
|
|
556
|
+
os.unlink(probe)
|
|
557
|
+
|
|
558
|
+
def _connect(path: str, *, read_only: bool = False, migrate: bool = True) -> _sqlite3.Connection:
|
|
559
|
+
global ACTIVE_DB_PATH
|
|
560
|
+
if read_only:
|
|
561
|
+
wal_path = f"{path}-wal"
|
|
562
|
+
has_wal = os.path.exists(wal_path) and os.path.getsize(wal_path) > 0
|
|
563
|
+
if has_wal:
|
|
564
|
+
# An immutable connection intentionally ignores WAL frames and
|
|
565
|
+
# would expose stale state. Take a consistent online-backup
|
|
566
|
+
# snapshot into memory instead; callers still get a read-only
|
|
567
|
+
# inspection connection without mutating the source ledger.
|
|
568
|
+
source_conn = _sqlite3.connect(
|
|
569
|
+
f"file:{os.path.abspath(path)}?mode=ro", uri=True, timeout=30.0
|
|
570
|
+
)
|
|
571
|
+
conn = _sqlite3.connect(":memory:")
|
|
572
|
+
try:
|
|
573
|
+
source_conn.backup(conn)
|
|
574
|
+
finally:
|
|
575
|
+
source_conn.close()
|
|
576
|
+
else:
|
|
577
|
+
conn = _sqlite3.connect(
|
|
578
|
+
f"file:{os.path.abspath(path)}?mode=ro&immutable=1",
|
|
579
|
+
uri=True,
|
|
580
|
+
timeout=30.0,
|
|
581
|
+
)
|
|
582
|
+
try:
|
|
583
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
584
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
585
|
+
quick_check = conn.execute("PRAGMA quick_check").fetchone()[0]
|
|
586
|
+
if quick_check != "ok":
|
|
587
|
+
raise _sqlite3.DatabaseError(
|
|
588
|
+
f"state database failed quick_check: {quick_check}"
|
|
589
|
+
)
|
|
590
|
+
except Exception:
|
|
591
|
+
conn.close()
|
|
592
|
+
raise
|
|
593
|
+
ACTIVE_DB_PATH = os.path.abspath(path)
|
|
594
|
+
return conn
|
|
595
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
596
|
+
_check_write_capability(path)
|
|
597
|
+
conn = _sqlite3.connect(path, timeout=30.0)
|
|
598
|
+
try:
|
|
599
|
+
conn.isolation_level = None
|
|
600
|
+
# Do this before WAL setup or migrations. BEGIN IMMEDIATE only
|
|
601
|
+
# acquires a write reservation; rolling it back leaves the
|
|
602
|
+
# candidate database and its sidecars byte-identical on failure.
|
|
603
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
604
|
+
conn.execute("ROLLBACK")
|
|
605
|
+
from synlynk.wal_ledger import ensure_wal_pragmas
|
|
606
|
+
ensure_wal_pragmas(conn)
|
|
607
|
+
if migrate:
|
|
608
|
+
_migrate_db_with_schema_retry(conn)
|
|
609
|
+
if migrate and not _IS_TESTING and os.path.abspath(path) == os.path.abspath(DB_PATH):
|
|
610
|
+
from synlynk.product_store import identity_slug_from_config
|
|
611
|
+
from synlynk.state_registry import ensure_registered_product, identity_metadata, product_identity
|
|
612
|
+
|
|
613
|
+
slug = identity_slug_from_config(_project_root())
|
|
614
|
+
repo_root = Path(_project_root())
|
|
615
|
+
entry = ensure_registered_product(
|
|
616
|
+
slug,
|
|
617
|
+
Path(path),
|
|
618
|
+
product_identity(slug, _project_root()),
|
|
619
|
+
repo_path=repo_root,
|
|
620
|
+
)
|
|
621
|
+
if entry.get("repo_path") != str(repo_root.resolve()):
|
|
622
|
+
from synlynk.state_registry import update_registered_product
|
|
623
|
+
|
|
624
|
+
update_registered_product(slug, repo_path=str(repo_root.resolve()))
|
|
625
|
+
identity_metadata(conn, product_id=entry["product_id"], mode="canonical", path=Path(path))
|
|
626
|
+
conn.commit()
|
|
627
|
+
except Exception:
|
|
628
|
+
conn.close()
|
|
629
|
+
raise
|
|
630
|
+
ACTIVE_DB_PATH = os.path.abspath(path)
|
|
631
|
+
return conn
|
|
632
|
+
|
|
633
|
+
if db_path is not None:
|
|
634
|
+
return _connect(db_path, read_only=read_only, migrate=migrate)
|
|
635
|
+
|
|
636
|
+
override = os.environ.get("SYNLYNK_STATE_DB_PATH")
|
|
637
|
+
if override:
|
|
638
|
+
# Explicit overrides remain authoritative: failures propagate and do
|
|
639
|
+
# not silently select another ledger.
|
|
640
|
+
return _connect(override, read_only=read_only, migrate=migrate)
|
|
641
|
+
|
|
642
|
+
if read_only:
|
|
643
|
+
# Read-only inspection must fail on an unavailable/corrupt canonical
|
|
644
|
+
# ledger rather than falling back to a stale repo or sandbox copy.
|
|
645
|
+
return _connect(DB_PATH, read_only=True, migrate=migrate)
|
|
646
|
+
|
|
647
|
+
from synlynk.fleet import assert_not_nested_product_ledger, sandbox_fallback_db_path
|
|
648
|
+
|
|
649
|
+
db_path = DB_PATH
|
|
650
|
+
if _should_isolate_worktree_db(db_path):
|
|
651
|
+
db_path = _test_isolation_db_path()
|
|
652
|
+
print(
|
|
653
|
+
"warning: pytest is running from a git worktree; using isolated "
|
|
654
|
+
f"state DB at {db_path}. Set SYNLYNK_ALLOW_SHARED_STATE_DB=1 only "
|
|
655
|
+
"for an intentional shared-DB test.",
|
|
656
|
+
file=sys.stderr,
|
|
657
|
+
)
|
|
658
|
+
try:
|
|
659
|
+
assert_not_nested_product_ledger(db_path, home_writable=True)
|
|
660
|
+
return _connect(db_path, migrate=migrate)
|
|
661
|
+
# RuntimeError from nested-ledger refusal must not trigger fallback.
|
|
662
|
+
except (OSError, _sqlite3.OperationalError) as exc:
|
|
663
|
+
if os.environ.get("SYNLYNK_ALLOW_STATE_DB_FALLBACK") != "1":
|
|
664
|
+
raise RuntimeError(
|
|
665
|
+
"canonical state DB unavailable or unusable at "
|
|
666
|
+
f"{os.path.abspath(db_path)}: {exc}; refusing to select a "
|
|
667
|
+
"non-canonical fallback. Set SYNLYNK_STATE_DB_PATH to an "
|
|
668
|
+
"explicit verified ledger, or set "
|
|
669
|
+
"SYNLYNK_ALLOW_STATE_DB_FALLBACK=1 only for an intentional "
|
|
670
|
+
"isolated sandbox."
|
|
671
|
+
) from exc
|
|
672
|
+
fallback_path = sandbox_fallback_db_path()
|
|
673
|
+
print(
|
|
674
|
+
f"warning: cannot open canonical project state DB at {db_path} "
|
|
675
|
+
f"({exc}); explicitly falling back with "
|
|
676
|
+
"SYNLYNK_ALLOW_STATE_DB_FALLBACK=1 — using isolated "
|
|
677
|
+
f"ledger {fallback_path}",
|
|
678
|
+
file=sys.stderr,
|
|
679
|
+
)
|
|
680
|
+
return _connect(fallback_path, migrate=migrate)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def get_state_db_path() -> str:
|
|
684
|
+
"""Return the effective state DB path, including the last fallback choice."""
|
|
685
|
+
return os.path.abspath(
|
|
686
|
+
os.environ.get("SYNLYNK_STATE_DB_PATH") or ACTIVE_DB_PATH or DB_PATH
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def open_state_db(
|
|
691
|
+
*,
|
|
692
|
+
product_id: str | None = None,
|
|
693
|
+
mode: str = "canonical",
|
|
694
|
+
read_only: bool = False,
|
|
695
|
+
explicit_path: str | None = None,
|
|
696
|
+
) -> _sqlite3.Connection:
|
|
697
|
+
"""Open a state ledger through an explicit, typed access mode.
|
|
698
|
+
|
|
699
|
+
``canonical`` is the only mode used by normal product commands. The
|
|
700
|
+
other modes are intentionally explicit so tests, backup verification, and
|
|
701
|
+
restore tooling cannot accidentally become an alternate product ledger.
|
|
702
|
+
Degraded ledgers are read-only unless an operator explicitly opts into a
|
|
703
|
+
write with ``SYNLYNK_ALLOW_DEGRADED_WRITES=1``.
|
|
704
|
+
"""
|
|
705
|
+
allowed = {"canonical", "test", "backup", "restore", "degraded"}
|
|
706
|
+
if mode not in allowed:
|
|
707
|
+
raise ValueError(f"unsupported state DB mode: {mode!r}")
|
|
708
|
+
if mode == "canonical":
|
|
709
|
+
if explicit_path is not None:
|
|
710
|
+
from synlynk.product_store import identity_slug_from_config, state_db_path
|
|
711
|
+
from synlynk.state_registry import canonical_path
|
|
712
|
+
|
|
713
|
+
slug = identity_slug_from_config(_project_root())
|
|
714
|
+
expected = canonical_path(slug, state_db_path(slug)).resolve()
|
|
715
|
+
requested = Path(explicit_path).expanduser().resolve()
|
|
716
|
+
if requested != expected:
|
|
717
|
+
raise RuntimeError(
|
|
718
|
+
f"canonical state DB path mismatch: expected {expected}, requested {requested}"
|
|
719
|
+
)
|
|
720
|
+
return _get_db(db_path=explicit_path, read_only=read_only) if explicit_path else _get_db(read_only=read_only)
|
|
721
|
+
if explicit_path is None:
|
|
722
|
+
raise ValueError(f"mode={mode!r} requires explicit_path")
|
|
723
|
+
if mode == "degraded" and not read_only and os.environ.get("SYNLYNK_ALLOW_DEGRADED_WRITES") != "1":
|
|
724
|
+
raise RuntimeError(
|
|
725
|
+
"degraded state DB access is read-only by default; set "
|
|
726
|
+
"SYNLYNK_ALLOW_DEGRADED_WRITES=1 for an intentional write"
|
|
727
|
+
)
|
|
728
|
+
return _get_db(db_path=explicit_path, read_only=read_only or mode in {"backup"})
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _is_migrated() -> bool:
|
|
733
|
+
return os.path.exists(os.path.join(_project_root(), ".synlynk", ".synlynk_migrated"))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
_GENERATED_DOC_FILES = ("roadmap.md", "todo.md", "memory.md", "costs.md")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _generated_docs_locked(replace_generated_docs: bool = False, root: str = None) -> bool:
|
|
740
|
+
"""True when init/brownfield must not raw-write the generated 4-docs.
|
|
741
|
+
|
|
742
|
+
Checks the given root and its git-common-dir parent so a linked worktree
|
|
743
|
+
of a migrated product is locked even if the worktree has no local marker.
|
|
744
|
+
"""
|
|
745
|
+
if replace_generated_docs:
|
|
746
|
+
return False
|
|
747
|
+
check_roots = []
|
|
748
|
+
if root:
|
|
749
|
+
check_roots.append(os.path.abspath(root))
|
|
750
|
+
try:
|
|
751
|
+
common = subprocess.check_output(
|
|
752
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
753
|
+
cwd=root,
|
|
754
|
+
stderr=subprocess.DEVNULL,
|
|
755
|
+
).decode().strip()
|
|
756
|
+
if common:
|
|
757
|
+
check_roots.append(os.path.abspath(os.path.join(common, "..")))
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
else:
|
|
761
|
+
check_roots.append(_project_root())
|
|
762
|
+
check_roots.append(os.getcwd())
|
|
763
|
+
seen = set()
|
|
764
|
+
for r in check_roots:
|
|
765
|
+
real = os.path.realpath(r)
|
|
766
|
+
if real in seen:
|
|
767
|
+
continue
|
|
768
|
+
seen.add(real)
|
|
769
|
+
if os.path.exists(os.path.join(real, ".synlynk", ".synlynk_migrated")):
|
|
770
|
+
return True
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _synlynk_project_docs_dir() -> str:
|
|
775
|
+
return os.path.join(_project_root(), ".synlynk", "project-docs")
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _dr_sync(relative_path: str) -> None:
|
|
779
|
+
if not _generated_doc_side_effects_allowed():
|
|
780
|
+
return
|
|
781
|
+
|
|
782
|
+
from synlynk.db import _resolved_docs_dir
|
|
783
|
+
|
|
784
|
+
# Root resolution is mandatory even when the DR destination is absolute:
|
|
785
|
+
# the source document must belong to the invoking checkout.
|
|
786
|
+
root = _working_tree_root()
|
|
787
|
+
src = os.path.join(_resolved_docs_dir(), relative_path)
|
|
788
|
+
try:
|
|
789
|
+
workspace_path = os.path.join(root, '.synlynk', 'workspace.json')
|
|
790
|
+
legacy_path = os.path.join(root, '.synlynk', 'config.json')
|
|
791
|
+
cfg_path = workspace_path if os.path.exists(workspace_path) else legacy_path
|
|
792
|
+
if not os.path.exists(cfg_path):
|
|
793
|
+
return
|
|
794
|
+
with open(cfg_path) as f:
|
|
795
|
+
cfg = json.load(f)
|
|
796
|
+
dr_path = cfg.get('dr_sync_path')
|
|
797
|
+
if not dr_path:
|
|
798
|
+
return
|
|
799
|
+
dr_path = os.path.expanduser(str(dr_path))
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
return
|
|
803
|
+
if not os.path.isabs(dr_path):
|
|
804
|
+
dr_path = os.path.abspath(os.path.join(root, dr_path))
|
|
805
|
+
try:
|
|
806
|
+
inside_root = os.path.commonpath(
|
|
807
|
+
[os.path.realpath(root), os.path.realpath(dr_path)]
|
|
808
|
+
) == os.path.realpath(root)
|
|
809
|
+
except ValueError:
|
|
810
|
+
inside_root = False
|
|
811
|
+
if not inside_root:
|
|
812
|
+
raise RuntimeError("relative dr_sync_path escapes the current checkout")
|
|
813
|
+
if not os.path.isdir(dr_path) or not os.path.exists(src):
|
|
814
|
+
return
|
|
815
|
+
dst = os.path.join(dr_path, 'project-docs', relative_path)
|
|
816
|
+
try:
|
|
817
|
+
os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
|
|
818
|
+
import shutil as _shutil
|
|
819
|
+
_shutil.copy2(src, dst)
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _generated_doc_side_effects_allowed() -> bool:
|
|
825
|
+
"""Return false for dispatcher jobs that must not emit project-wide docs."""
|
|
826
|
+
return "SYNLYNK_JOB_ID" not in os.environ
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _seed_verb_map(db_conn):
|
|
830
|
+
db_conn.executemany("""
|
|
831
|
+
INSERT OR IGNORE INTO harness_verb_map
|
|
832
|
+
(synlynk_verb, verb_category, harness_name, harness_command, supported, partial_notes)
|
|
833
|
+
VALUES (?,?,?,?,?,?)
|
|
834
|
+
""", _VERB_MAP_SEED)
|
|
835
|
+
db_conn.commit()
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _check_verb_support(verb: str, harness_name: str, db_conn) -> dict:
|
|
839
|
+
row = db_conn.execute(
|
|
840
|
+
"SELECT supported, partial_notes, harness_command FROM harness_verb_map WHERE synlynk_verb=? AND harness_name=?",
|
|
841
|
+
(verb, harness_name)
|
|
842
|
+
).fetchone()
|
|
843
|
+
if not row:
|
|
844
|
+
return {"supported": "unknown", "block": False, "warn": False, "notes": None, "command": None}
|
|
845
|
+
supported, notes, cmd = row
|
|
846
|
+
return {
|
|
847
|
+
"supported": supported,
|
|
848
|
+
"block": supported == "none",
|
|
849
|
+
"warn": supported == "partial",
|
|
850
|
+
"notes": notes,
|
|
851
|
+
"command": cmd,
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _load_agent_config(name: str) -> dict:
|
|
856
|
+
"""Load .harnesses/<name>.json or .agents/<name>.json. Raises FileNotFoundError with clear message."""
|
|
857
|
+
import json as _json
|
|
858
|
+
candidates = [
|
|
859
|
+
os.path.join(".harnesses", f"{name}.json"),
|
|
860
|
+
os.path.join("harnesses", f"{name}.json"),
|
|
861
|
+
os.path.join(".agents", f"{name}.json"),
|
|
862
|
+
os.path.join("agents", f"{name}.json"),
|
|
863
|
+
]
|
|
864
|
+
path = next((candidate for candidate in candidates if os.path.exists(candidate)), candidates[0])
|
|
865
|
+
if not os.path.exists(path):
|
|
866
|
+
raise FileNotFoundError(f"No agent config found at {path}")
|
|
867
|
+
with open(path) as f:
|
|
868
|
+
return _json.load(f)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _load_agent_profile(harness_name: str, agents_dir: str = ".agents") -> dict:
|
|
872
|
+
"""Load a harness profile and normalize harness/model defaults."""
|
|
873
|
+
import json as _json
|
|
874
|
+
|
|
875
|
+
candidates = [
|
|
876
|
+
os.path.join(agents_dir, f"{harness_name}.json"),
|
|
877
|
+
os.path.join(".harnesses", f"{harness_name}.json"),
|
|
878
|
+
os.path.join("harnesses", f"{harness_name}.json"),
|
|
879
|
+
os.path.join(".agents", f"{harness_name}.json"),
|
|
880
|
+
os.path.join("agents", f"{harness_name}.json"),
|
|
881
|
+
]
|
|
882
|
+
path = next((candidate for candidate in candidates if os.path.exists(candidate)), candidates[0])
|
|
883
|
+
if not os.path.exists(path):
|
|
884
|
+
return {"agent": harness_name, "harness": harness_name, "model": "unknown"}
|
|
885
|
+
try:
|
|
886
|
+
with open(path) as f:
|
|
887
|
+
profile = _json.load(f)
|
|
888
|
+
except (OSError, ValueError, TypeError):
|
|
889
|
+
return {"agent": harness_name, "harness": harness_name, "model": "unknown"}
|
|
890
|
+
profile.setdefault("harness", harness_name)
|
|
891
|
+
profile.setdefault("model", "unknown")
|
|
892
|
+
return profile
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _dispatch_flags_for_agent(agent: str) -> list:
|
|
896
|
+
"""Return the executable dispatch flags for a harness baseline.
|
|
897
|
+
|
|
898
|
+
Supports both the legacy list form and the structured mapping form.
|
|
899
|
+
"""
|
|
900
|
+
baselines = HARNESS_CAPABILITY_BASELINES.get(agent, {})
|
|
901
|
+
dispatch_flags = baselines.get("dispatch_flags", [])
|
|
902
|
+
if isinstance(dispatch_flags, dict):
|
|
903
|
+
ordered = []
|
|
904
|
+
for flag in dispatch_flags.get("required_flags", []) or []:
|
|
905
|
+
if flag not in ordered:
|
|
906
|
+
ordered.append(flag)
|
|
907
|
+
return ordered
|
|
908
|
+
return list(dispatch_flags or [])
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def cmd_roles(fix: bool = False) -> None:
|
|
912
|
+
"""Print current agent role table from config.
|
|
913
|
+
|
|
914
|
+
With --fix, regenerate role fences and repair SOP sections.
|
|
915
|
+
"""
|
|
916
|
+
cfg = load_config()
|
|
917
|
+
roles = cfg.get("roles", {})
|
|
918
|
+
if isinstance(roles, dict):
|
|
919
|
+
visible_agents = [agent for agent in roles if agent]
|
|
920
|
+
elif isinstance(roles, (list, tuple)):
|
|
921
|
+
visible_agents = [agent for agent in roles if agent]
|
|
922
|
+
else:
|
|
923
|
+
visible_agents = []
|
|
924
|
+
|
|
925
|
+
print(f"\n {_BOLD}synlynk roles{_RESET}\n")
|
|
926
|
+
print(f" {'agent':<10} {'roles':<40} {'directive file':<12} fence")
|
|
927
|
+
print(f" {'─' * 10} {'─' * 40} {'─' * 12} {'─' * 10}")
|
|
928
|
+
|
|
929
|
+
for agent in visible_agents:
|
|
930
|
+
role_list = roles.get(agent, []) if isinstance(roles, dict) else []
|
|
931
|
+
fname = _directive_file_for_agent(agent)
|
|
932
|
+
roles_str = ", ".join(role_list) if isinstance(role_list, list) else str(role_list)
|
|
933
|
+
file_exists = os.path.exists(fname)
|
|
934
|
+
fence_present = False
|
|
935
|
+
if file_exists:
|
|
936
|
+
try:
|
|
937
|
+
with open(fname) as f:
|
|
938
|
+
fence_present = "<!-- synlynk:harness" in f.read()
|
|
939
|
+
except IOError:
|
|
940
|
+
pass
|
|
941
|
+
file_status = fname if file_exists else f"{fname} (missing)"
|
|
942
|
+
fence_status = f"{_GREEN}✓{_RESET}" if fence_present else f"{_YELLOW}missing{_RESET}"
|
|
943
|
+
print(f" {agent:<10} {roles_str:<40} {file_status:<12} {fence_status}")
|
|
944
|
+
|
|
945
|
+
if fix and file_exists and not fence_present:
|
|
946
|
+
roles_line = ", ".join(role_list) if isinstance(role_list, list) else str(role_list)
|
|
947
|
+
try:
|
|
948
|
+
_upsert_harness_fence(
|
|
949
|
+
fname,
|
|
950
|
+
harness_version="roles",
|
|
951
|
+
body=f"## Your Role\n{roles_line}\n",
|
|
952
|
+
)
|
|
953
|
+
print(f" {_GREEN}✓{_RESET} wrote role fence to {fname}")
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
print(f" {_YELLOW}⚠{_RESET} could not write {fname}: {exc}")
|
|
956
|
+
|
|
957
|
+
if fix:
|
|
958
|
+
try:
|
|
959
|
+
_repair_sops_only(cfg=cfg, dry_run=False)
|
|
960
|
+
except Exception as exc:
|
|
961
|
+
print(f" {_YELLOW}⚠{_RESET} could not repair SOP sections: {exc}")
|
|
962
|
+
|
|
963
|
+
print()
|
|
964
|
+
if not fix:
|
|
965
|
+
missing = [
|
|
966
|
+
_directive_file_for_agent(a)
|
|
967
|
+
for a in visible_agents
|
|
968
|
+
if os.path.exists(_directive_file_for_agent(a))
|
|
969
|
+
and not _fence_exists(_directive_file_for_agent(a))
|
|
970
|
+
]
|
|
971
|
+
if missing:
|
|
972
|
+
print(f" {_DIM}Run `synlynk roles --fix` to write missing role fences and repair SOP sections{_RESET}\n")
|
|
973
|
+
if not visible_agents:
|
|
974
|
+
print(f" {_DIM}No agents in roles to display{_RESET}\n")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
_ENGG_DOMAIN_PATTERNS = [
|
|
980
|
+
("data", [r"etl", r"pipeline/", r"schema\.sql", r"migrations/", r"dbt/"]),
|
|
981
|
+
("ml", [r"ml/", r"models/", r"train\.", r"inference/", r"embeddings/"]),
|
|
982
|
+
("security", [r"auth/", r"oauth", r"jwt", r"crypto", r"certs/"]),
|
|
983
|
+
("devops", [r"\.github/", r"dockerfile", r"terraform", r"pulumi", r"k8s/", r"helm/"]),
|
|
984
|
+
("frontend", [r"components/", r"pages/", r"\.tsx?", r"\.vue", r"\.svelte", r"styles/"]),
|
|
985
|
+
("backend", [r"api/", r"routes/", r"handlers/", r"controllers/", r"services/"]),
|
|
986
|
+
("testing", [r"tests/", r"test_", r"spec/", r"\.spec\.", r"fixtures/"]),
|
|
987
|
+
("docs", [r"docs/", r"readme", r"\.md$", r"changelogs?"]),
|
|
988
|
+
("architecture", [r"design/", r"specs/", r"adr/", r"diagrams/"]),
|
|
989
|
+
]
|
|
990
|
+
|
|
991
|
+
def _infer_engg_domain(log_text: str) -> str:
|
|
992
|
+
"""Infers engineering domain from file path patterns in job log output."""
|
|
993
|
+
lower = log_text.lower()
|
|
994
|
+
for domain, patterns in _ENGG_DOMAIN_PATTERNS:
|
|
995
|
+
if any(re.search(p, lower) for p in patterns):
|
|
996
|
+
return domain
|
|
997
|
+
return "unknown"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
JOBS_FILE = ".synlynk/jobs.json"
|
|
1001
|
+
LOGS_DIR = ".synlynk/logs"
|
|
1002
|
+
PROMPTS_DIR = ".synlynk/prompts"
|
|
1003
|
+
|
|
1004
|
+
_VERB_MAP_SEED = [
|
|
1005
|
+
# (synlynk_verb, category, agent, harness_command, supported, partial_notes)
|
|
1006
|
+
("dispatch.task", "dispatch", "claude", "claude --print {task}", "full", None),
|
|
1007
|
+
("dispatch.task", "dispatch", "agy", "agy -p {task}", "full", None),
|
|
1008
|
+
("dispatch.task", "dispatch", "grok", "grok --single {task}", "full", None),
|
|
1009
|
+
("dispatch.task", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
|
|
1010
|
+
("dispatch.headless", "dispatch", "claude", "claude --print {task}", "full", None),
|
|
1011
|
+
("dispatch.headless", "dispatch", "agy", "agy -p {task}", "partial", "May hang without PTY on some agy versions"),
|
|
1012
|
+
("dispatch.headless", "dispatch", "grok", "grok --single {task}", "partial", "Network dep required"),
|
|
1013
|
+
("dispatch.headless", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
|
|
1014
|
+
("dispatch.resume", "dispatch", "claude", "claude --resume {session_id}", "full", None),
|
|
1015
|
+
("dispatch.resume", "dispatch", "agy", None, "none", None),
|
|
1016
|
+
("dispatch.resume", "dispatch", "grok", None, "none", None),
|
|
1017
|
+
("dispatch.resume", "dispatch", "codex", None, "none", None),
|
|
1018
|
+
("dispatch.approve", "dispatch", "claude", "claude --allowedTools {tools}", "full", None),
|
|
1019
|
+
("dispatch.approve", "dispatch", "agy", None, "none", None),
|
|
1020
|
+
("dispatch.approve", "dispatch", "grok", None, "none", None),
|
|
1021
|
+
("dispatch.approve", "dispatch", "codex", None, "partial", "ask-for-approval=untrusted only"),
|
|
1022
|
+
("dispatch.model", "dispatch", "claude", "--model {model}", "full", None),
|
|
1023
|
+
("dispatch.model", "dispatch", "agy", "--model {model}", "full", None),
|
|
1024
|
+
("dispatch.model", "dispatch", "grok", "--model {model}", "full", None),
|
|
1025
|
+
("dispatch.model", "dispatch", "codex", "--model {model}", "full", None),
|
|
1026
|
+
("dispatch.tools", "dispatch", "claude", "--allowedTools {tools}", "full", None),
|
|
1027
|
+
("dispatch.tools", "dispatch", "agy", None, "partial", "No tool_list flag"),
|
|
1028
|
+
("dispatch.tools", "dispatch", "grok", None, "none", None),
|
|
1029
|
+
("dispatch.tools", "dispatch", "codex", None, "partial", "ask-for-approval only"),
|
|
1030
|
+
("dispatch.context", "dispatch", "claude", "claude --print {task}", "full", None),
|
|
1031
|
+
("dispatch.context", "dispatch", "agy", "agy -p {task}", "full", None),
|
|
1032
|
+
("dispatch.context", "dispatch", "grok", "grok --prompt {task}", "partial", "No explicit context file flag"),
|
|
1033
|
+
("dispatch.context", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
|
|
1034
|
+
("jobs", "observability", "claude", None, "partial", "No native jobs subcommand"),
|
|
1035
|
+
("jobs", "observability", "agy", None, "none", None),
|
|
1036
|
+
("jobs", "observability", "grok", None, "none", None),
|
|
1037
|
+
("jobs", "observability", "codex", None, "none", None),
|
|
1038
|
+
("status", "observability", "claude", None, "partial", None),
|
|
1039
|
+
("status", "observability", "agy", None, "none", None),
|
|
1040
|
+
("status", "observability", "grok", None, "none", None),
|
|
1041
|
+
("status", "observability", "codex", None, "none", None),
|
|
1042
|
+
("telemetry", "observability", "claude", None, "none", None),
|
|
1043
|
+
("telemetry", "observability", "agy", None, "none", None),
|
|
1044
|
+
("telemetry", "observability", "grok", None, "none", None),
|
|
1045
|
+
("telemetry", "observability", "codex", None, "none", None),
|
|
1046
|
+
("costs", "observability", "claude", None, "partial", "Token count via /cost"),
|
|
1047
|
+
("costs", "observability", "agy", None, "none", None),
|
|
1048
|
+
("costs", "observability", "grok", None, "none", None),
|
|
1049
|
+
("costs", "observability", "codex", None, "none", None),
|
|
1050
|
+
("probe", "harness", "claude", "claude --version", "full", None),
|
|
1051
|
+
("probe", "harness", "agy", "agy --version", "full", None),
|
|
1052
|
+
("probe", "harness", "grok", "grok --version", "full", None),
|
|
1053
|
+
("probe", "harness", "codex", "codex --version", "full", None),
|
|
1054
|
+
("doctor", "harness", "claude", None, "full", None),
|
|
1055
|
+
("doctor", "harness", "agy", None, "full", None),
|
|
1056
|
+
("doctor", "harness", "grok", None, "full", None),
|
|
1057
|
+
("doctor", "harness", "codex", None, "full", None),
|
|
1058
|
+
("story", "pm", "claude", None, "none", None),
|
|
1059
|
+
("story", "pm", "agy", None, "none", None),
|
|
1060
|
+
("story", "pm", "grok", None, "none", None),
|
|
1061
|
+
("story", "pm", "codex", None, "none", None),
|
|
1062
|
+
("epic", "pm", "claude", None, "none", None),
|
|
1063
|
+
("epic", "pm", "agy", None, "none", None),
|
|
1064
|
+
("epic", "pm", "grok", None, "none", None),
|
|
1065
|
+
("epic", "pm", "codex", None, "none", None),
|
|
1066
|
+
("decide", "pm", "claude", None, "none", None),
|
|
1067
|
+
("decide", "pm", "agy", None, "none", None),
|
|
1068
|
+
("decide", "pm", "grok", None, "none", None),
|
|
1069
|
+
("decide", "pm", "codex", None, "none", None),
|
|
1070
|
+
("workspace", "workspace", "claude", None, "none", None),
|
|
1071
|
+
("workspace", "workspace", "agy", None, "none", None),
|
|
1072
|
+
("workspace", "workspace", "grok", None, "none", None),
|
|
1073
|
+
("workspace", "workspace", "codex", None, "none", None),
|
|
1074
|
+
("upgrade", "workspace", "claude", None, "partial", "Via /upgrade slash command"),
|
|
1075
|
+
("upgrade", "workspace", "agy", None, "partial", "Via agy update"),
|
|
1076
|
+
("upgrade", "workspace", "grok", None, "partial", None),
|
|
1077
|
+
("upgrade", "workspace", "codex", None, "partial", None),
|
|
1078
|
+
]
|
|
1079
|
+
|
|
1080
|
+
RELAY_EVENT_TYPES = frozenset({
|
|
1081
|
+
"story_updated",
|
|
1082
|
+
"job_dispatched",
|
|
1083
|
+
"job_completed",
|
|
1084
|
+
"alert_raised",
|
|
1085
|
+
"context_checkpoint",
|
|
1086
|
+
"table_changed",
|
|
1087
|
+
"broadcast",
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _build_relay_event(event_type: str, payload: dict) -> dict:
|
|
1092
|
+
"""Constructs a relay event dict with required base fields."""
|
|
1093
|
+
if event_type not in RELAY_EVENT_TYPES:
|
|
1094
|
+
raise ValueError(
|
|
1095
|
+
f"unknown event type '{event_type}'. Valid: {sorted(RELAY_EVENT_TYPES)}"
|
|
1096
|
+
)
|
|
1097
|
+
import socket as _socket
|
|
1098
|
+
|
|
1099
|
+
event = {
|
|
1100
|
+
"type": event_type,
|
|
1101
|
+
"ts": int(time.time()),
|
|
1102
|
+
"origin_node": _socket.gethostname(),
|
|
1103
|
+
}
|
|
1104
|
+
event.update(payload)
|
|
1105
|
+
return event
|
|
1106
|
+
|
|
1107
|
+
# Default paths scanned for agent CLI config directories.
|
|
1108
|
+
# Overridable in .synlynk/config.json under "agent_discovery_paths".
|
|
1109
|
+
AGENT_DISCOVERY_DEFAULTS = {
|
|
1110
|
+
"claude": os.path.expanduser("~/.claude"),
|
|
1111
|
+
"codex": os.path.expanduser("~/.codex"),
|
|
1112
|
+
"agy": os.path.expanduser("~/.agy"),
|
|
1113
|
+
"grok": os.path.expanduser("~/.grok"),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
_AGENT_DIRECTIVE_FILES = {
|
|
1117
|
+
"claude": "CLAUDE.md",
|
|
1118
|
+
"agy": "GEMINI.md",
|
|
1119
|
+
"grok": "GROK.md",
|
|
1120
|
+
"codex": "AGENTS.md",
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _default_roles_for_agent(agent: str) -> list:
|
|
1125
|
+
"""Return the default onboarded role list for an agent."""
|
|
1126
|
+
return {
|
|
1127
|
+
"claude": ["pm", "review", "deploy"],
|
|
1128
|
+
"agy": ["implement", "test", "css", "templates", "content"],
|
|
1129
|
+
"grok": ["implement", "test", "canvas", "js", "infra"],
|
|
1130
|
+
"codex": ["implement", "test", "refactor"],
|
|
1131
|
+
}.get(agent, [])
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _default_roles_map() -> dict:
|
|
1135
|
+
"""Return the default role mapping for supported agents."""
|
|
1136
|
+
return {name: _default_roles_for_agent(name) for name in _AGENT_DIRECTIVE_FILES}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _directive_file_for_agent(agent: str) -> str:
|
|
1140
|
+
return _AGENT_DIRECTIVE_FILES.get(agent, f"{agent}.md")
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _write_json_atomic(path: str, payload: dict) -> None:
|
|
1144
|
+
"""Write a JSON file atomically within the current workspace."""
|
|
1145
|
+
directory = os.path.dirname(path) or "."
|
|
1146
|
+
os.makedirs(directory, exist_ok=True)
|
|
1147
|
+
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp.", suffix=".json")
|
|
1148
|
+
try:
|
|
1149
|
+
with os.fdopen(fd, "w") as f:
|
|
1150
|
+
json.dump(payload, f, indent=2)
|
|
1151
|
+
f.write("\n")
|
|
1152
|
+
f.flush()
|
|
1153
|
+
os.fsync(f.fileno())
|
|
1154
|
+
os.replace(tmp_path, path)
|
|
1155
|
+
finally:
|
|
1156
|
+
if os.path.exists(tmp_path):
|
|
1157
|
+
try:
|
|
1158
|
+
os.remove(tmp_path)
|
|
1159
|
+
except OSError:
|
|
1160
|
+
pass
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
_WORKSPACE_KEYS = [
|
|
1164
|
+
"org", "owner", "repo", "project_id", "identity_slug", "project_docs_dir",
|
|
1165
|
+
"workspace_id", "agent_slots", "workgroup_agents", "agents", "team",
|
|
1166
|
+
"sync_endpoint", "dispatch", "local_auto_threshold", "local_fallback",
|
|
1167
|
+
"watch_interval_seconds", "auto_smoke_test", "auto_launch_after_wizard",
|
|
1168
|
+
"dispatch_mode", "fenced_commands", "nudges", "exec_timeout_minutes",
|
|
1169
|
+
"stall_timeout_minutes", "review_stall_timeout_minutes", "swarm_runners",
|
|
1170
|
+
"last_housekeeping_date", "features", "repo_id", "dr_sync_path", "mode",
|
|
1171
|
+
"termination_grace_seconds", "cleanup_verification_timeout_seconds",
|
|
1172
|
+
]
|
|
1173
|
+
_BILLING_KEYS = ["budget", "harness_billing", "payment_models", "capability_sweep"]
|
|
1174
|
+
_POLICY_MIGRATED_KEYS = ["qa_gate_mode", "roles", "story_classification", "sentinel"]
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _apply_workspace_defaults(config: dict) -> dict:
|
|
1178
|
+
"""Fill in schema-v1 defaults for a workspace config dict, in place and returned."""
|
|
1179
|
+
defaults = {
|
|
1180
|
+
"schema_version": 1,
|
|
1181
|
+
"dispatch": {"stacking": "auto", "gate_suite_cmd": ""},
|
|
1182
|
+
"local_auto_threshold": 0.5,
|
|
1183
|
+
"local_fallback": "agy",
|
|
1184
|
+
"watch_interval_seconds": 30,
|
|
1185
|
+
"auto_smoke_test": False,
|
|
1186
|
+
"auto_launch_after_wizard": True,
|
|
1187
|
+
"dispatch_mode": "daily-grind",
|
|
1188
|
+
"fenced_commands": ["dispatch", "jobs", "exec", "schedule"],
|
|
1189
|
+
"nudges": {"enabled": True, "dismissed_ids": [], "last_shown": {}},
|
|
1190
|
+
"org": None,
|
|
1191
|
+
"owner": None,
|
|
1192
|
+
"repo": None,
|
|
1193
|
+
"project_id": None,
|
|
1194
|
+
"identity_slug": None,
|
|
1195
|
+
"project_docs_dir": "project-docs",
|
|
1196
|
+
"agent_slots": {"claude": "claude", "agy": "agy", "codex": "codex", "grok": "grok"},
|
|
1197
|
+
"workgroup_agents": [],
|
|
1198
|
+
"last_housekeeping_date": None,
|
|
1199
|
+
"team": None,
|
|
1200
|
+
"sync_endpoint": None,
|
|
1201
|
+
"exec_timeout_minutes": 30,
|
|
1202
|
+
"stall_timeout_minutes": 30,
|
|
1203
|
+
"swarm_runners": {"default": "local", "enabled": ["local"], "timeout_seconds": 900},
|
|
1204
|
+
"review_stall_timeout_minutes": 90,
|
|
1205
|
+
"termination_grace_seconds": 5,
|
|
1206
|
+
"cleanup_verification_timeout_seconds": 300,
|
|
1207
|
+
"agents": {},
|
|
1208
|
+
"features": {},
|
|
1209
|
+
"repo_id": None,
|
|
1210
|
+
"dr_sync_path": None,
|
|
1211
|
+
"mode": "solo",
|
|
1212
|
+
}
|
|
1213
|
+
if not isinstance(config, dict):
|
|
1214
|
+
config = {}
|
|
1215
|
+
for key, val in defaults.items():
|
|
1216
|
+
if key not in config:
|
|
1217
|
+
config[key] = val
|
|
1218
|
+
for key, val in defaults["dispatch"].items():
|
|
1219
|
+
if key not in config.get("dispatch", {}):
|
|
1220
|
+
config.setdefault("dispatch", {})[key] = val
|
|
1221
|
+
for key, val in defaults["nudges"].items():
|
|
1222
|
+
if key not in config.get("nudges", {}):
|
|
1223
|
+
config.setdefault("nudges", {})[key] = val
|
|
1224
|
+
return config
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def load_workspace() -> dict:
|
|
1228
|
+
"""Load .synlynk/workspace.json with schema-v1 defaults."""
|
|
1229
|
+
config_file = ".synlynk/workspace.json"
|
|
1230
|
+
if not os.path.exists(config_file):
|
|
1231
|
+
return _apply_workspace_defaults({})
|
|
1232
|
+
try:
|
|
1233
|
+
with open(config_file) as f:
|
|
1234
|
+
config = json.load(f)
|
|
1235
|
+
if not isinstance(config, dict):
|
|
1236
|
+
return _apply_workspace_defaults({})
|
|
1237
|
+
return _apply_workspace_defaults(config)
|
|
1238
|
+
except (json.JSONDecodeError, IOError):
|
|
1239
|
+
return _apply_workspace_defaults({})
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _apply_billing_defaults(config: dict) -> dict:
|
|
1243
|
+
"""Fill in schema-v1 defaults for a billing config dict, in place and returned."""
|
|
1244
|
+
defaults = {
|
|
1245
|
+
"schema_version": 1,
|
|
1246
|
+
"budget": {"limit_usd": 10.0, "limit_requests": 100},
|
|
1247
|
+
"payment_models": {},
|
|
1248
|
+
"harness_billing": _default_harness_billing(),
|
|
1249
|
+
"capability_sweep": {"cost_cap_usd": 10.0},
|
|
1250
|
+
}
|
|
1251
|
+
if not isinstance(config, dict):
|
|
1252
|
+
config = {}
|
|
1253
|
+
has_harness_billing = "harness_billing" in config
|
|
1254
|
+
for key, val in defaults.items():
|
|
1255
|
+
if key not in config:
|
|
1256
|
+
config[key] = {} if key == "harness_billing" else val
|
|
1257
|
+
for key, val in defaults["budget"].items():
|
|
1258
|
+
if key not in config.get("budget", {}):
|
|
1259
|
+
config.setdefault("budget", {})[key] = val
|
|
1260
|
+
if not isinstance(config.get("harness_billing"), dict):
|
|
1261
|
+
config["harness_billing"] = _default_harness_billing()
|
|
1262
|
+
elif not config["harness_billing"] and has_harness_billing:
|
|
1263
|
+
config["harness_billing"] = _default_harness_billing()
|
|
1264
|
+
for billing in config["harness_billing"].values():
|
|
1265
|
+
if isinstance(billing, dict):
|
|
1266
|
+
billing.setdefault("payment_mode", "pay_as_you_go")
|
|
1267
|
+
billing.setdefault("monthly_base_fee_usd", billing.get("subscription_fee_usd", 0.0))
|
|
1268
|
+
billing.setdefault("projected_monthly_tokens", 10_000_000)
|
|
1269
|
+
billing.setdefault("allow_extra_usage", False)
|
|
1270
|
+
billing.setdefault("extra_usage_cap_usd", None)
|
|
1271
|
+
return config
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def load_billing() -> dict:
|
|
1275
|
+
"""Load .synlynk/billing.json with schema-v1 defaults."""
|
|
1276
|
+
config_file = ".synlynk/billing.json"
|
|
1277
|
+
if not os.path.exists(config_file):
|
|
1278
|
+
return {
|
|
1279
|
+
"schema_version": 1,
|
|
1280
|
+
"budget": {"limit_usd": 10.0, "limit_requests": 100},
|
|
1281
|
+
"payment_models": {},
|
|
1282
|
+
"harness_billing": _default_harness_billing(),
|
|
1283
|
+
"capability_sweep": {"cost_cap_usd": 10.0},
|
|
1284
|
+
}
|
|
1285
|
+
try:
|
|
1286
|
+
with open(config_file) as f:
|
|
1287
|
+
config = json.load(f)
|
|
1288
|
+
if not isinstance(config, dict):
|
|
1289
|
+
return {
|
|
1290
|
+
"schema_version": 1,
|
|
1291
|
+
"budget": {"limit_usd": 10.0, "limit_requests": 100},
|
|
1292
|
+
"payment_models": {},
|
|
1293
|
+
"harness_billing": _default_harness_billing(),
|
|
1294
|
+
"capability_sweep": {"cost_cap_usd": 10.0},
|
|
1295
|
+
}
|
|
1296
|
+
return _apply_billing_defaults(config)
|
|
1297
|
+
except (json.JSONDecodeError, IOError):
|
|
1298
|
+
return {
|
|
1299
|
+
"schema_version": 1,
|
|
1300
|
+
"budget": {"limit_usd": 10.0, "limit_requests": 100},
|
|
1301
|
+
"payment_models": {},
|
|
1302
|
+
"harness_billing": _default_harness_billing(),
|
|
1303
|
+
"capability_sweep": {"cost_cap_usd": 10.0},
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _read_raw_policy() -> dict:
|
|
1308
|
+
"""Read .synlynk/policy.json without defaults, or return {} if absent/corrupt."""
|
|
1309
|
+
try:
|
|
1310
|
+
with open(".synlynk/policy.json") as f:
|
|
1311
|
+
data = json.load(f)
|
|
1312
|
+
return data if isinstance(data, dict) else {}
|
|
1313
|
+
except (OSError, json.JSONDecodeError):
|
|
1314
|
+
return {}
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _read_policy_migrated_fields(policy: dict | None = None) -> dict:
|
|
1318
|
+
"""Read the legacy fields that are migrated into policy.json."""
|
|
1319
|
+
try:
|
|
1320
|
+
from synlynk.capability_roles import _load_capability_roles
|
|
1321
|
+
capability_roles = _load_capability_roles()
|
|
1322
|
+
except ImportError:
|
|
1323
|
+
capability_roles = None
|
|
1324
|
+
if policy is None:
|
|
1325
|
+
policy = _read_raw_policy()
|
|
1326
|
+
result = {
|
|
1327
|
+
"roles": capability_roles if capability_roles is not None else _default_roles_map(),
|
|
1328
|
+
"story_classification": {"method": "heuristic"},
|
|
1329
|
+
"qa_gate_mode": "block-only",
|
|
1330
|
+
"sentinel": {
|
|
1331
|
+
"dedup_window_seconds": 86400,
|
|
1332
|
+
"active_ttl_seconds": {"CRITICAL": 180 * 86400, "WARN": 180 * 86400, "INFO": 180 * 86400},
|
|
1333
|
+
},
|
|
1334
|
+
}
|
|
1335
|
+
if capability_roles is None and "roles" in policy:
|
|
1336
|
+
result["roles"] = policy["roles"]
|
|
1337
|
+
for key in ("story_classification", "qa_gate_mode", "sentinel"):
|
|
1338
|
+
if key in policy:
|
|
1339
|
+
result[key] = policy[key]
|
|
1340
|
+
return result
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _split_legacy_config(legacy: dict, existing_policy: dict) -> tuple[dict, dict, dict]:
|
|
1344
|
+
"""Partition a legacy config.json dict into workspace/billing/policy payloads.
|
|
1345
|
+
|
|
1346
|
+
Pure, no I/O. Workspace is the catch-all so unclaimed legacy keys are preserved.
|
|
1347
|
+
"""
|
|
1348
|
+
claimed = set(_BILLING_KEYS) | set(_POLICY_MIGRATED_KEYS)
|
|
1349
|
+
workspace_payload = {"schema_version": 1}
|
|
1350
|
+
workspace_payload.update({k: v for k, v in legacy.items() if k not in claimed})
|
|
1351
|
+
|
|
1352
|
+
billing_payload = {"schema_version": 1}
|
|
1353
|
+
billing_payload.update({k: legacy[k] for k in _BILLING_KEYS if k in legacy})
|
|
1354
|
+
|
|
1355
|
+
policy_payload = dict(existing_policy)
|
|
1356
|
+
policy_payload.update({k: legacy[k] for k in _POLICY_MIGRATED_KEYS if k in legacy})
|
|
1357
|
+
policy_payload.setdefault("schema_version", 1)
|
|
1358
|
+
|
|
1359
|
+
return workspace_payload, billing_payload, policy_payload
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def migrate_legacy_config_if_needed() -> bool:
|
|
1363
|
+
"""Explicitly split .synlynk/config.json into workspace, billing, and policy files once."""
|
|
1364
|
+
legacy_path = ".synlynk/config.json"
|
|
1365
|
+
workspace_path = ".synlynk/workspace.json"
|
|
1366
|
+
billing_path = ".synlynk/billing.json"
|
|
1367
|
+
policy_path = ".synlynk/policy.json"
|
|
1368
|
+
|
|
1369
|
+
if not os.path.exists(legacy_path):
|
|
1370
|
+
return False
|
|
1371
|
+
if os.path.exists(workspace_path) or os.path.exists(billing_path):
|
|
1372
|
+
return False
|
|
1373
|
+
|
|
1374
|
+
try:
|
|
1375
|
+
with open(legacy_path) as f:
|
|
1376
|
+
legacy = json.load(f)
|
|
1377
|
+
except (json.JSONDecodeError, IOError):
|
|
1378
|
+
return False
|
|
1379
|
+
if not isinstance(legacy, dict):
|
|
1380
|
+
return False
|
|
1381
|
+
|
|
1382
|
+
# A present but corrupt policy file aborts the whole migration. A missing
|
|
1383
|
+
# policy file is valid and uses an empty policy as its base.
|
|
1384
|
+
if os.path.exists(policy_path):
|
|
1385
|
+
try:
|
|
1386
|
+
with open(policy_path) as f:
|
|
1387
|
+
existing_policy = json.load(f)
|
|
1388
|
+
if not isinstance(existing_policy, dict):
|
|
1389
|
+
return False
|
|
1390
|
+
except (json.JSONDecodeError, IOError):
|
|
1391
|
+
return False
|
|
1392
|
+
else:
|
|
1393
|
+
existing_policy = {}
|
|
1394
|
+
|
|
1395
|
+
workspace_payload, billing_payload, policy_payload = _split_legacy_config(legacy, existing_policy)
|
|
1396
|
+
|
|
1397
|
+
_write_json_atomic(workspace_path, workspace_payload)
|
|
1398
|
+
_write_json_atomic(billing_path, billing_payload)
|
|
1399
|
+
_write_json_atomic(policy_path, policy_payload)
|
|
1400
|
+
os.replace(legacy_path, legacy_path + ".bak")
|
|
1401
|
+
return True
|
|
1402
|
+
|
|
1403
|
+
# ANSI helpers used by the wizard.
|
|
1404
|
+
_BOLD = "\033[1m"
|
|
1405
|
+
_GREEN = "\033[32m"
|
|
1406
|
+
_YELLOW = "\033[33m"
|
|
1407
|
+
_CYAN = "\033[36m"
|
|
1408
|
+
_DIM = "\033[2m"
|
|
1409
|
+
_RESET = "\033[0m"
|
|
1410
|
+
_RED = "\033[31m"
|
|
1411
|
+
_MAGENTA = "\033[35m"
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _docs_dir() -> str:
|
|
1415
|
+
"""Returns the configured project docs directory (defaults to 'project-docs').
|
|
1416
|
+
|
|
1417
|
+
Reads project_docs_dir from .synlynk/workspace.json (falls back to the
|
|
1418
|
+
legacy .synlynk/config.json for projects not yet migrated). Pass
|
|
1419
|
+
--docs-dir to synlynk init to set a custom location (e.g. '.' for repos
|
|
1420
|
+
that keep docs at the root).
|
|
1421
|
+
"""
|
|
1422
|
+
workspace_file = ".synlynk/workspace.json"
|
|
1423
|
+
legacy_file = ".synlynk/config.json"
|
|
1424
|
+
config_file = workspace_file if os.path.exists(workspace_file) else legacy_file
|
|
1425
|
+
if os.path.exists(config_file):
|
|
1426
|
+
try:
|
|
1427
|
+
with open(config_file) as f:
|
|
1428
|
+
return json.load(f).get("project_docs_dir", "project-docs")
|
|
1429
|
+
except (json.JSONDecodeError, IOError):
|
|
1430
|
+
pass
|
|
1431
|
+
return "project-docs"
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
_DEFAULT_HARNESS_BILLING = {
|
|
1435
|
+
"claude": {
|
|
1436
|
+
"payment_mode": "subscription",
|
|
1437
|
+
"monthly_base_fee_usd": 20.0,
|
|
1438
|
+
"projected_monthly_tokens": 10_000_000,
|
|
1439
|
+
"allow_extra_usage": False,
|
|
1440
|
+
},
|
|
1441
|
+
"codex": {
|
|
1442
|
+
"payment_mode": "subscription",
|
|
1443
|
+
"monthly_base_fee_usd": 20.0,
|
|
1444
|
+
"projected_monthly_tokens": 10_000_000,
|
|
1445
|
+
"allow_extra_usage": False,
|
|
1446
|
+
},
|
|
1447
|
+
"agy": {
|
|
1448
|
+
"payment_mode": "subscription",
|
|
1449
|
+
"monthly_base_fee_usd": 20.0,
|
|
1450
|
+
"projected_monthly_tokens": 10_000_000,
|
|
1451
|
+
"allow_extra_usage": False,
|
|
1452
|
+
},
|
|
1453
|
+
"grok": {
|
|
1454
|
+
"payment_mode": "subscription",
|
|
1455
|
+
"monthly_base_fee_usd": 30.0,
|
|
1456
|
+
"projected_monthly_tokens": 10_000_000,
|
|
1457
|
+
"allow_extra_usage": False,
|
|
1458
|
+
},
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _default_harness_billing() -> dict:
|
|
1463
|
+
"""Return an independent copy of the standard quad-harness billing seed."""
|
|
1464
|
+
return {agent: dict(config) for agent, config in _DEFAULT_HARNESS_BILLING.items()}
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def load_config() -> dict:
|
|
1468
|
+
"""Read the composed config without writing, renaming, or deleting files."""
|
|
1469
|
+
workspace_path = ".synlynk/workspace.json"
|
|
1470
|
+
billing_path = ".synlynk/billing.json"
|
|
1471
|
+
legacy_path = ".synlynk/config.json"
|
|
1472
|
+
|
|
1473
|
+
config = {"schema_version": 1}
|
|
1474
|
+
|
|
1475
|
+
if not os.path.exists(workspace_path) and not os.path.exists(billing_path) and os.path.exists(legacy_path):
|
|
1476
|
+
legacy = None
|
|
1477
|
+
try:
|
|
1478
|
+
with open(legacy_path) as f:
|
|
1479
|
+
legacy = json.load(f)
|
|
1480
|
+
except (json.JSONDecodeError, IOError):
|
|
1481
|
+
legacy = None
|
|
1482
|
+
if isinstance(legacy, dict):
|
|
1483
|
+
existing_policy = _read_raw_policy()
|
|
1484
|
+
workspace_payload, billing_payload, policy_payload = _split_legacy_config(legacy, existing_policy)
|
|
1485
|
+
config.update(_apply_workspace_defaults(workspace_payload))
|
|
1486
|
+
config.update(_apply_billing_defaults(billing_payload))
|
|
1487
|
+
config.update(_read_policy_migrated_fields(policy_payload))
|
|
1488
|
+
return config
|
|
1489
|
+
|
|
1490
|
+
config.update(load_workspace())
|
|
1491
|
+
config.update(load_billing())
|
|
1492
|
+
config.update(_read_policy_migrated_fields())
|
|
1493
|
+
return config
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def cmd_config_set(key: str, value) -> None:
|
|
1497
|
+
"""Set a top-level config key, routed to its owning split config file."""
|
|
1498
|
+
migrate_legacy_config_if_needed()
|
|
1499
|
+
if key in _BILLING_KEYS:
|
|
1500
|
+
path = ".synlynk/billing.json"
|
|
1501
|
+
data = load_billing()
|
|
1502
|
+
elif key in _POLICY_MIGRATED_KEYS:
|
|
1503
|
+
path = ".synlynk/policy.json"
|
|
1504
|
+
data = _read_raw_policy()
|
|
1505
|
+
data.setdefault("schema_version", 1)
|
|
1506
|
+
else:
|
|
1507
|
+
path = ".synlynk/workspace.json"
|
|
1508
|
+
data = load_workspace()
|
|
1509
|
+
data[key] = value
|
|
1510
|
+
_write_json_atomic(path, data)
|
|
1511
|
+
print(f" ✓ {key} = {value!r} saved to {path}")
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _check_upstream_divergence() -> None:
|
|
1529
|
+
"""Warn if remote has commits the local branch hasn't pulled. Silent no-op otherwise."""
|
|
1530
|
+
try:
|
|
1531
|
+
local = subprocess.check_output(
|
|
1532
|
+
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
|
|
1533
|
+
).decode().strip()
|
|
1534
|
+
upstream = subprocess.check_output(
|
|
1535
|
+
["git", "rev-parse", "@{u}"], stderr=subprocess.DEVNULL
|
|
1536
|
+
).decode().strip()
|
|
1537
|
+
except (subprocess.CalledProcessError, AttributeError):
|
|
1538
|
+
return # no upstream configured, detached HEAD, or not in git repo
|
|
1539
|
+
if local != upstream:
|
|
1540
|
+
try:
|
|
1541
|
+
behind = subprocess.check_output(
|
|
1542
|
+
["git", "rev-list", "--count", "HEAD..@{u}"],
|
|
1543
|
+
stderr=subprocess.DEVNULL
|
|
1544
|
+
).decode().strip()
|
|
1545
|
+
except (subprocess.CalledProcessError, AttributeError):
|
|
1546
|
+
behind = "?"
|
|
1547
|
+
print(f"⚠ Remote has {behind} commit(s) you haven't pulled. "
|
|
1548
|
+
f"Consider `git pull` before writing.\n Continuing anyway...")
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _seed_devlog(username: str) -> None:
|
|
1552
|
+
"""Creates project-docs/devlogs/<username>.md if absent. Idempotent."""
|
|
1553
|
+
devlog_dir = os.path.join(_docs_dir(), "devlogs")
|
|
1554
|
+
os.makedirs(devlog_dir, exist_ok=True)
|
|
1555
|
+
devlog_path = os.path.join(devlog_dir, f"{username}.md")
|
|
1556
|
+
if os.path.exists(devlog_path):
|
|
1557
|
+
return
|
|
1558
|
+
today = time.strftime("%Y-%m-%d")
|
|
1559
|
+
with open(devlog_path, "w") as f:
|
|
1560
|
+
f.write(f"# Devlog — @{username}\n\n")
|
|
1561
|
+
f.write(f"## {today} — Joined project\n")
|
|
1562
|
+
f.write("Joined via `synlynk join`.\n")
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def cmd_exit(dry_run: bool = True, remove_docs: bool = False) -> int:
|
|
1570
|
+
"""Reverse synlynk onboarding: strip instruction sections, remove .synlynk/ and .agents/.
|
|
1571
|
+
|
|
1572
|
+
Writes SYNLYNK_HANDOFF.md summarising what was configured before removal.
|
|
1573
|
+
Dry-run by default — pass --confirm to execute.
|
|
1574
|
+
"""
|
|
1575
|
+
manifest_data = _load_instruction_manifest()
|
|
1576
|
+
docs = None
|
|
1577
|
+
try:
|
|
1578
|
+
docs = _docs_dir()
|
|
1579
|
+
except Exception:
|
|
1580
|
+
pass
|
|
1581
|
+
|
|
1582
|
+
cfg = {}
|
|
1583
|
+
_cfg_path = ".synlynk/workspace.json" if os.path.exists(".synlynk/workspace.json") else ".synlynk/config.json"
|
|
1584
|
+
if os.path.exists(_cfg_path):
|
|
1585
|
+
try:
|
|
1586
|
+
cfg = json.load(open(_cfg_path))
|
|
1587
|
+
except Exception:
|
|
1588
|
+
pass
|
|
1589
|
+
|
|
1590
|
+
# Build action list
|
|
1591
|
+
strip_targets = []
|
|
1592
|
+
for fpath, info in manifest_data.items():
|
|
1593
|
+
style = _MARKER_STYLE_FOR_TOOL.get(info.get("tool", ""), "html")
|
|
1594
|
+
strip_targets.append((fpath, style, info.get("tool", "?")))
|
|
1595
|
+
|
|
1596
|
+
agent_profiles = []
|
|
1597
|
+
if os.path.isdir(".agents"):
|
|
1598
|
+
agent_profiles = [os.path.join(".agents", f) for f in os.listdir(".agents") if f.endswith(".json")]
|
|
1599
|
+
|
|
1600
|
+
synlynk_dir = ".synlynk"
|
|
1601
|
+
docs_dir_path = docs if (remove_docs and docs and os.path.isdir(docs)) else None
|
|
1602
|
+
|
|
1603
|
+
print(f"\n{_BOLD}synlynk exit{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
|
|
1604
|
+
|
|
1605
|
+
# Instruction files
|
|
1606
|
+
print(" Instruction files:")
|
|
1607
|
+
if strip_targets:
|
|
1608
|
+
for fpath, style, tool in strip_targets:
|
|
1609
|
+
exists = os.path.exists(fpath)
|
|
1610
|
+
tag = "remove" if style == "none" else "strip synlynk section"
|
|
1611
|
+
label = f"{_DIM}(not found){_RESET}" if not exists else ""
|
|
1612
|
+
print(f" {'→' if dry_run else '✓'} {fpath} [{tool}] — {tag} {label}")
|
|
1613
|
+
if not dry_run and exists:
|
|
1614
|
+
_strip_synlynk_section(fpath, style)
|
|
1615
|
+
else:
|
|
1616
|
+
print(f" {_DIM}no tracked instruction files{_RESET}")
|
|
1617
|
+
|
|
1618
|
+
# Agent profiles
|
|
1619
|
+
print(" Agent profiles:")
|
|
1620
|
+
if agent_profiles:
|
|
1621
|
+
for p in agent_profiles:
|
|
1622
|
+
print(f" {'→' if dry_run else '✓'} remove {p}")
|
|
1623
|
+
if not dry_run:
|
|
1624
|
+
os.remove(p)
|
|
1625
|
+
if not dry_run and os.path.isdir(".agents"):
|
|
1626
|
+
try:
|
|
1627
|
+
os.rmdir(".agents")
|
|
1628
|
+
except OSError:
|
|
1629
|
+
pass
|
|
1630
|
+
else:
|
|
1631
|
+
print(f" {_DIM}no .agents/ profiles found{_RESET}")
|
|
1632
|
+
|
|
1633
|
+
# .synlynk/ directory
|
|
1634
|
+
print(f" Config & state:")
|
|
1635
|
+
print(f" {'→' if dry_run else '✓'} remove {synlynk_dir}/")
|
|
1636
|
+
if not dry_run and os.path.isdir(synlynk_dir):
|
|
1637
|
+
import shutil as _shutil
|
|
1638
|
+
_shutil.rmtree(synlynk_dir)
|
|
1639
|
+
|
|
1640
|
+
# project-docs (optional)
|
|
1641
|
+
if docs_dir_path:
|
|
1642
|
+
print(f" Project docs:")
|
|
1643
|
+
print(f" {'→' if dry_run else '✓'} remove {docs_dir_path}/")
|
|
1644
|
+
if not dry_run:
|
|
1645
|
+
import shutil as _shutil
|
|
1646
|
+
_shutil.rmtree(docs_dir_path)
|
|
1647
|
+
elif remove_docs:
|
|
1648
|
+
print(f" Project docs: {_DIM}not found or already absent{_RESET}")
|
|
1649
|
+
|
|
1650
|
+
# Handoff doc
|
|
1651
|
+
handoff_path = "SYNLYNK_HANDOFF.md"
|
|
1652
|
+
handoff_lines = [
|
|
1653
|
+
f"# synlynk handoff — {time.strftime('%Y-%m-%d')}",
|
|
1654
|
+
"",
|
|
1655
|
+
"synlynk was removed from this repository. This file records what was configured.",
|
|
1656
|
+
"",
|
|
1657
|
+
"## Configuration",
|
|
1658
|
+
f"- Version: {cfg.get('synlynk_version', VERSION)}",
|
|
1659
|
+
f"- Mode: {cfg.get('mode', 'unknown')}",
|
|
1660
|
+
f"- Agents: {', '.join(cfg.get('agent_slots', {}).keys()) or 'unknown'}",
|
|
1661
|
+
f"- Org: {cfg.get('org', 'unknown')} / {cfg.get('repo', 'unknown')}",
|
|
1662
|
+
f"- Docs dir: {docs or 'unknown'}",
|
|
1663
|
+
"",
|
|
1664
|
+
"## Removed",
|
|
1665
|
+
]
|
|
1666
|
+
for fpath, style, tool in strip_targets:
|
|
1667
|
+
handoff_lines.append(f"- `{fpath}` ({tool}) — synlynk section stripped")
|
|
1668
|
+
for p in agent_profiles:
|
|
1669
|
+
handoff_lines.append(f"- `{p}` — removed")
|
|
1670
|
+
handoff_lines += [
|
|
1671
|
+
f"- `.synlynk/` — removed",
|
|
1672
|
+
"",
|
|
1673
|
+
"## To re-initialize",
|
|
1674
|
+
"```",
|
|
1675
|
+
f"synlynk init --agents {','.join(cfg.get('agent_slots', {}).keys()) or 'claude,agy,codex,grok'}",
|
|
1676
|
+
"```",
|
|
1677
|
+
"",
|
|
1678
|
+
]
|
|
1679
|
+
print(f" Handoff doc:")
|
|
1680
|
+
print(f" {'→' if dry_run else '✓'} write {handoff_path}")
|
|
1681
|
+
if not dry_run:
|
|
1682
|
+
with open(handoff_path, "w") as f:
|
|
1683
|
+
f.write("\n".join(handoff_lines))
|
|
1684
|
+
|
|
1685
|
+
print()
|
|
1686
|
+
if dry_run:
|
|
1687
|
+
print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply changes.")
|
|
1688
|
+
else:
|
|
1689
|
+
print(f" {_GREEN}synlynk removed.{_RESET} See {handoff_path} for re-init instructions.")
|
|
1690
|
+
print()
|
|
1691
|
+
return 0
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def cmd_repair(dry_run: bool = True) -> int:
|
|
1695
|
+
"""Exit synlynk and immediately re-initialize from the current configuration.
|
|
1696
|
+
|
|
1697
|
+
Reads config before exit so re-init uses the same agents/mode/org/repo/docs-dir.
|
|
1698
|
+
Dry-run by default — pass --confirm to execute.
|
|
1699
|
+
"""
|
|
1700
|
+
cfg = {}
|
|
1701
|
+
_cfg_path = ".synlynk/workspace.json" if os.path.exists(".synlynk/workspace.json") else ".synlynk/config.json"
|
|
1702
|
+
if os.path.exists(_cfg_path):
|
|
1703
|
+
try:
|
|
1704
|
+
cfg = json.load(open(_cfg_path))
|
|
1705
|
+
except Exception:
|
|
1706
|
+
pass
|
|
1707
|
+
|
|
1708
|
+
agents_str = ",".join(cfg.get("agent_slots", {}).keys()) or "claude,agy,codex,grok"
|
|
1709
|
+
mode = cfg.get("mode", "solo")
|
|
1710
|
+
org = cfg.get("org") or None
|
|
1711
|
+
repo = cfg.get("repo") or None
|
|
1712
|
+
project_id = cfg.get("project_id") or None
|
|
1713
|
+
docs_dir_arg = cfg.get("docs_dir") or None
|
|
1714
|
+
|
|
1715
|
+
print(f"\n{_BOLD}synlynk repair{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
|
|
1716
|
+
print(f" Captured config: agents={agents_str} mode={mode} org={org or '—'} docs-dir={docs_dir_arg or 'project-docs'}")
|
|
1717
|
+
print()
|
|
1718
|
+
|
|
1719
|
+
print(" Step 1: exit")
|
|
1720
|
+
cmd_exit(dry_run=dry_run)
|
|
1721
|
+
|
|
1722
|
+
print(" Step 2: re-init")
|
|
1723
|
+
if dry_run:
|
|
1724
|
+
print(f" → synlynk init --agents {agents_str} --mode {mode}"
|
|
1725
|
+
+ (f" --org {org}" if org else "")
|
|
1726
|
+
+ (f" --repo {repo}" if repo else "")
|
|
1727
|
+
+ (f" --project-id {project_id}" if project_id else "")
|
|
1728
|
+
+ (f" --docs-dir {docs_dir_arg}" if docs_dir_arg else ""))
|
|
1729
|
+
print()
|
|
1730
|
+
print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply.")
|
|
1731
|
+
else:
|
|
1732
|
+
init(
|
|
1733
|
+
agents=[a.strip() for a in agents_str.split(",") if a.strip()],
|
|
1734
|
+
mode=mode,
|
|
1735
|
+
org=org,
|
|
1736
|
+
repo=repo,
|
|
1737
|
+
project_id=project_id,
|
|
1738
|
+
docs_dir=docs_dir_arg,
|
|
1739
|
+
)
|
|
1740
|
+
try:
|
|
1741
|
+
from synlynk.doctor import cleanup_selftest_workspaces
|
|
1742
|
+
|
|
1743
|
+
removed = cleanup_selftest_workspaces()
|
|
1744
|
+
if removed:
|
|
1745
|
+
print(f" ✓ Cleaned up {removed} orphaned synlynk-selftest workspace(s)")
|
|
1746
|
+
except Exception:
|
|
1747
|
+
pass
|
|
1748
|
+
print(f"\n {_GREEN}Repair complete.{_RESET}")
|
|
1749
|
+
print()
|
|
1750
|
+
return 0
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def cmd_sync(dry_run: bool = True, repair_sops: bool = False) -> int:
|
|
1754
|
+
"""Propagate updated synlynk artifacts to an existing repo without full re-init.
|
|
1755
|
+
|
|
1756
|
+
Updates: instruction file sections (CLAUDE.md, GEMINI.md, etc.), .agents/ profile
|
|
1757
|
+
defaults for any slots missing from .agents/. Does NOT touch project-docs/.
|
|
1758
|
+
Dry-run by default — pass --confirm to execute.
|
|
1759
|
+
"""
|
|
1760
|
+
print(f"\n{_BOLD}synlynk sync{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
|
|
1761
|
+
|
|
1762
|
+
# --- instruction files ---
|
|
1763
|
+
manifest_data = _load_instruction_manifest()
|
|
1764
|
+
print(" Instruction files:")
|
|
1765
|
+
if not manifest_data:
|
|
1766
|
+
print(f" {_DIM}no tracked instruction files — run synlynk init first{_RESET}")
|
|
1767
|
+
else:
|
|
1768
|
+
_tool_content_builders = {
|
|
1769
|
+
"cursor": (_build_cursor_mdc, "none"),
|
|
1770
|
+
"copilot": (_build_copilot_instructions, "html"),
|
|
1771
|
+
"windsurf": (_build_windsurf_rules, "hash"),
|
|
1772
|
+
"universal": (lambda: _build_templates().get("AI_INSTRUCTIONS.md", ""), "html"),
|
|
1773
|
+
}
|
|
1774
|
+
updated_manifest = {}
|
|
1775
|
+
for fpath, info in manifest_data.items():
|
|
1776
|
+
tool = info.get("tool", "unknown")
|
|
1777
|
+
marker_style = _MARKER_STYLE_FOR_TOOL.get(tool, "html")
|
|
1778
|
+
if dry_run:
|
|
1779
|
+
print(f" → {fpath} [{tool}]")
|
|
1780
|
+
continue
|
|
1781
|
+
if tool in _tool_content_builders:
|
|
1782
|
+
builder, _ = _tool_content_builders[tool]
|
|
1783
|
+
content = builder()
|
|
1784
|
+
else:
|
|
1785
|
+
templates = _build_templates()
|
|
1786
|
+
content = templates.get(os.path.basename(fpath), "")
|
|
1787
|
+
_write_instruction_file(fpath, tool, content, marker_style)
|
|
1788
|
+
if os.path.exists(fpath):
|
|
1789
|
+
section = _extract_synlynk_section(open(fpath).read(), marker_style)
|
|
1790
|
+
if section:
|
|
1791
|
+
updated_manifest[fpath] = {"tool": tool, "sha": _compute_section_sha(section)}
|
|
1792
|
+
print(f" {_GREEN}✓{_RESET} {fpath} [{tool}]")
|
|
1793
|
+
if not dry_run and updated_manifest:
|
|
1794
|
+
_write_instruction_manifest(updated_manifest)
|
|
1795
|
+
|
|
1796
|
+
# --- agent profiles ---
|
|
1797
|
+
print(" Agent profiles (.agents/):")
|
|
1798
|
+
try:
|
|
1799
|
+
cfg = load_config()
|
|
1800
|
+
except Exception:
|
|
1801
|
+
cfg = {}
|
|
1802
|
+
slots = cfg.get("agent_slots", {})
|
|
1803
|
+
if not slots:
|
|
1804
|
+
print(f" {_DIM}no agent slots in config{_RESET}")
|
|
1805
|
+
else:
|
|
1806
|
+
os.makedirs(".agents", exist_ok=True) if not dry_run else None
|
|
1807
|
+
for name in slots:
|
|
1808
|
+
profile_path = os.path.join(".agents", f"{name}.json")
|
|
1809
|
+
if os.path.exists(profile_path):
|
|
1810
|
+
print(f" {_DIM}· {profile_path} already present — skipped{_RESET}")
|
|
1811
|
+
continue
|
|
1812
|
+
print(f" {'→' if dry_run else _GREEN + '✓' + _RESET} {profile_path} — create default profile")
|
|
1813
|
+
if not dry_run:
|
|
1814
|
+
profile = _load_agent_profile(name)
|
|
1815
|
+
os.makedirs(".agents", exist_ok=True)
|
|
1816
|
+
with open(profile_path, "w") as f:
|
|
1817
|
+
json.dump(profile, f, indent=2)
|
|
1818
|
+
f.write("\n")
|
|
1819
|
+
|
|
1820
|
+
print()
|
|
1821
|
+
if dry_run:
|
|
1822
|
+
print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply.")
|
|
1823
|
+
else:
|
|
1824
|
+
print(f" {_GREEN}Sync complete.{_RESET}")
|
|
1825
|
+
print()
|
|
1826
|
+
|
|
1827
|
+
if repair_sops:
|
|
1828
|
+
_repair_sops_only(load_config(), dry_run=dry_run)
|
|
1829
|
+
|
|
1830
|
+
return 0
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _check_agent_functional(cli: str) -> Optional[str]:
|
|
1843
|
+
"""Runs `<cli> --version` to confirm CLI is installed and executable.
|
|
1844
|
+
|
|
1845
|
+
Returns version string (stdout stripped) on success, None otherwise.
|
|
1846
|
+
"""
|
|
1847
|
+
try:
|
|
1848
|
+
result = subprocess.run(
|
|
1849
|
+
[cli, "--version"],
|
|
1850
|
+
capture_output=True, text=True, timeout=5
|
|
1851
|
+
)
|
|
1852
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1853
|
+
return result.stdout.strip().splitlines()[0]
|
|
1854
|
+
return None
|
|
1855
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
1856
|
+
return None
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def discover_agents(config: dict = None) -> list:
|
|
1860
|
+
"""Scans for installed harness CLIs and checks each is functional.
|
|
1861
|
+
|
|
1862
|
+
Returns list of dicts: {name, cli, version, functional, capabilities,
|
|
1863
|
+
roles, discovery_path}.
|
|
1864
|
+
Agents not found on disk are omitted. Agents found but failing --version
|
|
1865
|
+
are included with functional=False.
|
|
1866
|
+
"""
|
|
1867
|
+
if config is None:
|
|
1868
|
+
config = load_config()
|
|
1869
|
+
|
|
1870
|
+
# Allow per-project overrides of discovery paths.
|
|
1871
|
+
discovery_paths = {**AGENT_DISCOVERY_DEFAULTS}
|
|
1872
|
+
discovery_paths.update(config.get("agent_discovery_paths", {}))
|
|
1873
|
+
|
|
1874
|
+
found = []
|
|
1875
|
+
for name, defaults in HARNESS_CAPABILITY_BASELINES.items():
|
|
1876
|
+
path = discovery_paths.get(name)
|
|
1877
|
+
if path and not os.path.exists(path):
|
|
1878
|
+
continue # config dir not present — skip entirely
|
|
1879
|
+
cli = defaults["cli"]
|
|
1880
|
+
version = _check_agent_functional(cli)
|
|
1881
|
+
found.append({
|
|
1882
|
+
"name": name,
|
|
1883
|
+
"cli": cli,
|
|
1884
|
+
"version": version,
|
|
1885
|
+
"functional": version is not None,
|
|
1886
|
+
"roles": defaults["roles"],
|
|
1887
|
+
"capabilities": defaults["strengths"],
|
|
1888
|
+
"non_interactive_flags": defaults["non_interactive_flags"],
|
|
1889
|
+
"discovery_path": path or "",
|
|
1890
|
+
})
|
|
1891
|
+
return found
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def probe_all_configured_harnesses(agents: list = None) -> dict:
|
|
1895
|
+
"""Populate probe metadata for the harnesses selected for this workspace."""
|
|
1896
|
+
configured = list(agents) if agents is not None else []
|
|
1897
|
+
if agents is None:
|
|
1898
|
+
config = load_config()
|
|
1899
|
+
configured = list(config.get("workgroup_agents") or [])
|
|
1900
|
+
if agents is None and not configured:
|
|
1901
|
+
configured = [entry["name"] for entry in discover_agents() if entry.get("functional")]
|
|
1902
|
+
configured = list(dict.fromkeys(
|
|
1903
|
+
name for name in configured if name in HARNESS_CAPABILITY_BASELINES
|
|
1904
|
+
))
|
|
1905
|
+
results = {}
|
|
1906
|
+
if not configured:
|
|
1907
|
+
return results
|
|
1908
|
+
|
|
1909
|
+
from synlynk.probe import _probe_agent
|
|
1910
|
+
|
|
1911
|
+
db_conn = _get_db()
|
|
1912
|
+
try:
|
|
1913
|
+
for harness_name in configured:
|
|
1914
|
+
try:
|
|
1915
|
+
results[harness_name] = _probe_agent(
|
|
1916
|
+
harness_name, db_conn, fast_path_ok=False, write_fence=False
|
|
1917
|
+
)
|
|
1918
|
+
except Exception as exc:
|
|
1919
|
+
results[harness_name] = {
|
|
1920
|
+
"status": "unavailable",
|
|
1921
|
+
"version": "unavailable",
|
|
1922
|
+
"error": str(exc),
|
|
1923
|
+
}
|
|
1924
|
+
db_conn.commit()
|
|
1925
|
+
finally:
|
|
1926
|
+
db_conn.close()
|
|
1927
|
+
return results
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
_INDUSTRY_KEYWORDS = {
|
|
1933
|
+
"ott": ["ott", "over-the-top", "streaming service", "video platform"],
|
|
1934
|
+
"streaming": ["streaming", "live stream", "media delivery"],
|
|
1935
|
+
"fintech": ["fintech", "financial", "payment", "trading", "investment"],
|
|
1936
|
+
"banking": ["banking", "bank", "loan", "mortgage", "deposit"],
|
|
1937
|
+
"securities": ["securities", "stock", "equity", "portfolio", "brokerage"],
|
|
1938
|
+
"healthcare": ["healthcare", "medical", "patient", "clinical", "health"],
|
|
1939
|
+
"ecommerce": ["ecommerce", "e-commerce", "shop", "cart", "marketplace"],
|
|
1940
|
+
"edtech": ["edtech", "education", "learning", "course", "student"],
|
|
1941
|
+
"gaming": ["gaming", "game", "player", "leaderboard", "matchmaking"],
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
_STACK_FINGERPRINTS = [
|
|
1947
|
+
("pyproject.toml", "Python"),
|
|
1948
|
+
("setup.py", "Python"),
|
|
1949
|
+
("Cargo.toml", "Rust"),
|
|
1950
|
+
("go.mod", "Go"),
|
|
1951
|
+
("next.config.js", "Next.js"),
|
|
1952
|
+
("next.config.ts", "Next.js"),
|
|
1953
|
+
("next.config.mjs", "Next.js"),
|
|
1954
|
+
("Pulumi.yaml", "Pulumi"),
|
|
1955
|
+
("Pulumi.yml", "Pulumi"),
|
|
1956
|
+
("Dockerfile", "Docker"),
|
|
1957
|
+
("docker-compose.yml", "Docker"),
|
|
1958
|
+
("docker-compose.yaml", "Docker"),
|
|
1959
|
+
]
|
|
1960
|
+
|
|
1961
|
+
_STACK_EXT_MAP = {
|
|
1962
|
+
".go": "Go",
|
|
1963
|
+
".rs": "Rust",
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
_KNOWN_SKILL_PATTERNS = [
|
|
1972
|
+
"~/.claude/plugins/cache/superpowers-marketplace/superpowers/*/",
|
|
1973
|
+
"~/.config/gstack/plugins/*/",
|
|
1974
|
+
]
|
|
1975
|
+
_SKILL_MANIFEST_NAMES = ("manifest.json", "package.json", "skill.json")
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
_MONOREPO_MARKERS = ("packages", "apps", "services", "modules", "libs")
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
STAGE_KEYS = ["stack", "source", "complexity", "tests", "git", "arch"]
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def cmd_agent_configure(agent_name: str) -> None:
|
|
2027
|
+
"""Interactively write .agents/<agent_name>.json context-profile settings."""
|
|
2028
|
+
import json as _json
|
|
2029
|
+
|
|
2030
|
+
if agent_name not in HARNESS_CAPABILITY_BASELINES:
|
|
2031
|
+
print(f" Unknown agent '{agent_name}'. Known: {list(HARNESS_CAPABILITY_BASELINES)}")
|
|
2032
|
+
return
|
|
2033
|
+
|
|
2034
|
+
harnesses_dir = ".harnesses" if os.path.exists(".harnesses") or not os.path.exists(".agents") else ".agents"
|
|
2035
|
+
os.makedirs(harnesses_dir, exist_ok=True)
|
|
2036
|
+
path = os.path.join(harnesses_dir, f"{agent_name}.json")
|
|
2037
|
+
|
|
2038
|
+
existing = {}
|
|
2039
|
+
if os.path.exists(path):
|
|
2040
|
+
try:
|
|
2041
|
+
with open(path) as f:
|
|
2042
|
+
existing = _json.load(f)
|
|
2043
|
+
print(f" Existing profile at {path}:")
|
|
2044
|
+
for key, value in existing.items():
|
|
2045
|
+
print(f" {key}: {value}")
|
|
2046
|
+
except Exception:
|
|
2047
|
+
existing = {}
|
|
2048
|
+
|
|
2049
|
+
print(f"\n Configuring context profile for '{agent_name}' (press Enter to keep current)\n")
|
|
2050
|
+
|
|
2051
|
+
def _ask(default, desc):
|
|
2052
|
+
shown = "" if default is None else str(default)
|
|
2053
|
+
value = input(f" {desc} [{shown}]: ").strip()
|
|
2054
|
+
return value if value else default
|
|
2055
|
+
|
|
2056
|
+
context_mode = _ask(existing.get("context_mode", "task"),
|
|
2057
|
+
"context_mode (none / task / full)")
|
|
2058
|
+
max_bytes_raw = _ask(existing.get("context_max_bytes", None),
|
|
2059
|
+
"context_max_bytes (int, leave blank for no limit)")
|
|
2060
|
+
|
|
2061
|
+
profile = {
|
|
2062
|
+
"agent": agent_name,
|
|
2063
|
+
"harness": existing.get("harness", agent_name),
|
|
2064
|
+
"model": existing.get("model", "unknown"),
|
|
2065
|
+
"context_mode": context_mode,
|
|
2066
|
+
}
|
|
2067
|
+
if max_bytes_raw not in ("", None):
|
|
2068
|
+
try:
|
|
2069
|
+
profile["context_max_bytes"] = int(max_bytes_raw)
|
|
2070
|
+
except (TypeError, ValueError):
|
|
2071
|
+
pass
|
|
2072
|
+
|
|
2073
|
+
with open(path, "w") as f:
|
|
2074
|
+
_json.dump(profile, f, indent=2)
|
|
2075
|
+
f.write("\n")
|
|
2076
|
+
print(f"\n {_GREEN}✓{_RESET} Written {path}")
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def cmd_agent_add(agent_name: str) -> None:
|
|
2080
|
+
"""Retrofit an on-PATH harness into the current project."""
|
|
2081
|
+
if agent_name not in HARNESS_CAPABILITY_BASELINES:
|
|
2082
|
+
print(f" Error: unknown agent '{agent_name}'. Known: {', '.join(sorted(HARNESS_CAPABILITY_BASELINES))}")
|
|
2083
|
+
return
|
|
2084
|
+
|
|
2085
|
+
cli_path = shutil.which(agent_name)
|
|
2086
|
+
if not cli_path:
|
|
2087
|
+
print(f" Error: agent binary '{agent_name}' is not on PATH.")
|
|
2088
|
+
return
|
|
2089
|
+
|
|
2090
|
+
config_path = ".synlynk/config.json"
|
|
2091
|
+
config = load_config()
|
|
2092
|
+
workgroup_agents = list(config.get("workgroup_agents") or [])
|
|
2093
|
+
directive_path = _directive_file_for_agent(agent_name)
|
|
2094
|
+
fence_present = _fence_exists(directive_path)
|
|
2095
|
+
|
|
2096
|
+
if agent_name in workgroup_agents and fence_present:
|
|
2097
|
+
print(f" {agent_name} is already fully onboarded — no changes made.")
|
|
2098
|
+
return
|
|
2099
|
+
|
|
2100
|
+
created_directive = False
|
|
2101
|
+
if not os.path.exists(directive_path):
|
|
2102
|
+
templates = _build_templates()
|
|
2103
|
+
template_body = templates.get(os.path.basename(directive_path), "")
|
|
2104
|
+
if not template_body:
|
|
2105
|
+
template_body = f"# synlynk {agent_name} instructions\n"
|
|
2106
|
+
with open(directive_path, "w") as f:
|
|
2107
|
+
f.write(template_body.rstrip("\n") + "\n")
|
|
2108
|
+
created_directive = True
|
|
2109
|
+
|
|
2110
|
+
probe_results = cmd_probe(agent_name, write_fence=False) or []
|
|
2111
|
+
probe_result = probe_results[0] if probe_results else {}
|
|
2112
|
+
|
|
2113
|
+
role_list = _default_roles_for_agent(agent_name)
|
|
2114
|
+
_upsert_harness_fence(
|
|
2115
|
+
directive_path,
|
|
2116
|
+
harness_version="roles",
|
|
2117
|
+
body=f"## Your Role\n{', '.join(role_list) if role_list else 'general'}\n",
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
if agent_name not in workgroup_agents:
|
|
2121
|
+
workgroup_agents.append(agent_name)
|
|
2122
|
+
|
|
2123
|
+
agent_slots = dict(config.get("agent_slots") or {})
|
|
2124
|
+
agent_slots[agent_name] = agent_name
|
|
2125
|
+
|
|
2126
|
+
roles = dict(config.get("roles") or {})
|
|
2127
|
+
if not roles.get(agent_name):
|
|
2128
|
+
roles[agent_name] = role_list
|
|
2129
|
+
|
|
2130
|
+
config["workgroup_agents"] = workgroup_agents
|
|
2131
|
+
config["agent_slots"] = agent_slots
|
|
2132
|
+
config["roles"] = roles
|
|
2133
|
+
_write_json_atomic(config_path, config)
|
|
2134
|
+
|
|
2135
|
+
print(f" {_GREEN}✓{_RESET} added {agent_name} to workgroup_agents")
|
|
2136
|
+
print(f" {_GREEN}✓{_RESET} added {agent_name} to agent_slots")
|
|
2137
|
+
if created_directive:
|
|
2138
|
+
print(f" {_GREEN}✓{_RESET} created {directive_path} from template")
|
|
2139
|
+
else:
|
|
2140
|
+
print(f" {_GREEN}✓{_RESET} updated {directive_path}")
|
|
2141
|
+
print(f" {_GREEN}✓{_RESET} wrote role fence to {directive_path}")
|
|
2142
|
+
if probe_result:
|
|
2143
|
+
status = "skipped (up to date)" if probe_result.get("skipped") else probe_result.get("status", "unknown")
|
|
2144
|
+
print(f" {_GREEN}✓{_RESET} probe [{agent_name}] {probe_result.get('version', 'unknown')} → {status}")
|
|
2145
|
+
print(f" {_GREEN}✓{_RESET} onboarded {agent_name} from {cli_path}")
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
cmd_harness_add = cmd_agent_add
|
|
2149
|
+
cmd_harness_configure = cmd_agent_configure
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def _run_daily_housekeeping() -> None:
|
|
2154
|
+
"""Run the once-per-day drift check triggered by exec flow."""
|
|
2155
|
+
workspace_path = ".synlynk/workspace.json"
|
|
2156
|
+
legacy_path = ".synlynk/config.json"
|
|
2157
|
+
if not os.path.exists(workspace_path) and not os.path.exists(legacy_path):
|
|
2158
|
+
return
|
|
2159
|
+
|
|
2160
|
+
config = load_config()
|
|
2161
|
+
today = time.strftime("%Y-%m-%d")
|
|
2162
|
+
if config.get("last_housekeeping_date") == today:
|
|
2163
|
+
return
|
|
2164
|
+
|
|
2165
|
+
workgroup_agents = [a for a in (config.get("workgroup_agents") or []) if a in HARNESS_CAPABILITY_BASELINES]
|
|
2166
|
+
known_on_path = {
|
|
2167
|
+
harness["name"]
|
|
2168
|
+
for harness in _detect_harnesses_on_path()
|
|
2169
|
+
if harness.get("name") in HARNESS_CAPABILITY_BASELINES
|
|
2170
|
+
}
|
|
2171
|
+
onboarded = set(workgroup_agents)
|
|
2172
|
+
new_agents = sorted(known_on_path - onboarded)
|
|
2173
|
+
|
|
2174
|
+
printed = False
|
|
2175
|
+
for agent_name in new_agents:
|
|
2176
|
+
print(f" New agent detected on PATH: {agent_name} — run `synlynk agent add {agent_name}` to onboard it")
|
|
2177
|
+
printed = True
|
|
2178
|
+
|
|
2179
|
+
db_conn = None
|
|
2180
|
+
try:
|
|
2181
|
+
if workgroup_agents:
|
|
2182
|
+
db_conn = _get_db()
|
|
2183
|
+
for agent_name in workgroup_agents:
|
|
2184
|
+
try:
|
|
2185
|
+
before = db_conn.execute(
|
|
2186
|
+
"SELECT installed_version, capability_hash FROM harness_records WHERE harness_name=?",
|
|
2187
|
+
(agent_name,),
|
|
2188
|
+
).fetchone()
|
|
2189
|
+
result = _probe_agent(agent_name, db_conn, write_fence=False)
|
|
2190
|
+
after = db_conn.execute(
|
|
2191
|
+
"SELECT installed_version, capability_hash FROM harness_records WHERE harness_name=?",
|
|
2192
|
+
(agent_name,),
|
|
2193
|
+
).fetchone()
|
|
2194
|
+
except Exception as exc:
|
|
2195
|
+
print(f" Housekeeping probe failed for {agent_name}: {exc}")
|
|
2196
|
+
printed = True
|
|
2197
|
+
continue
|
|
2198
|
+
if before != after:
|
|
2199
|
+
before_version = before[0] if before else "none"
|
|
2200
|
+
after_version = after[0] if after else result.get("version", "unknown")
|
|
2201
|
+
status = result.get("status", "unknown")
|
|
2202
|
+
print(f" Probe drift for {agent_name}: {before_version} → {after_version} ({status})")
|
|
2203
|
+
baseline = HARNESS_CAPABILITY_BASELINES.get(agent_name)
|
|
2204
|
+
if baseline is not None:
|
|
2205
|
+
baseline["last_probe_snapshot"] = {
|
|
2206
|
+
"installed_version": after_version,
|
|
2207
|
+
"capability_hash": after[1] if after else "",
|
|
2208
|
+
"status": status,
|
|
2209
|
+
}
|
|
2210
|
+
printed = True
|
|
2211
|
+
try:
|
|
2212
|
+
_repair_sops_only(harness_name=agent_name, dry_run=False)
|
|
2213
|
+
except Exception as exc:
|
|
2214
|
+
print(f" Housekeeping repair failed for {agent_name}: {exc}")
|
|
2215
|
+
printed = True
|
|
2216
|
+
finally:
|
|
2217
|
+
if db_conn is not None:
|
|
2218
|
+
db_conn.close()
|
|
2219
|
+
|
|
2220
|
+
config["last_housekeeping_date"] = today
|
|
2221
|
+
if os.path.exists(workspace_path):
|
|
2222
|
+
workspace_data = load_workspace()
|
|
2223
|
+
workspace_data["last_housekeeping_date"] = today
|
|
2224
|
+
_write_json_atomic(workspace_path, workspace_data)
|
|
2225
|
+
else:
|
|
2226
|
+
_write_json_atomic(legacy_path, config)
|
|
2227
|
+
|
|
2228
|
+
if not printed:
|
|
2229
|
+
return
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def cmd_configure_agent(
|
|
2233
|
+
name: str,
|
|
2234
|
+
flags: dict = None,
|
|
2235
|
+
envs: dict = None,
|
|
2236
|
+
network_deps: list = None,
|
|
2237
|
+
) -> None:
|
|
2238
|
+
"""Write per-project harness overrides to .agents/<name>.json."""
|
|
2239
|
+
flags = flags or {}
|
|
2240
|
+
envs = envs or {}
|
|
2241
|
+
network_deps = network_deps or []
|
|
2242
|
+
|
|
2243
|
+
os.makedirs(".agents", exist_ok=True)
|
|
2244
|
+
profile_path = os.path.join(".agents", f"{name}.json")
|
|
2245
|
+
profile: dict = {}
|
|
2246
|
+
if os.path.exists(profile_path):
|
|
2247
|
+
try:
|
|
2248
|
+
with open(profile_path) as f:
|
|
2249
|
+
profile = json.load(f)
|
|
2250
|
+
except (json.JSONDecodeError, OSError):
|
|
2251
|
+
profile = {}
|
|
2252
|
+
|
|
2253
|
+
overrides = profile.setdefault(
|
|
2254
|
+
"harness_overrides",
|
|
2255
|
+
{"dispatch_flags": {}, "env": {}, "network_deps": []},
|
|
2256
|
+
)
|
|
2257
|
+
overrides.setdefault("dispatch_flags", {})
|
|
2258
|
+
overrides.setdefault("env", {})
|
|
2259
|
+
overrides.setdefault("network_deps", [])
|
|
2260
|
+
|
|
2261
|
+
overrides["dispatch_flags"].update(flags)
|
|
2262
|
+
overrides["env"].update(envs)
|
|
2263
|
+
for dep in network_deps:
|
|
2264
|
+
if dep not in overrides["network_deps"]:
|
|
2265
|
+
overrides["network_deps"].append(dep)
|
|
2266
|
+
|
|
2267
|
+
with open(profile_path, "w") as f:
|
|
2268
|
+
json.dump(profile, f, indent=2)
|
|
2269
|
+
f.write("\n")
|
|
2270
|
+
|
|
2271
|
+
print(f" ✓ {name}: harness overrides written to {profile_path}")
|
|
2272
|
+
if flags:
|
|
2273
|
+
print(f" flags: {flags}")
|
|
2274
|
+
if envs:
|
|
2275
|
+
print(f" env: {envs}")
|
|
2276
|
+
if network_deps:
|
|
2277
|
+
print(f" deps: {network_deps}")
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
|
|
2305
|
+
def cmd_shell(story_id: str = None) -> None:
|
|
2306
|
+
"""Spawns an interactive subshell with synlynk context env vars injected.
|
|
2307
|
+
|
|
2308
|
+
The shell runs in the current directory (worktree-per-story lands in v0.5.0).
|
|
2309
|
+
On exit the calling process resumes normally.
|
|
2310
|
+
"""
|
|
2311
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
2312
|
+
env = {**os.environ,
|
|
2313
|
+
"SYNLYNK_PROJECT_DIR": os.path.abspath("."),
|
|
2314
|
+
"SYNLYNK_STORY_ID": story_id or "",
|
|
2315
|
+
"SYNLYNK_CONTEXT": os.path.abspath(".synlynk/context.md")}
|
|
2316
|
+
label = f"story #{story_id}" if story_id else "synlynk"
|
|
2317
|
+
print(f"{_BOLD}Entering synlynk shell ({label}).{_RESET} "
|
|
2318
|
+
f"Type {_CYAN}exit{_RESET} to return.")
|
|
2319
|
+
subprocess.run([shell], env=env)
|
|
2320
|
+
print(f"{_DIM}Returned from synlynk shell.{_RESET}")
|
|
2321
|
+
|
|
2322
|
+
|
|
2323
|
+
def cmd_launch(agent: str, story_id: str = None) -> None:
|
|
2324
|
+
"""Launches a harness CLI interactively in the current directory.
|
|
2325
|
+
|
|
2326
|
+
Pre-generates .synlynk/context-<agent>.md and starts the CLI so the
|
|
2327
|
+
agent reads it as initial context. Stdout/stderr are not captured —
|
|
2328
|
+
this is an interactive session. Telemetry is logged on exit.
|
|
2329
|
+
"""
|
|
2330
|
+
if agent not in HARNESS_CAPABILITY_BASELINES:
|
|
2331
|
+
print(f"Unknown agent '{agent}'. Known: {list(HARNESS_CAPABILITY_BASELINES)}")
|
|
2332
|
+
return
|
|
2333
|
+
|
|
2334
|
+
cli = HARNESS_CAPABILITY_BASELINES[agent]["cli"]
|
|
2335
|
+
|
|
2336
|
+
try:
|
|
2337
|
+
generate_context(scope="full")
|
|
2338
|
+
except Exception:
|
|
2339
|
+
pass
|
|
2340
|
+
src = ".synlynk/context.md"
|
|
2341
|
+
dest = f".synlynk/context-{agent}.md"
|
|
2342
|
+
if os.path.exists(src):
|
|
2343
|
+
import shutil as _shutil
|
|
2344
|
+
_shutil.copy(src, dest)
|
|
2345
|
+
|
|
2346
|
+
label = f"story #{story_id}" if story_id else "interactive session"
|
|
2347
|
+
print(f"{_BOLD}Launching {agent} — {label}.{_RESET}")
|
|
2348
|
+
print(f" Context: {_CYAN}{dest}{_RESET}")
|
|
2349
|
+
print(f" Exit the agent to return to synlynk.\n")
|
|
2350
|
+
|
|
2351
|
+
start = time.time()
|
|
2352
|
+
result = subprocess.run([cli])
|
|
2353
|
+
duration = time.time() - start
|
|
2354
|
+
|
|
2355
|
+
log_telemetry_event({"type": "launch", "agent": agent,
|
|
2356
|
+
"story_id": story_id, "exit_code": result.returncode,
|
|
2357
|
+
"duration_s": round(duration, 1)})
|
|
2358
|
+
model_version = extract_model_version("", agent=agent)
|
|
2359
|
+
update_costs(
|
|
2360
|
+
cli,
|
|
2361
|
+
0,
|
|
2362
|
+
0,
|
|
2363
|
+
duration,
|
|
2364
|
+
cache_read_tokens=0,
|
|
2365
|
+
model_version=model_version,
|
|
2366
|
+
story_id=story_id,
|
|
2367
|
+
agent=agent,
|
|
2368
|
+
basis="none",
|
|
2369
|
+
)
|
|
2370
|
+
print(f"\n{_DIM}Returned from {agent}. Duration: {duration:.0f}s{_RESET}")
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
def cmd_run_trio(task: str, story_id: str = None) -> None:
|
|
2376
|
+
"""Dispatches all functional harnesses in parallel — one job per harness.
|
|
2377
|
+
|
|
2378
|
+
This is a parallel convenience wrapper, NOT the sequential Trio pipeline.
|
|
2379
|
+
Each agent gets the same task description and full context. For the
|
|
2380
|
+
sequential Architect→Build→Verify pipeline, see the Trio Protocol spec.
|
|
2381
|
+
"""
|
|
2382
|
+
agents = [a for a in discover_agents() if a["functional"]]
|
|
2383
|
+
if not agents:
|
|
2384
|
+
print("No functional agents found. Synlynk is a neutral control plane that routes coding tasks across AI vendors and local models, then proves the result. Run `synlynk init` to configure an agent.")
|
|
2385
|
+
return
|
|
2386
|
+
if len(agents) < 3:
|
|
2387
|
+
print(f" {_YELLOW}Only {len(agents)} agent(s) available "
|
|
2388
|
+
f"(trio needs 3). Dispatching what's configured.{_RESET}")
|
|
2389
|
+
|
|
2390
|
+
print(f"{_BOLD}✨ Dispatching {len(agents)} agents in parallel{_RESET}")
|
|
2391
|
+
print(f" Task: {task}\n")
|
|
2392
|
+
|
|
2393
|
+
jobs = []
|
|
2394
|
+
for ag in agents:
|
|
2395
|
+
job = dispatch_agent(ag["name"], task, story_id=story_id)
|
|
2396
|
+
jobs.append(job)
|
|
2397
|
+
role = ag["roles"][0] if ag["roles"] else "worker"
|
|
2398
|
+
print(f" {_GREEN}▶{_RESET} [{job['id']}] {ag['name']:10} → {role} PID {job['pid']}")
|
|
2399
|
+
|
|
2400
|
+
print(f"\n {_DIM}All agents running in background.{_RESET}")
|
|
2401
|
+
print(f" Monitor with: {_CYAN}synlynk jobs{_RESET}")
|
|
2402
|
+
print(f" View output: {_CYAN}synlynk logs <job-id>{_RESET}")
|
|
2403
|
+
|
|
2404
|
+
|
|
2405
|
+
def set_state(state: str) -> None:
|
|
2406
|
+
"""Writes synlynk state to .synlynk/state and updates terminal title."""
|
|
2407
|
+
icons = {"watching": "●", "active": "⚡", "stopped": "○"}
|
|
2408
|
+
state_file = ".synlynk/state"
|
|
2409
|
+
if not os.path.exists(".synlynk"):
|
|
2410
|
+
return
|
|
2411
|
+
with open(state_file, "w") as f:
|
|
2412
|
+
f.write(state)
|
|
2413
|
+
if sys.stdout.isatty():
|
|
2414
|
+
project = os.path.basename(os.getcwd())
|
|
2415
|
+
title = f"{icons.get(state, '○')} synlynk: {state} · {project}"
|
|
2416
|
+
sys.stdout.write(f"\033]0;{title}\007")
|
|
2417
|
+
sys.stdout.flush()
|
|
2418
|
+
|
|
2419
|
+
def detect_remote_owner_repo() -> tuple:
|
|
2420
|
+
"""Returns (owner, repo) from git remote origin URL, or (None, None)."""
|
|
2421
|
+
try:
|
|
2422
|
+
result = subprocess.run(
|
|
2423
|
+
["git", "remote", "get-url", "origin"],
|
|
2424
|
+
capture_output=True, text=True
|
|
2425
|
+
)
|
|
2426
|
+
if result.returncode != 0:
|
|
2427
|
+
return None, None
|
|
2428
|
+
url = result.stdout.strip().rstrip("/")
|
|
2429
|
+
if url.endswith(".git"):
|
|
2430
|
+
url = url[:-4]
|
|
2431
|
+
if "github.com/" in url:
|
|
2432
|
+
path = url.split("github.com/")[-1]
|
|
2433
|
+
elif "github.com:" in url:
|
|
2434
|
+
path = url.split("github.com:")[-1]
|
|
2435
|
+
else:
|
|
2436
|
+
return None, None
|
|
2437
|
+
parts = path.split("/")
|
|
2438
|
+
return (parts[0], parts[1]) if len(parts) >= 2 else (None, None)
|
|
2439
|
+
except Exception:
|
|
2440
|
+
return None, None
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def _update_config(updates: dict) -> None:
|
|
2444
|
+
"""Merges updates into the owning split config file(s) (workspace.json / billing.json / policy.json)."""
|
|
2445
|
+
if not os.path.exists(".synlynk"):
|
|
2446
|
+
return
|
|
2447
|
+
|
|
2448
|
+
workspace_updates = {}
|
|
2449
|
+
billing_updates = {}
|
|
2450
|
+
policy_updates = {}
|
|
2451
|
+
for key, value in updates.items():
|
|
2452
|
+
if key in _BILLING_KEYS:
|
|
2453
|
+
billing_updates[key] = value
|
|
2454
|
+
elif key in _POLICY_MIGRATED_KEYS:
|
|
2455
|
+
policy_updates[key] = value
|
|
2456
|
+
else:
|
|
2457
|
+
workspace_updates[key] = value
|
|
2458
|
+
|
|
2459
|
+
if workspace_updates:
|
|
2460
|
+
workspace_data = load_workspace()
|
|
2461
|
+
workspace_data.update(workspace_updates)
|
|
2462
|
+
_write_json_atomic(".synlynk/workspace.json", workspace_data)
|
|
2463
|
+
|
|
2464
|
+
if billing_updates:
|
|
2465
|
+
billing_data = load_billing()
|
|
2466
|
+
billing_data.update(billing_updates)
|
|
2467
|
+
_write_json_atomic(".synlynk/billing.json", billing_data)
|
|
2468
|
+
|
|
2469
|
+
if policy_updates:
|
|
2470
|
+
policy_data = _read_raw_policy()
|
|
2471
|
+
policy_data.setdefault("schema_version", 1)
|
|
2472
|
+
policy_data.update(policy_updates)
|
|
2473
|
+
_write_json_atomic(".synlynk/policy.json", policy_data)
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
# Task 3-5: Repo scanning, maturity detection, section signals, semantic matching, GH ID extraction
|
|
2477
|
+
_PROJECT_DOC_NAMES = {"roadmap.md", "todo.md", "memory.md", "costs.md", "devlog.md"}
|
|
2478
|
+
_AGENT_FILE_NAMES = {"CLAUDE.md", "GEMINI.md", "AGENTS.md", "GROK.md", "AI_INSTRUCTIONS.md"}
|
|
2479
|
+
_SCAN_SKIP_DIRS = {
|
|
2480
|
+
".git", "node_modules", ".synlynk", "project-docs",
|
|
2481
|
+
"__pycache__", ".venv", "venv", "env", ".next", "dist", "build",
|
|
2482
|
+
"vendor", ".worktrees", "coverage", ".nyc_output", "target", "out", "tmp",
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
_SOURCE_EXTENSIONS = {
|
|
2486
|
+
".py": "python",
|
|
2487
|
+
".js": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
2488
|
+
".ts": "typescript", ".tsx": "typescript",
|
|
2489
|
+
".go": "go",
|
|
2490
|
+
".rs": "rust",
|
|
2491
|
+
".rb": "ruby",
|
|
2492
|
+
".java": "java",
|
|
2493
|
+
".kt": "kotlin",
|
|
2494
|
+
".sh": "shell",
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
_SOURCE_ENTRY_POINTS = {
|
|
2498
|
+
"main.py", "app.py", "server.py", "index.js", "index.ts", "main.go",
|
|
2499
|
+
"lib.rs", "main.rs", "app.rb", "manage.py", "wsgi.py", "asgi.py", "__init__.py",
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
_SYMBOL_PATTERNS = {
|
|
2503
|
+
"python": [
|
|
2504
|
+
(re.compile(r"^async def (\w+)"), "async_function"),
|
|
2505
|
+
(re.compile(r"^def (\w+)"), "function"),
|
|
2506
|
+
(re.compile(r"^class (\w+)"), "class"),
|
|
2507
|
+
(re.compile(r"^([A-Z_]{2,})\s*="), "constant"),
|
|
2508
|
+
],
|
|
2509
|
+
"javascript": [
|
|
2510
|
+
(re.compile(r"^export (?:default )?(?:async )?function (\w+)"), "function"),
|
|
2511
|
+
(re.compile(r"^export (?:default )?class (\w+)"), "class"),
|
|
2512
|
+
(re.compile(r"^export const (\w+)"), "constant"),
|
|
2513
|
+
(re.compile(r"^function (\w+)"), "function"),
|
|
2514
|
+
(re.compile(r"^class (\w+)"), "class"),
|
|
2515
|
+
],
|
|
2516
|
+
"typescript": [
|
|
2517
|
+
(re.compile(r"^export (?:default )?(?:async )?function (\w+)"), "function"),
|
|
2518
|
+
(re.compile(r"^export (?:default )?class (\w+)"), "class"),
|
|
2519
|
+
(re.compile(r"^export interface (\w+)"), "interface"),
|
|
2520
|
+
(re.compile(r"^export type (\w+)"), "type"),
|
|
2521
|
+
(re.compile(r"^export enum (\w+)"), "enum"),
|
|
2522
|
+
(re.compile(r"^export const (\w+)"), "constant"),
|
|
2523
|
+
(re.compile(r"^function (\w+)"), "function"),
|
|
2524
|
+
(re.compile(r"^class (\w+)"), "class"),
|
|
2525
|
+
],
|
|
2526
|
+
"go": [
|
|
2527
|
+
(re.compile(r"^func (?:\(\w+ \*?\w+\) )?(\w+)"), "function"),
|
|
2528
|
+
(re.compile(r"^type (\w+) struct"), "struct"),
|
|
2529
|
+
(re.compile(r"^type (\w+) interface"), "interface"),
|
|
2530
|
+
],
|
|
2531
|
+
"rust": [
|
|
2532
|
+
(re.compile(r"^pub fn (\w+)"), "function"),
|
|
2533
|
+
(re.compile(r"^pub struct (\w+)"), "struct"),
|
|
2534
|
+
(re.compile(r"^pub trait (\w+)"), "trait"),
|
|
2535
|
+
(re.compile(r"^pub enum (\w+)"), "enum"),
|
|
2536
|
+
(re.compile(r"^pub type (\w+)"), "type"),
|
|
2537
|
+
],
|
|
2538
|
+
"ruby": [
|
|
2539
|
+
(re.compile(r"^def (\w+)"), "function"),
|
|
2540
|
+
(re.compile(r"^class (\w+)"), "class"),
|
|
2541
|
+
(re.compile(r"^module (\w+)"), "module"),
|
|
2542
|
+
],
|
|
2543
|
+
"java": [
|
|
2544
|
+
(re.compile(r"(?:public|protected) (?:class|interface|enum) (\w+)"), "class"),
|
|
2545
|
+
(re.compile(r"(?:public|protected) \w+ (\w+)\s*\("), "function"),
|
|
2546
|
+
],
|
|
2547
|
+
"kotlin": [
|
|
2548
|
+
(re.compile(r"^fun (\w+)"), "function"),
|
|
2549
|
+
(re.compile(r"^class (\w+)"), "class"),
|
|
2550
|
+
(re.compile(r"^object (\w+)"), "class"),
|
|
2551
|
+
(re.compile(r"^interface (\w+)"), "interface"),
|
|
2552
|
+
],
|
|
2553
|
+
"shell": [
|
|
2554
|
+
(re.compile(r"^function (\w+)"), "function"),
|
|
2555
|
+
(re.compile(r"^(\w+)\(\)"), "function"),
|
|
2556
|
+
],
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
SECTION_SIGNALS: dict = {
|
|
2560
|
+
"## Live Issues SOP": [
|
|
2561
|
+
"live issue", "live-issue", "sev1", "sev2", "sev3", "rca", "[live-",
|
|
2562
|
+
],
|
|
2563
|
+
"## Mid-Session Anti-Amnesia Protocol": [
|
|
2564
|
+
"25,000 tokens", "25k tokens", "compaction", "compaction imminent",
|
|
2565
|
+
"mid-session", "checkpoint every",
|
|
2566
|
+
],
|
|
2567
|
+
"## Mandatory 4-Doc Discipline": [
|
|
2568
|
+
"roadmap.md", "devlog", "costs.md", "memory.md",
|
|
2569
|
+
"mandatory document", "four doc", "4-doc",
|
|
2570
|
+
],
|
|
2571
|
+
"## GitHub Projects v2 Integration": [
|
|
2572
|
+
"updateProjectV2", "projectId", "PVT_", "PVTSSF_",
|
|
2573
|
+
"github projects", "programme board",
|
|
2574
|
+
],
|
|
2575
|
+
"## Git Worktree-First Policy": [
|
|
2576
|
+
"git worktree", "worktree add", "never commit to main",
|
|
2577
|
+
"never commit to master",
|
|
2578
|
+
],
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
_INSTRUCTIONS_MANIFEST = ".synlynk/instructions.json"
|
|
2617
|
+
|
|
2618
|
+
_INSTRUCTION_TARGETS = [
|
|
2619
|
+
# (path, tool, marker_style, detection_fn)
|
|
2620
|
+
# detection_fn: called in init() to decide whether to write the file.
|
|
2621
|
+
("CLAUDE.md", "claude", "html", lambda: True),
|
|
2622
|
+
("GEMINI.md", "agy", "html", lambda: True),
|
|
2623
|
+
("AGENTS.md", "codex", "html", lambda: True),
|
|
2624
|
+
("GROK.md", "grok", "html", lambda: True),
|
|
2625
|
+
(".cursor/rules/synlynk.mdc", "cursor", "none", lambda: os.path.isdir(".cursor")),
|
|
2626
|
+
(".github/copilot-instructions.md", "copilot", "html", lambda: os.path.isdir(".github")),
|
|
2627
|
+
(".windsurfrules", "windsurf", "hash", lambda: True),
|
|
2628
|
+
("AI_INSTRUCTIONS.md", "universal", "html", lambda: True),
|
|
2629
|
+
]
|
|
2630
|
+
|
|
2631
|
+
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
_MARKER_STYLE_FOR_TOOL = {
|
|
2636
|
+
"claude": "html",
|
|
2637
|
+
"agy": "html",
|
|
2638
|
+
"codex": "html",
|
|
2639
|
+
"grok": "html",
|
|
2640
|
+
"cursor": "none",
|
|
2641
|
+
"copilot": "html",
|
|
2642
|
+
"windsurf": "hash",
|
|
2643
|
+
"universal": "html",
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
def _is_interactive(cmd_args: list) -> bool:
|
|
2658
|
+
"""Returns True if the command needs a real TTY (no stdout capture)."""
|
|
2659
|
+
NON_INTERACTIVE = ["--no-tty", "--output-format json", "--print",
|
|
2660
|
+
"--non-interactive", "-p "]
|
|
2661
|
+
cmd_str = " ".join(cmd_args)
|
|
2662
|
+
return not any(flag in cmd_str for flag in NON_INTERACTIVE)
|
|
2663
|
+
|
|
2664
|
+
|
|
2665
|
+
def _inject_grok_rules(cmd_args: list) -> list:
|
|
2666
|
+
"""Adds Grok rules flags when invoking grok and the rule files exist."""
|
|
2667
|
+
if not cmd_args or cmd_args[0] != "grok":
|
|
2668
|
+
return list(cmd_args)
|
|
2669
|
+
|
|
2670
|
+
injected = [cmd_args[0]]
|
|
2671
|
+
if os.path.exists("GROK.md"):
|
|
2672
|
+
injected.extend(["--rules", "GROK.md"])
|
|
2673
|
+
if "-p" in cmd_args and os.path.exists(os.path.join(".synlynk", "context.md")):
|
|
2674
|
+
injected.extend(["--rules", os.path.join(".synlynk", "context.md")])
|
|
2675
|
+
injected.extend(cmd_args[1:])
|
|
2676
|
+
return injected
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def _tee_process(process, buffer: list) -> None:
|
|
2680
|
+
"""Reads process stdout line-by-line, writes to terminal and appends to buffer."""
|
|
2681
|
+
for line in iter(process.stdout.readline, b''):
|
|
2682
|
+
sys.stdout.buffer.write(line)
|
|
2683
|
+
sys.stdout.buffer.flush()
|
|
2684
|
+
buffer.append(line.decode('utf-8', errors='replace'))
|
|
2685
|
+
process.stdout.close()
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
|
|
2699
|
+
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
|
|
2707
|
+
def _archive_old_devlog_entries(devlog_path: str, author: str) -> None:
|
|
2708
|
+
"""Moves devlog entries older than 30 days to devlogs/archive/YYYY-MM.md."""
|
|
2709
|
+
import calendar
|
|
2710
|
+
if not os.path.exists(devlog_path):
|
|
2711
|
+
return
|
|
2712
|
+
cutoff = time.time() - (30 * 24 * 3600)
|
|
2713
|
+
pattern = re.compile(r'^## (\d{4}-\d{2}-\d{2})')
|
|
2714
|
+
sections = []
|
|
2715
|
+
current_lines, current_date = [], None
|
|
2716
|
+
with open(devlog_path) as f:
|
|
2717
|
+
for line in f:
|
|
2718
|
+
m = pattern.match(line)
|
|
2719
|
+
if m:
|
|
2720
|
+
if current_lines:
|
|
2721
|
+
sections.append((current_date, current_lines))
|
|
2722
|
+
current_date = m.group(1)
|
|
2723
|
+
current_lines = [line]
|
|
2724
|
+
else:
|
|
2725
|
+
current_lines.append(line)
|
|
2726
|
+
if current_lines:
|
|
2727
|
+
sections.append((current_date, current_lines))
|
|
2728
|
+
|
|
2729
|
+
keep, archive_by_month, archived_dates = [], {}, []
|
|
2730
|
+
for date_str, lines in sections:
|
|
2731
|
+
if date_str is None:
|
|
2732
|
+
keep.append((date_str, lines))
|
|
2733
|
+
continue
|
|
2734
|
+
try:
|
|
2735
|
+
ts = calendar.timegm(time.strptime(date_str, "%Y-%m-%d"))
|
|
2736
|
+
if ts < cutoff:
|
|
2737
|
+
month_key = date_str[:7]
|
|
2738
|
+
archive_by_month.setdefault(month_key, []).extend(lines)
|
|
2739
|
+
archived_dates.append(date_str)
|
|
2740
|
+
else:
|
|
2741
|
+
keep.append((date_str, lines))
|
|
2742
|
+
except ValueError:
|
|
2743
|
+
keep.append((date_str, lines))
|
|
2744
|
+
|
|
2745
|
+
if not archive_by_month:
|
|
2746
|
+
return
|
|
2747
|
+
|
|
2748
|
+
archive_dir = os.path.join(os.path.dirname(devlog_path), "archive")
|
|
2749
|
+
os.makedirs(archive_dir, exist_ok=True)
|
|
2750
|
+
for month_key, lines in archive_by_month.items():
|
|
2751
|
+
with open(os.path.join(archive_dir, f"{month_key}.md"), "a") as f:
|
|
2752
|
+
f.writelines(lines)
|
|
2753
|
+
|
|
2754
|
+
with open(devlog_path, "w") as f:
|
|
2755
|
+
for _, lines in keep:
|
|
2756
|
+
f.writelines(lines)
|
|
2757
|
+
|
|
2758
|
+
conn = _get_db()
|
|
2759
|
+
conn.executemany(
|
|
2760
|
+
"DELETE FROM devlog_entries WHERE author=? AND entry_date=?",
|
|
2761
|
+
[(author, date_str) for date_str in archived_dates],
|
|
2762
|
+
)
|
|
2763
|
+
conn.commit()
|
|
2764
|
+
conn.close()
|
|
2765
|
+
|
|
2766
|
+
def _resolve_member_id(username: str) -> str:
|
|
2767
|
+
"""Looks up username in the member_aliases registry; falls back to username
|
|
2768
|
+
itself when unregistered (matches audit-docs' "unregistered" finding — an
|
|
2769
|
+
unregistered identity is reported, never silently reassigned)."""
|
|
2770
|
+
try:
|
|
2771
|
+
conn = _get_db()
|
|
2772
|
+
row = conn.execute(
|
|
2773
|
+
"SELECT member_id FROM member_aliases WHERE alias = ?", (username,)
|
|
2774
|
+
).fetchone()
|
|
2775
|
+
conn.close()
|
|
2776
|
+
return row[0] if row else username
|
|
2777
|
+
except Exception:
|
|
2778
|
+
return username
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def checkpoint() -> None:
|
|
2782
|
+
"""Archives done tasks, refreshes context, and emits a telemetry event."""
|
|
2783
|
+
set_state("active")
|
|
2784
|
+
_check_upstream_divergence()
|
|
2785
|
+
username = get_username()
|
|
2786
|
+
canonical_id = _resolve_member_id(username)
|
|
2787
|
+
if _is_migrated():
|
|
2788
|
+
devlog_path = os.path.join(_synlynk_project_docs_dir(), "devlogs", f"{canonical_id}.md")
|
|
2789
|
+
docs_dir = _synlynk_project_docs_dir()
|
|
2790
|
+
else:
|
|
2791
|
+
devlog_path = os.path.join(_docs_dir(), "devlogs", f"{canonical_id}.md")
|
|
2792
|
+
docs_dir = _docs_dir()
|
|
2793
|
+
|
|
2794
|
+
# Legacy projects may have a hand-written todo.md but no story rows yet.
|
|
2795
|
+
# Import it once before checkpointing so the database remains the source of truth.
|
|
2796
|
+
conn = _get_db()
|
|
2797
|
+
story_count = conn.execute("SELECT COUNT(*) FROM stories").fetchone()[0]
|
|
2798
|
+
if story_count == 0:
|
|
2799
|
+
_import_todo_to_stories(docs_dir, conn=conn)
|
|
2800
|
+
conn.commit()
|
|
2801
|
+
|
|
2802
|
+
resolved_rows = conn.execute(
|
|
2803
|
+
"SELECT story_id, title FROM stories "
|
|
2804
|
+
"WHERE archived_at IS NULL AND status IN ('done', 'superseded', 'absorbed') "
|
|
2805
|
+
"ORDER BY created_at ASC, id ASC"
|
|
2806
|
+
).fetchall()
|
|
2807
|
+
completed = [{"id": story_id, "text": title or story_id} for story_id, title in resolved_rows]
|
|
2808
|
+
if resolved_rows:
|
|
2809
|
+
archived_at = time.strftime('%Y-%m-%d %H:%M:%S')
|
|
2810
|
+
conn.executemany(
|
|
2811
|
+
"UPDATE stories SET archived_at=? WHERE story_id=? AND archived_at IS NULL",
|
|
2812
|
+
[(archived_at, story_id) for story_id, _ in resolved_rows],
|
|
2813
|
+
)
|
|
2814
|
+
conn.commit()
|
|
2815
|
+
conn.close()
|
|
2816
|
+
|
|
2817
|
+
# Write resolved tasks through to the devlog (DB row + regenerated flat file)
|
|
2818
|
+
if completed:
|
|
2819
|
+
body_lines = ["### Resolved (checkpoint)"]
|
|
2820
|
+
for task in completed:
|
|
2821
|
+
body_lines.append(f"- {task['text']}")
|
|
2822
|
+
cmd_devlog_append(canonical_id, time.strftime('%Y-%m-%d'), "\n".join(body_lines) + "\n")
|
|
2823
|
+
if resolved_rows:
|
|
2824
|
+
_generate_todo_md()
|
|
2825
|
+
|
|
2826
|
+
_archive_old_devlog_entries(devlog_path, canonical_id)
|
|
2827
|
+
if os.path.exists(devlog_path):
|
|
2828
|
+
try:
|
|
2829
|
+
with open(devlog_path, "r", encoding="utf-8") as _df:
|
|
2830
|
+
_devlog_text = _df.read()
|
|
2831
|
+
_discovered = extract_from_devlog_content(_devlog_text, author=canonical_id)
|
|
2832
|
+
for _d in _discovered:
|
|
2833
|
+
stage_discovered_work(
|
|
2834
|
+
title=_d["title"],
|
|
2835
|
+
description=_d.get("description", ""),
|
|
2836
|
+
role=_d.get("role", "dev"),
|
|
2837
|
+
stage=_d.get("stage", "open"),
|
|
2838
|
+
source_type=_d.get("source_type", "devlog"),
|
|
2839
|
+
source_ref=_d.get("source_ref", f"devlog:{canonical_id}"),
|
|
2840
|
+
)
|
|
2841
|
+
except Exception:
|
|
2842
|
+
pass
|
|
2843
|
+
generate_context()
|
|
2844
|
+
|
|
2845
|
+
completed_ids = [t["id"] for t in completed if t["id"]]
|
|
2846
|
+
log_telemetry_event({
|
|
2847
|
+
"type": "checkpoint",
|
|
2848
|
+
"schema_version": 1,
|
|
2849
|
+
"timestamp": time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
2850
|
+
"user": username,
|
|
2851
|
+
"completed_task_count": len(completed),
|
|
2852
|
+
"completed_task_ids": completed_ids,
|
|
2853
|
+
"devlog_entry_appended": bool(completed),
|
|
2854
|
+
})
|
|
2855
|
+
|
|
2856
|
+
total_usd, total_requests = parse_costs_md()
|
|
2857
|
+
config = load_config()
|
|
2858
|
+
limit_usd = config["budget"]["limit_usd"]
|
|
2859
|
+
pct = (total_usd / limit_usd * 100) if limit_usd else 0
|
|
2860
|
+
|
|
2861
|
+
daemon = WatchDaemon()
|
|
2862
|
+
set_state("watching" if daemon._is_running() else "stopped")
|
|
2863
|
+
|
|
2864
|
+
print(f"\n✓ checkpoint [@{username}] — {len(completed)} tasks archived, context refreshed")
|
|
2865
|
+
if completed:
|
|
2866
|
+
names = " · ".join(f'"{t["text"][:40]}"' for t in completed[:3])
|
|
2867
|
+
print(f" Archived: {names}")
|
|
2868
|
+
print(f" Budget: ${total_usd:.2f} / ${limit_usd:.2f} ({pct:.0f}%) · {total_requests} requests")
|
|
2869
|
+
|
|
2870
|
+
def _mark_roadmap_arc_shipped(version: str) -> None:
|
|
2871
|
+
"""Flip a roadmap arc to shipped and sync its Milestone closed."""
|
|
2872
|
+
conn = _get_db()
|
|
2873
|
+
try:
|
|
2874
|
+
conn.execute(
|
|
2875
|
+
"UPDATE roadmap_arcs SET status='shipped' WHERE version=?", (version,)
|
|
2876
|
+
)
|
|
2877
|
+
conn.commit()
|
|
2878
|
+
finally:
|
|
2879
|
+
conn.close()
|
|
2880
|
+
_try_sync_arc_by_version(version)
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
def cmd_release(
|
|
2884
|
+
dry_run: bool = False,
|
|
2885
|
+
version: Optional[str] = None,
|
|
2886
|
+
bump: bool = False,
|
|
2887
|
+
minor: bool = False,
|
|
2888
|
+
role: str = "pm",
|
|
2889
|
+
check_docs: bool = False,
|
|
2890
|
+
pr_mode: bool = False,
|
|
2891
|
+
waive: Optional[list] = None,
|
|
2892
|
+
) -> None:
|
|
2893
|
+
"""Cut a named release: bump version, prepend CHANGELOG.md, write blog stub, print checklist."""
|
|
2894
|
+
import datetime
|
|
2895
|
+
import re
|
|
2896
|
+
from synlynk.policy import check_authority
|
|
2897
|
+
from synlynk.release_readme import (
|
|
2898
|
+
format_readme_check_report,
|
|
2899
|
+
parse_waivers,
|
|
2900
|
+
validate_readme_for_release,
|
|
2901
|
+
)
|
|
2902
|
+
|
|
2903
|
+
try:
|
|
2904
|
+
waivers = parse_waivers(waive)
|
|
2905
|
+
except ValueError as exc:
|
|
2906
|
+
raise RuntimeError(str(exc)) from exc
|
|
2907
|
+
|
|
2908
|
+
if not check_docs:
|
|
2909
|
+
authority = check_authority("release_cut", role=role, repo_path=os.getcwd())
|
|
2910
|
+
if not authority.allowed:
|
|
2911
|
+
raise RuntimeError(f"Release refused: role {role!r} is not authorized to cut a release per policy.json.")
|
|
2912
|
+
|
|
2913
|
+
# Resolve project root:
|
|
2914
|
+
# First priority: check if a VERSION file exists in CWD. If so, root is CWD.
|
|
2915
|
+
if os.path.exists(os.path.join(os.getcwd(), "VERSION")):
|
|
2916
|
+
root = os.getcwd()
|
|
2917
|
+
else:
|
|
2918
|
+
root = _get_project_root()
|
|
2919
|
+
|
|
2920
|
+
version_path = os.path.join(root, "VERSION")
|
|
2921
|
+
changelog_path = os.path.join(root, "CHANGELOG.md")
|
|
2922
|
+
blog_dir = os.path.join(root, "docs", "blog")
|
|
2923
|
+
|
|
2924
|
+
# Step a: Determine next version
|
|
2925
|
+
current_version = ""
|
|
2926
|
+
if version:
|
|
2927
|
+
next_version = version
|
|
2928
|
+
else:
|
|
2929
|
+
# read current VERSION file
|
|
2930
|
+
if os.path.exists(version_path):
|
|
2931
|
+
with open(version_path, "r") as f:
|
|
2932
|
+
current_version = f.read().strip()
|
|
2933
|
+
else:
|
|
2934
|
+
current_version = "0.10.0"
|
|
2935
|
+
|
|
2936
|
+
# parse current_version
|
|
2937
|
+
parts = current_version.split(".")
|
|
2938
|
+
if len(parts) >= 3:
|
|
2939
|
+
try:
|
|
2940
|
+
major = int(parts[0])
|
|
2941
|
+
minor_ver = int(parts[1])
|
|
2942
|
+
patch = int(parts[2].split("-")[0].split("+")[0]) # strip any prerelease suffix
|
|
2943
|
+
except ValueError:
|
|
2944
|
+
major, minor_ver, patch = 0, 10, 0
|
|
2945
|
+
else:
|
|
2946
|
+
major, minor_ver, patch = 0, 10, 0
|
|
2947
|
+
|
|
2948
|
+
if minor:
|
|
2949
|
+
next_version = f"{major}.{minor_ver + 1}.0"
|
|
2950
|
+
else:
|
|
2951
|
+
next_version = f"{major}.{minor_ver}.{patch + 1}"
|
|
2952
|
+
|
|
2953
|
+
next_version = next_version.lstrip("v")
|
|
2954
|
+
print(f"Proposed version: v{next_version}")
|
|
2955
|
+
|
|
2956
|
+
if check_docs and version is None and not minor:
|
|
2957
|
+
expected_readme_version = current_version.lstrip("v")
|
|
2958
|
+
else:
|
|
2959
|
+
expected_readme_version = next_version
|
|
2960
|
+
|
|
2961
|
+
findings = validate_readme_for_release(
|
|
2962
|
+
root, expected_readme_version, waivers=waivers, pr_mode=pr_mode
|
|
2963
|
+
)
|
|
2964
|
+
if findings and not check_docs:
|
|
2965
|
+
print(f"⚡ [Marketing Ceremony] Auto-synchronizing README.md for v{expected_readme_version}...")
|
|
2966
|
+
from synlynk.release_readme import collect_pytest_test_count, sync_readme_for_release
|
|
2967
|
+
collected = collect_pytest_test_count(root)
|
|
2968
|
+
sync_readme_for_release(root, expected_readme_version, collected_count=collected)
|
|
2969
|
+
findings = validate_readme_for_release(
|
|
2970
|
+
root, expected_readme_version, waivers=waivers, pr_mode=pr_mode
|
|
2971
|
+
)
|
|
2972
|
+
|
|
2973
|
+
report = format_readme_check_report(
|
|
2974
|
+
findings, expected_readme_version, waivers=waivers
|
|
2975
|
+
)
|
|
2976
|
+
print(report)
|
|
2977
|
+
if check_docs:
|
|
2978
|
+
from synlynk.release_authority import (
|
|
2979
|
+
format_version_authority_report,
|
|
2980
|
+
validate_release_authority,
|
|
2981
|
+
)
|
|
2982
|
+
|
|
2983
|
+
authority_version, authority_findings = validate_release_authority(
|
|
2984
|
+
root, expected_version=expected_readme_version
|
|
2985
|
+
)
|
|
2986
|
+
print(format_version_authority_report(authority_version, authority_findings))
|
|
2987
|
+
findings.extend(authority_findings)
|
|
2988
|
+
if findings:
|
|
2989
|
+
if check_docs:
|
|
2990
|
+
sys.exit(1)
|
|
2991
|
+
raise RuntimeError(
|
|
2992
|
+
"README is not synchronized for this named release. "
|
|
2993
|
+
"Update README.md or re-run with --check-docs / --waive check=reason."
|
|
2994
|
+
)
|
|
2995
|
+
if check_docs:
|
|
2996
|
+
return
|
|
2997
|
+
|
|
2998
|
+
# Execute marketing release ceremony across collateral
|
|
2999
|
+
from synlynk.release_marketing import execute_release_ceremony
|
|
3000
|
+
print(f"🚀 [Marketing Ceremony] Executing release ceremony for v{next_version}...")
|
|
3001
|
+
ceremony_res = execute_release_ceremony(
|
|
3002
|
+
root=root,
|
|
3003
|
+
version=next_version,
|
|
3004
|
+
dry_run=dry_run,
|
|
3005
|
+
skip_build=False,
|
|
3006
|
+
)
|
|
3007
|
+
if not ceremony_res.ok:
|
|
3008
|
+
print(f"⚠️ [Marketing Ceremony] Notice: {', '.join(ceremony_res.errors)}")
|
|
3009
|
+
else:
|
|
3010
|
+
print(f"✅ [Marketing Ceremony] Collateral synchronized (README, {len(ceremony_res.docs_updated)} docs bundles, website)")
|
|
3011
|
+
|
|
3012
|
+
# Step b: Read merged stories since last git tag
|
|
3013
|
+
try:
|
|
3014
|
+
tag_cmd = "git describe --tags --abbrev=0 2>/dev/null || echo ''"
|
|
3015
|
+
last_tag = subprocess.check_output(tag_cmd, shell=True).decode("utf-8").strip()
|
|
3016
|
+
except Exception:
|
|
3017
|
+
last_tag = ""
|
|
3018
|
+
|
|
3019
|
+
if last_tag:
|
|
3020
|
+
git_cmd = f"git log {last_tag}..HEAD --oneline"
|
|
3021
|
+
else:
|
|
3022
|
+
git_cmd = "git log -n 20 --oneline"
|
|
3023
|
+
|
|
3024
|
+
try:
|
|
3025
|
+
log_output = subprocess.check_output(git_cmd, shell=True).decode("utf-8").strip()
|
|
3026
|
+
except Exception:
|
|
3027
|
+
log_output = ""
|
|
3028
|
+
|
|
3029
|
+
lines = log_output.splitlines() if log_output else []
|
|
3030
|
+
added = []
|
|
3031
|
+
fixed = []
|
|
3032
|
+
changed = []
|
|
3033
|
+
|
|
3034
|
+
for line in lines:
|
|
3035
|
+
line = line.strip()
|
|
3036
|
+
if not line:
|
|
3037
|
+
continue
|
|
3038
|
+
parts = line.split(" ", 1)
|
|
3039
|
+
if len(parts) < 2:
|
|
3040
|
+
continue
|
|
3041
|
+
msg = parts[1].strip()
|
|
3042
|
+
|
|
3043
|
+
match = re.match(r"^([a-zA-Z0-9_\-]+)(?:\(.*?\))?\s*:\s*(.*)", msg)
|
|
3044
|
+
if match:
|
|
3045
|
+
prefix = match.group(1).lower()
|
|
3046
|
+
else:
|
|
3047
|
+
prefix = ""
|
|
3048
|
+
|
|
3049
|
+
if prefix == "feat":
|
|
3050
|
+
added.append(msg)
|
|
3051
|
+
elif prefix == "fix":
|
|
3052
|
+
fixed.append(msg)
|
|
3053
|
+
else:
|
|
3054
|
+
changed.append(msg)
|
|
3055
|
+
|
|
3056
|
+
# Step c: Build CHANGELOG entry
|
|
3057
|
+
today = datetime.date.today().isoformat()
|
|
3058
|
+
changelog_parts = [f"## [v{next_version}] - {today}"]
|
|
3059
|
+
if added:
|
|
3060
|
+
changelog_parts.append("### Added")
|
|
3061
|
+
changelog_parts.extend(f"- {item}" for item in added)
|
|
3062
|
+
if fixed:
|
|
3063
|
+
changelog_parts.append("### Fixed")
|
|
3064
|
+
changelog_parts.extend(f"- {item}" for item in fixed)
|
|
3065
|
+
if changed:
|
|
3066
|
+
changelog_parts.append("### Changed")
|
|
3067
|
+
changelog_parts.extend(f"- {item}" for item in changed)
|
|
3068
|
+
|
|
3069
|
+
changelog_entry = "\n".join(changelog_parts) + "\n\n"
|
|
3070
|
+
|
|
3071
|
+
# Step d: Build blog post stub
|
|
3072
|
+
# Determine NN
|
|
3073
|
+
next_nn = 0
|
|
3074
|
+
if os.path.exists(blog_dir):
|
|
3075
|
+
existing_numbers = []
|
|
3076
|
+
for name in os.listdir(blog_dir):
|
|
3077
|
+
if name.endswith(".md"):
|
|
3078
|
+
match = re.match(r"^(\d+)-", name)
|
|
3079
|
+
if match:
|
|
3080
|
+
existing_numbers.append(int(match.group(1)))
|
|
3081
|
+
if existing_numbers:
|
|
3082
|
+
next_nn = max(existing_numbers) + 1
|
|
3083
|
+
|
|
3084
|
+
nn_str = f"{next_nn:02d}"
|
|
3085
|
+
blog_filename = f"{nn_str}-prTBD-v{next_version}.md"
|
|
3086
|
+
blog_path = os.path.join(blog_dir, blog_filename)
|
|
3087
|
+
|
|
3088
|
+
# Read template from docs/blog/README.md if it exists
|
|
3089
|
+
readme_path = os.path.join(blog_dir, "README.md")
|
|
3090
|
+
template_content = None
|
|
3091
|
+
if os.path.exists(readme_path):
|
|
3092
|
+
try:
|
|
3093
|
+
with open(readme_path, "r") as f:
|
|
3094
|
+
readme_text = f.read()
|
|
3095
|
+
h_idx = readme_text.find("## Per-PR Post Template")
|
|
3096
|
+
if h_idx != -1:
|
|
3097
|
+
sub = readme_text[h_idx:]
|
|
3098
|
+
m_idx = sub.find("```markdown")
|
|
3099
|
+
if m_idx != -1:
|
|
3100
|
+
start_idx = m_idx + len("```markdown")
|
|
3101
|
+
end_idx = sub.find("```", start_idx)
|
|
3102
|
+
if end_idx != -1:
|
|
3103
|
+
template_content = sub[start_idx:end_idx].strip()
|
|
3104
|
+
except Exception:
|
|
3105
|
+
pass
|
|
3106
|
+
|
|
3107
|
+
if not template_content:
|
|
3108
|
+
template_content = """---
|
|
3109
|
+
title: "PR #N — <theme>"
|
|
3110
|
+
date: YYYY-MM-DD
|
|
3111
|
+
series: "Building the OS for Multi-Agent Development"
|
|
3112
|
+
post: N
|
|
3113
|
+
pr: "#N"
|
|
3114
|
+
merged: YYYY-MM-DD (or status: open)
|
|
3115
|
+
---
|
|
3116
|
+
|
|
3117
|
+
## The Broader Goal at the End of the Previous PR
|
|
3118
|
+
[What was the stated/understood goal before this work started?]
|
|
3119
|
+
|
|
3120
|
+
## Strategic Shifts in This PR (if any)
|
|
3121
|
+
[What changed in the broader strategy? What moved the goalpost and why?]
|
|
3122
|
+
|
|
3123
|
+
## What This PR Shipped
|
|
3124
|
+
[Deep technical description: commands, key implementation decisions, data structures, test approach]
|
|
3125
|
+
|
|
3126
|
+
## Brainstorm Visuals Used
|
|
3127
|
+
[Links to any HTML brainstorm files in docs/brainstorm/ that informed decisions in this PR]
|
|
3128
|
+
|
|
3129
|
+
## What This Achieved on the Path to Autonomy
|
|
3130
|
+
[Specific ways this PR advances the eventual goal of autonomous multi-agent dispatch]
|
|
3131
|
+
|
|
3132
|
+
## Strategic Note: The Goal at the End of This PR
|
|
3133
|
+
[The new goalpost, as understood after this PR's decisions]"""
|
|
3134
|
+
|
|
3135
|
+
stub_content = template_content
|
|
3136
|
+
stub_content = stub_content.replace('title: "PR #N — <theme>"', f'title: "PR #TBD — v{next_version} Release"')
|
|
3137
|
+
stub_content = stub_content.replace('date: YYYY-MM-DD', f'date: {today}')
|
|
3138
|
+
stub_content = stub_content.replace('post: N', f'post: {next_nn}')
|
|
3139
|
+
stub_content = stub_content.replace('pr: "#N"', 'pr: "#TBD"')
|
|
3140
|
+
stub_content = stub_content.replace('merged: YYYY-MM-DD (or status: open)', 'status: open')
|
|
3141
|
+
stub_content = stub_content.replace('merged: status' + ': open', 'status: open')
|
|
3142
|
+
|
|
3143
|
+
if dry_run:
|
|
3144
|
+
# Step f: In dry-run mode, print everything but write nothing
|
|
3145
|
+
print("\n--- Proposed CHANGELOG Entry ---")
|
|
3146
|
+
print(changelog_entry.strip())
|
|
3147
|
+
print("\n--- Proposed Blog Post Stub ---")
|
|
3148
|
+
print(f"Path: docs/blog/{blog_filename}")
|
|
3149
|
+
print(stub_content.strip())
|
|
3150
|
+
print("\n--------------------------------")
|
|
3151
|
+
else:
|
|
3152
|
+
# Write the single canonical VERSION source.
|
|
3153
|
+
with open(version_path, "w") as f:
|
|
3154
|
+
f.write(next_version + "\n")
|
|
3155
|
+
|
|
3156
|
+
# Prepend to CHANGELOG.md (create if missing)
|
|
3157
|
+
if os.path.exists(changelog_path):
|
|
3158
|
+
with open(changelog_path, "r") as f:
|
|
3159
|
+
old_content = f.read()
|
|
3160
|
+
match = re.search(r"##\s*\[v?\d+\.\d+\.\d+\]", old_content)
|
|
3161
|
+
if match:
|
|
3162
|
+
idx = match.start()
|
|
3163
|
+
new_changelog = old_content[:idx] + changelog_entry + "---\n\n" + old_content[idx:]
|
|
3164
|
+
else:
|
|
3165
|
+
idx = old_content.find("## ")
|
|
3166
|
+
if idx != -1:
|
|
3167
|
+
new_changelog = old_content[:idx] + changelog_entry + "---\n\n" + old_content[idx:]
|
|
3168
|
+
else:
|
|
3169
|
+
new_changelog = old_content + "\n\n" + changelog_entry
|
|
3170
|
+
else:
|
|
3171
|
+
new_changelog = "# Changelog\n\nAll notable changes to synlynk are documented here.\n\n" + changelog_entry
|
|
3172
|
+
|
|
3173
|
+
with open(changelog_path, "w") as f:
|
|
3174
|
+
f.write(new_changelog)
|
|
3175
|
+
|
|
3176
|
+
# Write blog post stub
|
|
3177
|
+
os.makedirs(os.path.dirname(blog_path), exist_ok=True)
|
|
3178
|
+
with open(blog_path, "w") as f:
|
|
3179
|
+
f.write(stub_content + "\n")
|
|
3180
|
+
|
|
3181
|
+
# Step e: Print named release checklist
|
|
3182
|
+
print(f"synlynk release checklist for v{next_version}")
|
|
3183
|
+
print("[x] VERSION bumped")
|
|
3184
|
+
print("[x] CHANGELOG entry written")
|
|
3185
|
+
print(f"[x] Blog post stub: docs/blog/{blog_filename}")
|
|
3186
|
+
print("[x] README synchronized (version, collected test-count not pass/fail, hero, install, links, commands)")
|
|
3187
|
+
print("[x] Marketing Release Ceremony completed:")
|
|
3188
|
+
print(" - [x] GitHub README synchronized")
|
|
3189
|
+
print(f" - [{'x' if ceremony_res.website_updated else ' '}] Synlynk.com website (website/) updated for release")
|
|
3190
|
+
print(f" - [{'x' if ceremony_res.docs_updated else ' '}] Synlynk Docs bundles synchronized: Quick Start, Official Reference, Command Ref (HTML & PDF)")
|
|
3191
|
+
print(f"[ ] git tag v{next_version} && git push --tags")
|
|
3192
|
+
print(f"[ ] gh release create v{next_version}")
|
|
3193
|
+
if dry_run:
|
|
3194
|
+
print(f" [ ] Roadmap row would be marked shipped and Milestone closed — {next_version}")
|
|
3195
|
+
else:
|
|
3196
|
+
_mark_roadmap_arc_shipped(next_version)
|
|
3197
|
+
print(f" [x] Roadmap row marked shipped and Milestone closed — {next_version}")
|
|
3198
|
+
|
|
3199
|
+
|
|
3200
|
+
def cmd_marketing_ceremony(
|
|
3201
|
+
version: Optional[str] = None,
|
|
3202
|
+
dry_run: bool = False,
|
|
3203
|
+
skip_build: bool = False,
|
|
3204
|
+
) -> None:
|
|
3205
|
+
"""Execute the Marketing Release Ceremony across README, docs bundles, and website."""
|
|
3206
|
+
from synlynk.release_marketing import execute_release_ceremony
|
|
3207
|
+
|
|
3208
|
+
root = os.getcwd()
|
|
3209
|
+
if not os.path.exists(os.path.join(root, "VERSION")):
|
|
3210
|
+
try:
|
|
3211
|
+
root = _get_project_root()
|
|
3212
|
+
except Exception:
|
|
3213
|
+
root = os.getcwd()
|
|
3214
|
+
|
|
3215
|
+
if not version:
|
|
3216
|
+
version_file = os.path.join(root, "VERSION")
|
|
3217
|
+
if os.path.isfile(version_file):
|
|
3218
|
+
with open(version_file, "r", encoding="utf-8") as f:
|
|
3219
|
+
version = f.read().strip()
|
|
3220
|
+
else:
|
|
3221
|
+
version = "0.20.0"
|
|
3222
|
+
|
|
3223
|
+
print(f"🚀 Executing Marketing Release Ceremony for v{version.lstrip('v')} (dry_run={dry_run})...")
|
|
3224
|
+
res = execute_release_ceremony(
|
|
3225
|
+
root=root,
|
|
3226
|
+
version=version,
|
|
3227
|
+
dry_run=dry_run,
|
|
3228
|
+
skip_build=skip_build,
|
|
3229
|
+
)
|
|
3230
|
+
if not res.ok:
|
|
3231
|
+
print(f"❌ Marketing Release Ceremony failed: {', '.join(res.errors)}")
|
|
3232
|
+
sys.exit(1)
|
|
3233
|
+
|
|
3234
|
+
prefix = "[DRY-RUN] " if res.dry_run else ""
|
|
3235
|
+
print(f"✅ {prefix}Marketing Release Ceremony completed successfully for v{res.version}.")
|
|
3236
|
+
print(f" - README updated: {res.readme_updated}")
|
|
3237
|
+
print(f" - Docs bundles updated: {len(res.docs_updated)} files ({', '.join(res.docs_updated) if res.docs_updated else 'none'})")
|
|
3238
|
+
print(f" - Website metadata updated: {res.website_updated}")
|
|
3239
|
+
print(f" - Website build ok: {res.website_build_ok}")
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
def cmd_marketing_sync_pr(pr_number: Union[int, str], dry_run: bool = False) -> None:
|
|
3243
|
+
"""Executes the limited-treatment marketing sync for a merged pull request."""
|
|
3244
|
+
from synlynk.marketing import cmd_marketing_sync_pr as _impl
|
|
3245
|
+
return _impl(pr_number=pr_number, dry_run=dry_run)
|
|
3246
|
+
|
|
3247
|
+
|
|
3248
|
+
def cmd_status(json_output: bool = False, platform: bool = False) -> None:
|
|
3249
|
+
"""Displays project state dashboard. Exits 1 if sentinel active or budget exceeded."""
|
|
3250
|
+
if platform:
|
|
3251
|
+
has_alert = _print_platform_health()
|
|
3252
|
+
sys.exit(1 if has_alert else 0)
|
|
3253
|
+
|
|
3254
|
+
username = get_username()
|
|
3255
|
+
mode = get_mode()
|
|
3256
|
+
|
|
3257
|
+
# Active tasks
|
|
3258
|
+
active_tasks = []
|
|
3259
|
+
todo_path = "project-docs/todo.md"
|
|
3260
|
+
if os.path.exists(todo_path):
|
|
3261
|
+
with open(todo_path) as f:
|
|
3262
|
+
for line in f:
|
|
3263
|
+
if "- [ ]" in line:
|
|
3264
|
+
id_m = re.search(r'<!--\s*id:\s*(\d+)\s*-->', line)
|
|
3265
|
+
text = re.sub(r'-\s*\[ \]\s*', '', line).strip()
|
|
3266
|
+
text = re.sub(r'<!--.*?-->', '', text).strip()
|
|
3267
|
+
active_tasks.append({"id": id_m.group(1) if id_m else None, "text": text})
|
|
3268
|
+
|
|
3269
|
+
telemetry_events = _load_telemetry_events()
|
|
3270
|
+
|
|
3271
|
+
# Last checkpoint from telemetry
|
|
3272
|
+
last_checkpoint = None
|
|
3273
|
+
for e in reversed(telemetry_events):
|
|
3274
|
+
if e.get("type") == "checkpoint":
|
|
3275
|
+
last_checkpoint = e
|
|
3276
|
+
break
|
|
3277
|
+
|
|
3278
|
+
# Sentinel alerts
|
|
3279
|
+
sentinel_file = ".synlynk/sentinel.md"
|
|
3280
|
+
sentinel_alerts = _read_sentinel_alerts(sentinel_path=sentinel_file)
|
|
3281
|
+
|
|
3282
|
+
# Budget
|
|
3283
|
+
total_usd, total_requests = parse_costs_md()
|
|
3284
|
+
config = load_config()
|
|
3285
|
+
limit_usd = config["budget"]["limit_usd"]
|
|
3286
|
+
limit_reqs = config["budget"]["limit_requests"]
|
|
3287
|
+
|
|
3288
|
+
# Watcher
|
|
3289
|
+
daemon = WatchDaemon()
|
|
3290
|
+
watcher_running = daemon._is_running()
|
|
3291
|
+
last_trigger_file = None
|
|
3292
|
+
for e in reversed(telemetry_events):
|
|
3293
|
+
if e.get("type") == "watch_trigger":
|
|
3294
|
+
last_trigger_file = e.get("changed_file")
|
|
3295
|
+
break
|
|
3296
|
+
|
|
3297
|
+
# Teammates (team mode)
|
|
3298
|
+
teammates = []
|
|
3299
|
+
if mode == "team":
|
|
3300
|
+
devlogs_dir = "project-docs/devlogs"
|
|
3301
|
+
if os.path.exists(devlogs_dir):
|
|
3302
|
+
for fname in sorted(os.listdir(devlogs_dir)):
|
|
3303
|
+
if fname.endswith(".md") and fname not in (f"{username}.md", "README.md"):
|
|
3304
|
+
fpath = os.path.join(devlogs_dir, fname)
|
|
3305
|
+
teammates.append({
|
|
3306
|
+
"user": fname[:-3],
|
|
3307
|
+
"last_active": _get_last_devlog_date(fpath),
|
|
3308
|
+
})
|
|
3309
|
+
|
|
3310
|
+
has_alert = bool(sentinel_alerts) or total_usd >= limit_usd or total_requests >= limit_reqs
|
|
3311
|
+
|
|
3312
|
+
if json_output:
|
|
3313
|
+
data = {
|
|
3314
|
+
"schema_version": 1,
|
|
3315
|
+
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
3316
|
+
"user": username,
|
|
3317
|
+
"mode": mode,
|
|
3318
|
+
"active_tasks": active_tasks,
|
|
3319
|
+
"last_checkpoint": last_checkpoint,
|
|
3320
|
+
"sentinel": {"alerts": sentinel_alerts},
|
|
3321
|
+
"budget": {
|
|
3322
|
+
"used_usd": round(total_usd, 4),
|
|
3323
|
+
"limit_usd": limit_usd,
|
|
3324
|
+
"requests": total_requests,
|
|
3325
|
+
"limit_requests": limit_reqs,
|
|
3326
|
+
},
|
|
3327
|
+
"watcher": {"running": watcher_running, "last_trigger_file": last_trigger_file},
|
|
3328
|
+
"teammates": teammates,
|
|
3329
|
+
}
|
|
3330
|
+
print(json.dumps(data, indent=2))
|
|
3331
|
+
sys.exit(1 if has_alert else 0)
|
|
3332
|
+
|
|
3333
|
+
# Human output
|
|
3334
|
+
sep = "─" * 45
|
|
3335
|
+
print(sep)
|
|
3336
|
+
print(f" synlynk status · @{username} · {mode} mode")
|
|
3337
|
+
print(sep)
|
|
3338
|
+
print(f" ACTIVE TASKS ({len(active_tasks)})")
|
|
3339
|
+
for t in active_tasks:
|
|
3340
|
+
tid = f"#{t['id']}" if t['id'] else ""
|
|
3341
|
+
print(f" [ ] {t['text']:<40} {tid}")
|
|
3342
|
+
print()
|
|
3343
|
+
print(" LAST CHECKPOINT")
|
|
3344
|
+
if last_checkpoint:
|
|
3345
|
+
print(f" @{last_checkpoint.get('user')} · {last_checkpoint.get('timestamp')} · "
|
|
3346
|
+
f"{last_checkpoint.get('completed_task_count', 0)} tasks archived")
|
|
3347
|
+
else:
|
|
3348
|
+
print(" No checkpoints yet")
|
|
3349
|
+
print()
|
|
3350
|
+
print(" SENTINEL")
|
|
3351
|
+
if sentinel_alerts:
|
|
3352
|
+
for alert in sentinel_alerts:
|
|
3353
|
+
print(f" ⚠ {alert}")
|
|
3354
|
+
else:
|
|
3355
|
+
print(" ✓ No alerts")
|
|
3356
|
+
print()
|
|
3357
|
+
pct = (total_usd / limit_usd * 100) if limit_usd else 0
|
|
3358
|
+
print(" BUDGET")
|
|
3359
|
+
print(f" ${total_usd:.2f} / ${limit_usd:.2f} ({pct:.0f}%) · {total_requests} / {limit_reqs} requests")
|
|
3360
|
+
avg_cost, remaining_execs = _compute_burn_rate()
|
|
3361
|
+
if avg_cost > 0:
|
|
3362
|
+
runway = f"~{remaining_execs:,} execs remaining" if remaining_execs is not None else "N/A"
|
|
3363
|
+
print(f" Burn: ${avg_cost:.4f}/exec avg | {runway} at current pace")
|
|
3364
|
+
print()
|
|
3365
|
+
icon = "●" if watcher_running else "○"
|
|
3366
|
+
state = "Running" if watcher_running else "Stopped"
|
|
3367
|
+
trigger = f" · last trigger {last_trigger_file}" if last_trigger_file else ""
|
|
3368
|
+
print(f" WATCHER\n {icon} {state}{trigger}")
|
|
3369
|
+
sentinel_path = os.path.join(os.getcwd(), ".synlynk", "sentinel.md")
|
|
3370
|
+
check_daemon_health(sentinel_path=sentinel_path)
|
|
3371
|
+
check_stall(sentinel_path=sentinel_path)
|
|
3372
|
+
if mode == "team" and teammates:
|
|
3373
|
+
print()
|
|
3374
|
+
print(" TEAMMATES")
|
|
3375
|
+
for tm in teammates:
|
|
3376
|
+
print(f" @{tm['user']:<12} · last active {tm['last_active']}")
|
|
3377
|
+
print()
|
|
3378
|
+
print(f" {_CYAN}CAPABILITY LEDGER{_RESET}")
|
|
3379
|
+
try:
|
|
3380
|
+
_cl_conn = _get_db()
|
|
3381
|
+
_cl_rows = _cl_conn.execute(
|
|
3382
|
+
"SELECT agent, model_version, engg_domain, phase, weighted_score, sample_count "
|
|
3383
|
+
"FROM capability_scores ORDER BY weighted_score DESC LIMIT 3"
|
|
3384
|
+
).fetchall()
|
|
3385
|
+
_cl_conn.close()
|
|
3386
|
+
except Exception:
|
|
3387
|
+
_cl_rows = []
|
|
3388
|
+
if _cl_rows:
|
|
3389
|
+
print(f" {'Agent':<10} {'Model':<22} {'Domain':<8} {'Phase':<10} {'Score':>6} N")
|
|
3390
|
+
for _ag, _mv, _dom, _ph, _sc, _n in _cl_rows:
|
|
3391
|
+
_sc_str = f"{_sc:.2f}" if _sc is not None else " — "
|
|
3392
|
+
print(f" {_GREEN}{_ag:<10}{_RESET} {_mv:<22} {_dom:<8} {_ph:<10} {_sc_str:>6} {_n}")
|
|
3393
|
+
else:
|
|
3394
|
+
print(" No capability data yet.")
|
|
3395
|
+
print(sep)
|
|
3396
|
+
sys.exit(1 if has_alert else 0)
|
|
3397
|
+
|
|
3398
|
+
def init(force: bool = False, agents: list = None,
|
|
3399
|
+
org: str = None, repo: str = None, project_id: str = None,
|
|
3400
|
+
mode: str = "solo", dry_run: bool = False, quiet: bool = False,
|
|
3401
|
+
replace_generated_docs: bool = False,
|
|
3402
|
+
non_interactive: bool = False) -> None:
|
|
3403
|
+
"""Progressive wizard: semantic scan → harness discovery → doc bootstrap → nudge."""
|
|
3404
|
+
|
|
3405
|
+
# An explicit non-interactive flag always uses defaults. Otherwise keep
|
|
3406
|
+
# prompting even when stdin is not a TTY: callers and tests may provide a
|
|
3407
|
+
# working input function. Closed/exhausted stdin is handled below via
|
|
3408
|
+
# EOFError, which preserves the headless subprocess fallback.
|
|
3409
|
+
auto_defaults = bool(non_interactive)
|
|
3410
|
+
|
|
3411
|
+
def _print_step(n: int, label: str) -> None:
|
|
3412
|
+
print(f"\n{_BOLD}{_CYAN}Step {n}/{_TOTAL_STEPS} — {label}{_RESET}")
|
|
3413
|
+
|
|
3414
|
+
_TOTAL_STEPS = 6
|
|
3415
|
+
|
|
3416
|
+
# ── Step 1: Detect existing state ──────────────────────────────────────
|
|
3417
|
+
_print_step(1, "Scanning repository")
|
|
3418
|
+
synlynk_exists = os.path.exists(".synlynk")
|
|
3419
|
+
if synlynk_exists and not force:
|
|
3420
|
+
print(f" {_YELLOW}⚠ .synlynk/ already exists.{_RESET} "
|
|
3421
|
+
"Use --force to reinitialise.\n Updating agent files only.")
|
|
3422
|
+
|
|
3423
|
+
if dry_run:
|
|
3424
|
+
print(" DRY RUN — no files will be written\n")
|
|
3425
|
+
dd_preview = _docs_dir()
|
|
3426
|
+
for d in [dd_preview, os.path.join(dd_preview, "devlogs"), ".synlynk", LOGS_DIR, PROMPTS_DIR]:
|
|
3427
|
+
if not os.path.exists(d):
|
|
3428
|
+
print(f" would create: {d}/")
|
|
3429
|
+
print(" would always overwrite (marker_style='none', regardless of --force):")
|
|
3430
|
+
for fpath in (".cursor/rules/synlynk.mdc", ".github/copilot-instructions.md",
|
|
3431
|
+
".windsurfrules", "AI_INSTRUCTIONS.md"):
|
|
3432
|
+
if os.path.exists(fpath):
|
|
3433
|
+
print(f" ⚠ {fpath} (already exists — would be overwritten unconditionally)")
|
|
3434
|
+
else:
|
|
3435
|
+
print(f" {fpath} (would be created)")
|
|
3436
|
+
return
|
|
3437
|
+
|
|
3438
|
+
scan = _static_scan(".")
|
|
3439
|
+
print(f" Project : {_BOLD}{scan['project_name']}{_RESET}")
|
|
3440
|
+
print(f" Commits : {scan['commit_count']}")
|
|
3441
|
+
print(f" Languages: {', '.join(scan['languages']) or 'unknown'}")
|
|
3442
|
+
if scan["recent_topics"]:
|
|
3443
|
+
print(f" Recent : {scan['recent_topics'][0]}")
|
|
3444
|
+
if not scan["has_structured_commits"] and scan["commit_count"] > 0:
|
|
3445
|
+
print(f" {_DIM}⚠ Commit messages don't follow a structured convention — "
|
|
3446
|
+
"skeleton quality may be lower. Review generated docs before proceeding.{_RESET}")
|
|
3447
|
+
|
|
3448
|
+
# ── Step 2: Agent discovery ─────────────────────────────────────────────
|
|
3449
|
+
_print_step(2, "Discovering agents")
|
|
3450
|
+
discovered = discover_agents()
|
|
3451
|
+
functional = [a for a in discovered if a["functional"]]
|
|
3452
|
+
non_functional = [a for a in discovered if not a["functional"]]
|
|
3453
|
+
|
|
3454
|
+
if functional:
|
|
3455
|
+
print(f"\n {_BOLD}{_GREEN}✨ Synlynk is ready to route coding tasks:{_RESET}")
|
|
3456
|
+
for ag in functional:
|
|
3457
|
+
roles = ", ".join(ag["roles"])
|
|
3458
|
+
print(f" {_GREEN}✓ {ag['name']:10}{_RESET} {ag['version']} "
|
|
3459
|
+
f"roles: {roles}")
|
|
3460
|
+
else:
|
|
3461
|
+
print(f" {_YELLOW}No agents detected. Install Claude, Gemini, or Codex to route coding tasks.{_RESET}")
|
|
3462
|
+
|
|
3463
|
+
if non_functional:
|
|
3464
|
+
print(f"\n {_DIM}Found but not configured (run --version failed):{_RESET}")
|
|
3465
|
+
for ag in non_functional:
|
|
3466
|
+
print(f" {_DIM}✗ {ag['name']} — check API key / install{_RESET}")
|
|
3467
|
+
|
|
3468
|
+
from synlynk.rollback import rollback_checkpoint
|
|
3469
|
+
|
|
3470
|
+
config_path = os.path.join(".synlynk", "config.json")
|
|
3471
|
+
rates_path = os.path.join(".synlynk", "model_rates.json")
|
|
3472
|
+
with rollback_checkpoint("init", untracked_paths=[
|
|
3473
|
+
os.path.join(".git", "hooks", "pre-commit"),
|
|
3474
|
+
config_path,
|
|
3475
|
+
os.path.join(".synlynk", "instructions.json"),
|
|
3476
|
+
rates_path,
|
|
3477
|
+
"project-docs",
|
|
3478
|
+
"roadmap.md",
|
|
3479
|
+
"memory.md",
|
|
3480
|
+
"todo.md",
|
|
3481
|
+
"CLAUDE.md",
|
|
3482
|
+
"GEMINI.md",
|
|
3483
|
+
"AGENTS.md",
|
|
3484
|
+
"GROK.md",
|
|
3485
|
+
".cursor/rules/synlynk.mdc",
|
|
3486
|
+
".github/copilot-instructions.md",
|
|
3487
|
+
".windsurfrules",
|
|
3488
|
+
"AI_INSTRUCTIONS.md",
|
|
3489
|
+
]):
|
|
3490
|
+
# ── Step 3: Create directories + write skeleton ─────────────────────
|
|
3491
|
+
dd = _docs_dir()
|
|
3492
|
+
_print_step(3, f"Bootstrapping {dd}/")
|
|
3493
|
+
for d in [dd, os.path.join(dd, "devlogs"), ".synlynk",
|
|
3494
|
+
LOGS_DIR, PROMPTS_DIR]:
|
|
3495
|
+
if not os.path.exists(d):
|
|
3496
|
+
os.makedirs(d)
|
|
3497
|
+
|
|
3498
|
+
written = _write_informed_skeleton(
|
|
3499
|
+
scan, skip_existing=not force, replace_generated_docs=replace_generated_docs
|
|
3500
|
+
)
|
|
3501
|
+
if written:
|
|
3502
|
+
for p, label in written:
|
|
3503
|
+
print(f" {_GREEN}✓{_RESET} {p} {_DIM}({label}){_RESET}")
|
|
3504
|
+
else:
|
|
3505
|
+
print(f" {_DIM}All docs already exist — skipped (use --force to overwrite){_RESET}")
|
|
3506
|
+
|
|
3507
|
+
# Write agent instruction files using _write_instruction_file().
|
|
3508
|
+
agent_set = set(agents) if agents is not None else {a["name"] for a in functional} or {"claude", "agy", "codex", "grok"}
|
|
3509
|
+
templates = _build_templates(org=org, repo=repo, project_id=project_id)
|
|
3510
|
+
|
|
3511
|
+
# Core trio: only write if agent was discovered as functional.
|
|
3512
|
+
trio_content = {
|
|
3513
|
+
"CLAUDE.md": (templates.get("CLAUDE.md", ""), "html"),
|
|
3514
|
+
"GEMINI.md": (templates.get("GEMINI.md", ""), "html"),
|
|
3515
|
+
"AGENTS.md": (templates.get("AGENTS.md", ""), "html"),
|
|
3516
|
+
"GROK.md": (templates.get("GROK.md", ""), "html"),
|
|
3517
|
+
}
|
|
3518
|
+
_agent_guards = {"CLAUDE.md": "claude", "GEMINI.md": "agy", "AGENTS.md": "codex", "GROK.md": "grok"}
|
|
3519
|
+
for fname, (content, mstyle) in trio_content.items():
|
|
3520
|
+
required = _agent_guards[fname]
|
|
3521
|
+
if required not in agent_set:
|
|
3522
|
+
continue
|
|
3523
|
+
_write_instruction_file(fname, required, content, mstyle)
|
|
3524
|
+
|
|
3525
|
+
# Extended targets: written based on environment detection.
|
|
3526
|
+
# Guards are sourced from _INSTRUCTION_TARGETS[i][3] (detection_fn).
|
|
3527
|
+
_target_detection = {fpath: fn for fpath, _, _, fn in _INSTRUCTION_TARGETS}
|
|
3528
|
+
extended = [
|
|
3529
|
+
(".cursor/rules/synlynk.mdc", "cursor", "none", _build_cursor_mdc()),
|
|
3530
|
+
(".github/copilot-instructions.md", "copilot", "html", _build_copilot_instructions()),
|
|
3531
|
+
(".windsurfrules", "windsurf", "hash", _build_windsurf_rules()),
|
|
3532
|
+
("AI_INSTRUCTIONS.md", "universal", "html", templates.get("AI_INSTRUCTIONS.md", "")),
|
|
3533
|
+
]
|
|
3534
|
+
for fpath, tool, mstyle, content in extended:
|
|
3535
|
+
if _target_detection[fpath]():
|
|
3536
|
+
# marker_style='none' means synlynk owns the whole file — always overwrites
|
|
3537
|
+
_write_instruction_file(fpath, tool, content, mstyle)
|
|
3538
|
+
|
|
3539
|
+
# Write manifest of all tracked files with their SHAs.
|
|
3540
|
+
manifest_entries = {}
|
|
3541
|
+
for fpath, tool, mstyle, _ in _INSTRUCTION_TARGETS:
|
|
3542
|
+
if not os.path.exists(fpath):
|
|
3543
|
+
continue
|
|
3544
|
+
file_content = open(fpath).read()
|
|
3545
|
+
section = _extract_synlynk_section(file_content, mstyle)
|
|
3546
|
+
if section is not None:
|
|
3547
|
+
manifest_entries[fpath] = {"tool": tool, "sha": _compute_section_sha(section)}
|
|
3548
|
+
if manifest_entries:
|
|
3549
|
+
_write_instruction_manifest(manifest_entries)
|
|
3550
|
+
|
|
3551
|
+
install_pre_commit_hook(repo_root=Path.cwd())
|
|
3552
|
+
|
|
3553
|
+
# Write config.json if needed.
|
|
3554
|
+
config_json_content = templates.get("config.json", "")
|
|
3555
|
+
if config_json_content:
|
|
3556
|
+
if not os.path.exists(config_path) or force:
|
|
3557
|
+
try:
|
|
3558
|
+
config_payload = json.loads(config_json_content)
|
|
3559
|
+
except (TypeError, json.JSONDecodeError):
|
|
3560
|
+
config_payload = None
|
|
3561
|
+
if isinstance(config_payload, dict):
|
|
3562
|
+
config_payload.setdefault("local_auto_threshold", 0.5)
|
|
3563
|
+
config_payload.setdefault("local_fallback", "agy")
|
|
3564
|
+
config_json_content = json.dumps(config_payload, indent=2) + "\n"
|
|
3565
|
+
with open(config_path, "w") as f:
|
|
3566
|
+
f.write(config_json_content)
|
|
3567
|
+
|
|
3568
|
+
if not os.path.exists(rates_path):
|
|
3569
|
+
from synlynk.costs import _HARDCODED_FALLBACK_RATES
|
|
3570
|
+
rates_seed = dict(_HARDCODED_FALLBACK_RATES)
|
|
3571
|
+
rates_seed["rates_updated_at"] = time.strftime("%Y-%m-%d")
|
|
3572
|
+
with open(rates_path, "w") as f:
|
|
3573
|
+
json.dump(rates_seed, f, indent=2)
|
|
3574
|
+
print(f" ✓ Created {rates_path}")
|
|
3575
|
+
|
|
3576
|
+
# ── Step 4: LLM enrichment offer ────────────────────────────────────────
|
|
3577
|
+
if not quiet:
|
|
3578
|
+
_print_step(4, "LLM enrichment (optional)")
|
|
3579
|
+
if functional:
|
|
3580
|
+
enricher = functional[0]
|
|
3581
|
+
print(f" I found {scan['commit_count']} commits and {len(scan['recent_topics'])} "
|
|
3582
|
+
f"recent topics.\n Want me to ask {enricher['name']} to synthesise a roadmap "
|
|
3583
|
+
f"from this? (costs tokens)")
|
|
3584
|
+
answer = ""
|
|
3585
|
+
if not auto_defaults:
|
|
3586
|
+
try:
|
|
3587
|
+
answer = input(" [y/N] ").strip().lower()
|
|
3588
|
+
except EOFError:
|
|
3589
|
+
auto_defaults = True
|
|
3590
|
+
if answer == "y":
|
|
3591
|
+
print(f" {_DIM}Calling {enricher['cli']} --print...{_RESET}", end=" ", flush=True)
|
|
3592
|
+
ok = _llm_enrich(enricher["name"], enricher["cli"], scan)
|
|
3593
|
+
print(f"{_GREEN}done{_RESET}" if ok else f"{_YELLOW}failed — keeping skeleton{_RESET}")
|
|
3594
|
+
else:
|
|
3595
|
+
print(f" {_DIM}No functional agent available — skipping enrichment{_RESET}")
|
|
3596
|
+
|
|
3597
|
+
# ── Step 5: Cloud directory nudge ────────────────────────────────────────
|
|
3598
|
+
inferred = _infer_industry()
|
|
3599
|
+
if quiet:
|
|
3600
|
+
email = ""
|
|
3601
|
+
industry = inferred
|
|
3602
|
+
else:
|
|
3603
|
+
_print_step(5, "Team & cloud setup (optional)")
|
|
3604
|
+
print(" Add a collaborator or share this workspace with your team.")
|
|
3605
|
+
print(" Leave blank to skip.")
|
|
3606
|
+
if auto_defaults:
|
|
3607
|
+
email = ""
|
|
3608
|
+
industry = inferred
|
|
3609
|
+
else:
|
|
3610
|
+
try:
|
|
3611
|
+
email = input(" Email or synlynk ID: ").strip()
|
|
3612
|
+
industry = input(f" Industry vertical [{inferred}]: ").strip() or inferred
|
|
3613
|
+
except EOFError:
|
|
3614
|
+
auto_defaults = True
|
|
3615
|
+
email = ""
|
|
3616
|
+
industry = inferred
|
|
3617
|
+
if industry not in list(_INDUSTRY_KEYWORDS.keys()) + ["unknown"]:
|
|
3618
|
+
industry = "unknown"
|
|
3619
|
+
|
|
3620
|
+
# ── Step 6: Finalise config ──────────────────────────────────────────────
|
|
3621
|
+
_print_step(6, "Finalising")
|
|
3622
|
+
synlynk_config_path = os.path.join("project-docs", ".synlynk_config.json")
|
|
3623
|
+
if not os.path.exists(synlynk_config_path) or force:
|
|
3624
|
+
config_data = {"mode": mode, "version": VERSION,
|
|
3625
|
+
"init_timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
|
3626
|
+
with open(synlynk_config_path, "w") as f:
|
|
3627
|
+
json.dump(config_data, f, indent=2)
|
|
3628
|
+
|
|
3629
|
+
_update_config({
|
|
3630
|
+
"workgroup_agents": [a["name"] for a in functional],
|
|
3631
|
+
"workgroup_invite_email": email or None,
|
|
3632
|
+
"industry": industry,
|
|
3633
|
+
})
|
|
3634
|
+
|
|
3635
|
+
set_state("stopped")
|
|
3636
|
+
from synlynk.capability_sweep import _seed_capability_ledger_from_baseline
|
|
3637
|
+
|
|
3638
|
+
_seed_capability_ledger_from_baseline(_get_db())
|
|
3639
|
+
|
|
3640
|
+
# LIVE-21: make probe metadata available before the first dispatch. Do
|
|
3641
|
+
# not probe fallback names when no harness is installed: some harness
|
|
3642
|
+
# probes perform network checks and init must retain its fast headless
|
|
3643
|
+
# path. Explicit ``agents=`` remains authoritative.
|
|
3644
|
+
if agents is not None:
|
|
3645
|
+
probe_targets = sorted(set(agents))
|
|
3646
|
+
else:
|
|
3647
|
+
# Keep the legacy no-argument init path bounded. Explicitly selected
|
|
3648
|
+
# harnesses are probed here; otherwise first dispatch performs the
|
|
3649
|
+
# defensive inline probe for the discovered harness.
|
|
3650
|
+
probe_targets = []
|
|
3651
|
+
probe_all_configured_harnesses(probe_targets)
|
|
3652
|
+
|
|
3653
|
+
if auto_defaults:
|
|
3654
|
+
print(f" Auto-selected defaults: enrichment=no, email=empty, industry={industry}")
|
|
3655
|
+
|
|
3656
|
+
print(f"\n{_BOLD}{_GREEN}✓ synlynk initialised — Synlynk is a neutral control plane that routes coding tasks across AI vendors and local models, then proves the result.{_RESET}")
|
|
3657
|
+
if functional:
|
|
3658
|
+
agent_names = " + ".join(a["name"] for a in functional)
|
|
3659
|
+
print(f"\n {_BOLD}✨ Magic Moment 2 — dispatch agents now:{_RESET}")
|
|
3660
|
+
print(f" {_CYAN}synlynk dispatch {functional[0]['name']} --task \"your task\"{_RESET}")
|
|
3661
|
+
if len(functional) >= 3:
|
|
3662
|
+
print(f" {_CYAN}synlynk run --trio --task \"your task\"{_RESET} "
|
|
3663
|
+
f"← runs {agent_names} in parallel")
|
|
3664
|
+
print(f"\n Background Supervision:")
|
|
3665
|
+
print(f" {_CYAN}synlynk daemon --install-service{_RESET} ← supervise daemon across reboots (launchd/systemd)")
|
|
3666
|
+
print(f"\n Next: {_DIM}synlynk status · synlynk jobs · synlynk dispatch --help{_RESET}\n")
|
|
3667
|
+
|
|
3668
|
+
# --- module extractions (backwards compat) ---
|
|
3669
|
+
# Normal library imports retain the historical package-level exports. CLI
|
|
3670
|
+
# startup skips this fan-out until after argparse has parsed the command.
|
|
3671
|
+
if not _FAST_CLI and not _vizor_daemon_process():
|
|
3672
|
+
_load_legacy_imports()
|