@smilintux/skcapstone 0.10.0 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/.env.example +10 -4
  2. package/.github/workflows/ci.yml +2 -2
  3. package/.github/workflows/publish.yml +9 -2
  4. package/.openclaw-workspace.json +2 -2
  5. package/CLAUDE.md +37 -0
  6. package/MISSION.md +17 -2
  7. package/README.md +282 -3
  8. package/docker/Dockerfile +7 -7
  9. package/docker/compose-templates/dev-team.yml +12 -12
  10. package/docker/compose-templates/mini-team.yml +9 -9
  11. package/docker/compose-templates/ops-team.yml +10 -10
  12. package/docker/compose-templates/research-team.yml +10 -10
  13. package/docker/entrypoint.sh +4 -4
  14. package/docs/ADR-optional-integration-backbone.md +181 -0
  15. package/docs/ARCHITECTURE.md +186 -43
  16. package/docs/BOND_WITH_GROK.md +6 -6
  17. package/docs/CUSTOM_AGENT.md +123 -30
  18. package/docs/DREAMING.md +70 -0
  19. package/docs/GETTING_STARTED.md +7 -7
  20. package/docs/QUICKSTART.md +10 -6
  21. package/docs/SKJOULE_ARCHITECTURE.md +3 -3
  22. package/docs/SOUL_SWAPPER.md +5 -5
  23. package/docs/hammertime-audit.md +402 -0
  24. package/docs/sk-integration-HANDOFF.md +117 -0
  25. package/docs/skscheduler.md +155 -0
  26. package/docs/superpowers/examples/jobs.yaml +31 -0
  27. package/docs/superpowers/plans/2026-06-08-skscheduler.md +1265 -0
  28. package/docs/superpowers/specs/2026-06-08-skscheduler-design.md +186 -0
  29. package/examples/custom-bond-template.json +1 -1
  30. package/examples/grok-feb.json +1 -1
  31. package/examples/queen-ava-feb.json +1 -1
  32. package/launchd/{com.skcapstone.skcomm-heartbeat.plist → com.skcapstone.skcomms-heartbeat.plist} +4 -4
  33. package/launchd/{com.skcapstone.skcomm-queue-drain.plist → com.skcapstone.skcomms-queue-drain.plist} +4 -4
  34. package/launchd/install-launchd.sh +6 -6
  35. package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/index.ts +3 -2
  36. package/package.json +1 -1
  37. package/pyproject.toml +16 -10
  38. package/scripts/archive-sessions.sh +7 -0
  39. package/scripts/check-updates.py +4 -4
  40. package/scripts/install-bundle.sh +8 -8
  41. package/scripts/install.ps1 +12 -11
  42. package/scripts/install.sh +159 -5
  43. package/scripts/model-fallback-monitor.sh +102 -0
  44. package/scripts/nvidia-proxy.mjs +78 -26
  45. package/scripts/refresh-anthropic-token.sh +172 -0
  46. package/scripts/release.sh +98 -0
  47. package/scripts/session-to-memory.py +219 -0
  48. package/scripts/skgateway.mjs +3 -3
  49. package/scripts/telegram-catchup-all.sh +12 -1
  50. package/scripts/verify_install.sh +2 -2
  51. package/scripts/wargov-ufo-capture/README.md +43 -0
  52. package/scripts/wargov-ufo-capture/cdp_capture_release2.py +273 -0
  53. package/scripts/wargov-ufo-capture/cdp_capture_splc_doj.py +246 -0
  54. package/scripts/wargov-ufo-capture/cdp_finish.py +271 -0
  55. package/scripts/wargov-ufo-capture/cdp_probe.py +188 -0
  56. package/scripts/wargov-ufo-capture/cdp_splc_pressrelease.py +101 -0
  57. package/scripts/wargov-ufo-capture/parse_csv.py +95 -0
  58. package/scripts/wargov-ufo-capture/pull_dvids.sh +107 -0
  59. package/scripts/watch-anthropic-token.sh +212 -0
  60. package/scripts/windows/install-tasks.ps1 +7 -7
  61. package/scripts/windows/skcapstone-task.xml +1 -1
  62. package/src/skcapstone/__init__.py +45 -3
  63. package/src/skcapstone/_cli_monolith.py +20 -15
  64. package/src/skcapstone/activity.py +5 -1
  65. package/src/skcapstone/agent_card.py +3 -2
  66. package/src/skcapstone/api.py +41 -40
  67. package/src/skcapstone/auction.py +14 -11
  68. package/src/skcapstone/backup.py +2 -1
  69. package/src/skcapstone/blueprint_registry.py +4 -3
  70. package/src/skcapstone/brain_first.py +238 -0
  71. package/src/skcapstone/changelog.py +1 -1
  72. package/src/skcapstone/chat.py +22 -17
  73. package/src/skcapstone/cli/__init__.py +9 -1
  74. package/src/skcapstone/cli/_common.py +1 -0
  75. package/src/skcapstone/cli/agents_spawner.py +5 -2
  76. package/src/skcapstone/cli/alerts.py +25 -4
  77. package/src/skcapstone/cli/bench.py +15 -15
  78. package/src/skcapstone/cli/chat.py +7 -4
  79. package/src/skcapstone/cli/consciousness.py +5 -2
  80. package/src/skcapstone/cli/context_cmd.py +18 -4
  81. package/src/skcapstone/cli/daemon.py +11 -7
  82. package/src/skcapstone/cli/gtd.py +26 -1
  83. package/src/skcapstone/cli/housekeeping.py +3 -3
  84. package/src/skcapstone/cli/identity_cmd.py +378 -0
  85. package/src/skcapstone/cli/joule_cmd.py +7 -3
  86. package/src/skcapstone/cli/memory.py +8 -6
  87. package/src/skcapstone/cli/peers_dir.py +1 -1
  88. package/src/skcapstone/cli/register_cmd.py +29 -3
  89. package/src/skcapstone/cli/scheduler_cmd.py +167 -0
  90. package/src/skcapstone/cli/session.py +25 -0
  91. package/src/skcapstone/cli/setup.py +96 -29
  92. package/src/skcapstone/cli/shell_cmd.py +53 -1
  93. package/src/skcapstone/cli/skills_cmd.py +2 -2
  94. package/src/skcapstone/cli/soul.py +8 -5
  95. package/src/skcapstone/cli/status.py +37 -11
  96. package/src/skcapstone/cli/telegram.py +21 -0
  97. package/src/skcapstone/cli/test_cmd.py +5 -5
  98. package/src/skcapstone/cli/test_connection.py +2 -2
  99. package/src/skcapstone/cli/upgrade_cmd.py +23 -14
  100. package/src/skcapstone/cli/version_cmd.py +1 -1
  101. package/src/skcapstone/cli/watch_cmd.py +9 -6
  102. package/src/skcapstone/cloud9_bridge.py +14 -14
  103. package/src/skcapstone/codex_setup.py +255 -0
  104. package/src/skcapstone/config_validator.py +7 -4
  105. package/src/skcapstone/consciousness_config.py +5 -1
  106. package/src/skcapstone/consciousness_loop.py +313 -273
  107. package/src/skcapstone/context_loader.py +121 -0
  108. package/src/skcapstone/coord_federation.py +2 -1
  109. package/src/skcapstone/coordination.py +23 -6
  110. package/src/skcapstone/crush_integration.py +2 -1
  111. package/src/skcapstone/daemon.py +132 -77
  112. package/src/skcapstone/dashboard.py +10 -10
  113. package/src/skcapstone/data/sk-agent-picker.sh +421 -0
  114. package/src/skcapstone/data/systemd/skcapstone-api.socket +9 -0
  115. package/src/skcapstone/data/systemd/skcapstone-memory-compress.service +18 -0
  116. package/src/skcapstone/data/systemd/skcapstone-memory-compress.timer +11 -0
  117. package/src/skcapstone/data/systemd/skcapstone.service +37 -0
  118. package/src/skcapstone/data/systemd/skcapstone@.service +50 -0
  119. package/src/skcapstone/data/systemd/skcomms-heartbeat.service +18 -0
  120. package/{systemd/skcomm-heartbeat.timer → src/skcapstone/data/systemd/skcomms-heartbeat.timer} +2 -2
  121. package/src/skcapstone/data/systemd/skcomms-queue-drain.service +17 -0
  122. package/{systemd/skcomm-queue-drain.timer → src/skcapstone/data/systemd/skcomms-queue-drain.timer} +2 -2
  123. package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
  124. package/src/skcapstone/defaults/claude/settings.json +74 -0
  125. package/src/skcapstone/defaults/lumina/config/claude-hooks.md +57 -0
  126. package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
  127. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
  128. package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
  129. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +2 -2
  130. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +2 -2
  131. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +9 -9
  132. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +2 -2
  133. package/src/skcapstone/defaults/unhinged.json +13 -0
  134. package/src/skcapstone/discovery.py +43 -20
  135. package/src/skcapstone/doctor.py +941 -22
  136. package/src/skcapstone/dreaming.py +1183 -109
  137. package/src/skcapstone/emotion_tracker.py +2 -2
  138. package/src/skcapstone/export.py +4 -3
  139. package/src/skcapstone/fuse_mount.py +14 -12
  140. package/src/skcapstone/gui_installer.py +2 -2
  141. package/src/skcapstone/heartbeat.py +1 -1
  142. package/src/skcapstone/housekeeping.py +14 -14
  143. package/src/skcapstone/install_wizard.py +209 -7
  144. package/src/skcapstone/itil.py +13 -4
  145. package/src/skcapstone/kms_scheduler.py +10 -8
  146. package/src/skcapstone/launchd.py +19 -19
  147. package/src/skcapstone/mcp_launcher.py +15 -1
  148. package/src/skcapstone/mcp_server.py +83 -49
  149. package/src/skcapstone/mcp_tools/__init__.py +2 -0
  150. package/src/skcapstone/mcp_tools/_helpers.py +2 -2
  151. package/src/skcapstone/mcp_tools/ansible_tools.py +7 -4
  152. package/src/skcapstone/mcp_tools/brain_first_tools.py +90 -0
  153. package/src/skcapstone/mcp_tools/capauth_tools.py +7 -4
  154. package/src/skcapstone/mcp_tools/comm_tools.py +10 -10
  155. package/src/skcapstone/mcp_tools/coord_tools.py +8 -4
  156. package/src/skcapstone/mcp_tools/did_tools.py +11 -8
  157. package/src/skcapstone/mcp_tools/gtd_tools.py +4 -4
  158. package/src/skcapstone/mcp_tools/memory_tools.py +6 -2
  159. package/src/skcapstone/mcp_tools/notification_tools.py +22 -6
  160. package/src/skcapstone/mcp_tools/{skcomm_tools.py → skcomms_tools.py} +14 -14
  161. package/src/skcapstone/mcp_tools/soul_tools.py +8 -2
  162. package/src/skcapstone/mdns_discovery.py +2 -2
  163. package/src/skcapstone/memory_curator.py +1 -1
  164. package/src/skcapstone/memory_engine.py +10 -3
  165. package/src/skcapstone/metrics.py +30 -16
  166. package/src/skcapstone/migrate_memories.py +4 -3
  167. package/src/skcapstone/migrate_multi_agent.py +8 -7
  168. package/src/skcapstone/models.py +47 -5
  169. package/src/skcapstone/notifications.py +42 -18
  170. package/src/skcapstone/onboard.py +875 -121
  171. package/src/skcapstone/operator_link.py +170 -0
  172. package/src/skcapstone/peer_directory.py +4 -4
  173. package/src/skcapstone/peers.py +19 -19
  174. package/src/skcapstone/pillars/__init__.py +7 -5
  175. package/src/skcapstone/pillars/consciousness.py +191 -0
  176. package/src/skcapstone/pillars/identity.py +51 -7
  177. package/src/skcapstone/pillars/memory.py +9 -3
  178. package/src/skcapstone/pillars/sync.py +2 -2
  179. package/src/skcapstone/preflight.py +3 -3
  180. package/src/skcapstone/providers/docker.py +28 -28
  181. package/src/skcapstone/register.py +6 -6
  182. package/src/skcapstone/registry_client.py +5 -4
  183. package/src/skcapstone/runtime.py +14 -3
  184. package/src/skcapstone/scheduled_tasks.py +254 -19
  185. package/src/skcapstone/scheduler_jobs.py +456 -0
  186. package/src/skcapstone/scheduler_runner.py +239 -0
  187. package/src/skcapstone/scheduler_state.py +162 -0
  188. package/src/skcapstone/sdk.py +310 -0
  189. package/src/skcapstone/service_health.py +279 -39
  190. package/src/skcapstone/session_briefing.py +108 -0
  191. package/src/skcapstone/session_capture.py +1 -1
  192. package/src/skcapstone/shell.py +7 -1
  193. package/src/skcapstone/soul.py +3 -1
  194. package/src/skcapstone/soul_switch.py +3 -1
  195. package/src/skcapstone/summary.py +6 -6
  196. package/src/skcapstone/sync_engine.py +15 -15
  197. package/src/skcapstone/sync_watcher.py +2 -2
  198. package/src/skcapstone/systemd.py +55 -21
  199. package/src/skcapstone/team_comms.py +8 -8
  200. package/src/skcapstone/team_engine.py +1 -1
  201. package/src/skcapstone/testrunner.py +3 -3
  202. package/src/skcapstone/trust_graph.py +40 -5
  203. package/src/skcapstone/unified_search.py +15 -6
  204. package/src/skcapstone/uninstall_wizard.py +11 -3
  205. package/src/skcapstone/version_check.py +8 -4
  206. package/src/skcapstone/warmth_anchor.py +4 -2
  207. package/src/skcapstone/whoami.py +4 -4
  208. package/systemd/skcapstone.service +4 -6
  209. package/systemd/skcapstone@.service +7 -8
  210. package/systemd/skcomms-heartbeat.service +21 -0
  211. package/systemd/skcomms-heartbeat.timer +12 -0
  212. package/systemd/skcomms-queue-drain.service +17 -0
  213. package/systemd/skcomms-queue-drain.timer +12 -0
  214. package/tests/conftest.py +39 -0
  215. package/tests/integration/test_consciousness_e2e.py +39 -39
  216. package/tests/test_agent_card.py +1 -1
  217. package/tests/test_agent_home_scaffold.py +34 -0
  218. package/tests/test_alerts_consumer_topics.py +27 -0
  219. package/tests/test_backup.py +2 -1
  220. package/tests/test_chat.py +6 -6
  221. package/tests/test_claude_md.py +2 -2
  222. package/tests/test_cli_skills.py +10 -10
  223. package/tests/test_cli_test_cmd.py +4 -4
  224. package/tests/test_cli_test_connection.py +1 -1
  225. package/tests/test_cloud9_bridge.py +6 -6
  226. package/tests/test_consciousness_e2e.py +1 -1
  227. package/tests/test_consciousness_loop.py +10 -10
  228. package/tests/test_coordination.py +25 -0
  229. package/tests/test_cross_package.py +21 -21
  230. package/tests/test_daemon.py +4 -4
  231. package/tests/test_daemon_shutdown.py +1 -1
  232. package/tests/test_docker_provider.py +29 -29
  233. package/tests/test_doctor.py +400 -0
  234. package/tests/test_doctor_skscheduler.py +50 -0
  235. package/tests/test_dreaming_engine.py +147 -0
  236. package/tests/test_dreaming_gtd_capture.py +35 -0
  237. package/tests/test_e2e_automated.py +8 -5
  238. package/tests/test_fuse_mount.py +10 -10
  239. package/tests/test_gtd_brief.py +46 -0
  240. package/tests/test_gtd_malformed_tolerance.py +31 -0
  241. package/tests/test_housekeeping.py +15 -15
  242. package/tests/test_identity_migrate.py +251 -0
  243. package/tests/test_integration_backbone.py +598 -0
  244. package/tests/test_itil_gtd_lifecycle.py +37 -0
  245. package/tests/test_jobs_dropins.py +84 -0
  246. package/tests/test_mcp_server.py +82 -37
  247. package/tests/test_models.py +48 -4
  248. package/tests/test_multi_agent.py +31 -29
  249. package/tests/test_notifications.py +122 -32
  250. package/tests/test_onboard.py +63 -75
  251. package/tests/test_operator_link.py +78 -0
  252. package/tests/test_peers.py +14 -14
  253. package/tests/test_pillars.py +98 -0
  254. package/tests/test_preflight.py +3 -3
  255. package/tests/test_runtime.py +21 -0
  256. package/tests/test_scheduled_tasks.py +11 -6
  257. package/tests/test_scheduler_cli.py +47 -0
  258. package/tests/test_scheduler_features.py +133 -0
  259. package/tests/test_scheduler_integration.py +87 -0
  260. package/tests/test_scheduler_jobs.py +155 -0
  261. package/tests/test_scheduler_runner.py +64 -0
  262. package/tests/test_scheduler_state.py +57 -0
  263. package/tests/test_sdk.py +70 -0
  264. package/tests/test_service_health_incidents.py +34 -0
  265. package/tests/test_service_registry.py +52 -0
  266. package/tests/test_session_briefing.py +130 -0
  267. package/tests/test_snapshots.py +4 -4
  268. package/tests/test_sync_pipeline.py +26 -26
  269. package/tests/test_team_comms.py +2 -2
  270. package/tests/test_testrunner.py +2 -2
  271. package/tests/test_trust_graph.py +18 -0
  272. package/tests/test_unified_search.py +2 -2
  273. package/tests/test_version_check.py +10 -0
  274. package/tests/test_version_cmd.py +8 -8
  275. package/tests/test_whoami.py +1 -1
  276. package/systemd/skcomm-heartbeat.service +0 -18
  277. package/systemd/skcomm-queue-drain.service +0 -17
  278. /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/package.json +0 -0
  279. /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/openclaw.plugin.json +0 -0
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import importlib
15
15
  import json
16
+ import logging
16
17
  import os
17
18
  import shutil
18
19
  import subprocess
@@ -20,6 +21,8 @@ from dataclasses import dataclass, field
20
21
  from pathlib import Path
21
22
  from typing import Optional
22
23
 
24
+ logger = logging.getLogger(__name__)
25
+
23
26
 
24
27
  @dataclass
25
28
  class Check:
@@ -115,14 +118,140 @@ def run_diagnostics(home: Path) -> DiagnosticReport:
115
118
  report.checks.extend(_check_system_tools())
116
119
  report.checks.extend(_check_agent_home(home))
117
120
  report.checks.extend(_check_identity(home))
121
+ report.checks.extend(_check_identity_consistency(home))
118
122
  report.checks.extend(_check_memory(home))
119
123
  report.checks.extend(_check_transport())
120
124
  report.checks.extend(_check_sync(home))
125
+ report.checks.extend(_check_sync_conflicts(home))
126
+ report.checks.extend(_check_scheduler(home))
127
+ report.checks.extend(_check_codex())
128
+ report.checks.extend(_check_harness_env(home))
121
129
  report.checks.extend(_check_versions())
122
130
 
123
131
  return report
124
132
 
125
133
 
134
+ def _check_sync_conflicts(home: Path) -> list[Check]:
135
+ """Detect Syncthing sync-conflict files under the shared root.
136
+
137
+ Recurring ``.sync-conflict-*`` files signal concurrent multi-node writes to
138
+ the same synced file (root cause tracked in prb-7810b08e). Reports a count
139
+ and the affected top-level areas. Cleanup is intentionally left to a human:
140
+ the authoritative copy must be chosen per file, so this check warns rather
141
+ than auto-deleting.
142
+
143
+ Args:
144
+ home: Shared root directory (~/.skcapstone).
145
+
146
+ Returns:
147
+ A single Check, passed only when no conflict files exist.
148
+ """
149
+ conflicts: list[Path] = []
150
+ if home.exists():
151
+ conflicts = [
152
+ p
153
+ for p in home.rglob("*.sync-conflict-*")
154
+ if ".stversions" not in p.parts
155
+ ]
156
+ if not conflicts:
157
+ return [
158
+ Check(
159
+ name="sync:conflicts",
160
+ description="No Syncthing sync-conflict files",
161
+ passed=True,
162
+ detail="clean",
163
+ category="sync",
164
+ )
165
+ ]
166
+ areas = sorted({p.relative_to(home).parts[0] for p in conflicts})
167
+ return [
168
+ Check(
169
+ name="sync:conflicts",
170
+ description="Syncthing sync-conflict files present",
171
+ passed=False,
172
+ detail=f"{len(conflicts)} conflict file(s) in: {', '.join(areas)}",
173
+ fix=(
174
+ "List with: find ~/.skcapstone -name '*.sync-conflict-*' ; keep "
175
+ "the authoritative copy and remove stale duplicates "
176
+ "(root cause: prb-7810b08e)."
177
+ ),
178
+ category="sync",
179
+ )
180
+ ]
181
+
182
+
183
+ def _check_scheduler(home: Path) -> list[Check]:
184
+ """Validate the skscheduler config (jobs.yaml) and its cron dependency.
185
+
186
+ Args:
187
+ home: Shared root directory (~/.skcapstone).
188
+
189
+ Returns:
190
+ Checks for jobs.yaml parseability (an optional file) and croniter
191
+ availability (required for cron-style schedules).
192
+ """
193
+ checks: list[Check] = []
194
+ jobs_path = home / "config" / "jobs.yaml"
195
+ if not jobs_path.exists():
196
+ checks.append(
197
+ Check(
198
+ name="scheduler:config",
199
+ description="skscheduler jobs.yaml",
200
+ passed=True,
201
+ detail="not configured (optional)",
202
+ category="system",
203
+ )
204
+ )
205
+ else:
206
+ try:
207
+ from .scheduler_jobs import load_jobs_with_dropins
208
+
209
+ jobs = load_jobs_with_dropins(jobs_path)
210
+ checks.append(
211
+ Check(
212
+ name="scheduler:config",
213
+ description="skscheduler jobs.yaml parses",
214
+ passed=True,
215
+ detail=f"{len(jobs)} job(s)",
216
+ category="system",
217
+ )
218
+ )
219
+ except Exception as exc: # noqa: BLE001 - report any parse failure
220
+ checks.append(
221
+ Check(
222
+ name="scheduler:config",
223
+ description="skscheduler jobs.yaml parse error",
224
+ passed=False,
225
+ detail=str(exc)[:120],
226
+ fix="Fix the YAML in ~/.skcapstone/config/jobs.yaml",
227
+ category="system",
228
+ )
229
+ )
230
+ try:
231
+ import croniter # noqa: F401
232
+
233
+ checks.append(
234
+ Check(
235
+ name="scheduler:croniter",
236
+ description="croniter installed (cron schedules)",
237
+ passed=True,
238
+ detail="ok",
239
+ category="system",
240
+ )
241
+ )
242
+ except ImportError:
243
+ checks.append(
244
+ Check(
245
+ name="scheduler:croniter",
246
+ description="croniter missing (cron schedules unavailable)",
247
+ passed=False,
248
+ fix="pip install croniter",
249
+ category="system",
250
+ )
251
+ )
252
+ return checks
253
+
254
+
126
255
  def _check_packages() -> list[Check]:
127
256
  """Check that all ecosystem Python packages are installed."""
128
257
  checks = []
@@ -130,9 +259,9 @@ def _check_packages() -> list[Check]:
130
259
  ("skcapstone", "Sovereign agent framework", "pip install skcapstone"),
131
260
  ("capauth", "PGP-based sovereign identity", "pip install capauth"),
132
261
  ("skmemory", "Universal AI memory system", "pip install skmemory"),
133
- ("skcomm", "Redundant agent communication", "pip install skcomm"),
262
+ ("skcomms", "Redundant agent communication", "pip install skcomms"),
134
263
  ("skchat", "Encrypted P2P chat", "pip install skchat"),
135
- ("cloud9_protocol", "Emotional continuity protocol", "pip install cloud9-protocol"),
264
+ ("cloud9", "Emotional continuity protocol", "pip install cloud9"),
136
265
  ("pgpy", "PGP cryptography (PGPy backend)", "pip install pgpy"),
137
266
  ]
138
267
 
@@ -159,6 +288,17 @@ def _check_packages() -> list[Check]:
159
288
  category="packages",
160
289
  )
161
290
  )
291
+ except (ValueError, RuntimeError, OSError) as exc:
292
+ # Package installed but failed to initialize (e.g. no agent configured)
293
+ checks.append(
294
+ Check(
295
+ name=f"pkg:{pkg_name}",
296
+ description=desc,
297
+ passed=True,
298
+ detail=f"installed (init pending: {exc})",
299
+ category="packages",
300
+ )
301
+ )
162
302
 
163
303
  return checks
164
304
 
@@ -381,11 +521,253 @@ def _check_identity(home: Path) -> list[Check]:
381
521
  return checks
382
522
 
383
523
 
524
+ # Agents are considered "provisioned" — and therefore expected to carry a
525
+ # per-agent identity.json — when they have a CapAuth home on disk. Empty
526
+ # scaffolds and ``*-template`` directories are intentionally excluded so the
527
+ # check does not red-flag dirs that were never meant to hold a real identity.
528
+ def _provisioned_agents(home: Path) -> list[str]:
529
+ """List agents that have a CapAuth home (and thus a real identity).
530
+
531
+ Args:
532
+ home: Shared root directory (~/.skcapstone).
533
+
534
+ Returns:
535
+ Sorted agent names whose ``agents/<name>/capauth/`` dir exists,
536
+ excluding ``*-template`` scaffolds.
537
+ """
538
+ agents_root = home / "agents"
539
+ if not agents_root.is_dir():
540
+ return []
541
+ names = []
542
+ for d in agents_root.iterdir():
543
+ if not d.is_dir() or d.name.endswith("-template"):
544
+ continue
545
+ if (d / "capauth").is_dir():
546
+ names.append(d.name)
547
+ return sorted(names)
548
+
549
+
550
+ def _scan_capauth_local(home: Path) -> list[str]:
551
+ """Find identity.json files still carrying an ``@capauth.local`` placeholder.
552
+
553
+ The ``@capauth.local`` suffix was the old placeholder email minted before a
554
+ real CapAuth profile existed. The unified identity layer (epic 2b264064)
555
+ eliminated it; any lingering occurrence means a stale/placeholder identity
556
+ that should be re-minted from the real profile.
557
+
558
+ Args:
559
+ home: Shared root directory (~/.skcapstone).
560
+
561
+ Returns:
562
+ Relative paths (to *home*) of identity.json files containing the
563
+ placeholder, sorted for stable output.
564
+ """
565
+ candidates = [home / "identity" / "identity.json"]
566
+ agents_root = home / "agents"
567
+ if agents_root.is_dir():
568
+ candidates += sorted(agents_root.glob("*/identity/identity.json"))
569
+ hits: list[str] = []
570
+ for path in candidates:
571
+ if not path.exists():
572
+ continue
573
+ try:
574
+ if "@capauth.local" in path.read_text(encoding="utf-8"):
575
+ hits.append(str(path.relative_to(home)))
576
+ except OSError:
577
+ continue
578
+ return hits
579
+
580
+
581
+ def _check_identity_consistency(home: Path) -> list[Check]:
582
+ """Validate the unified identity layer (epic 2b264064 / skos T6).
583
+
584
+ Locks in the single agent-aware resolver and the shared-operator /
585
+ per-agent-wire split. Five checks in the ``identity`` category:
586
+
587
+ 1. ``identity:resolver`` — ``capauth.resolve_agent_identity`` is importable
588
+ (the single canonical resolver every SK package delegates to).
589
+ 2. ``identity:self`` — that resolver returns an agent-aware identity for the
590
+ active agent (not the ``"local"`` floor) with a populated ``capauth_uri``.
591
+ 3. ``identity:operator`` — the shared ``~/.skcapstone/identity/identity.json``
592
+ describes the operator (``role == "operator"``), not a stale placeholder.
593
+ 4. ``identity:no-placeholder`` — no identity.json anywhere still carries an
594
+ ``@capauth.local`` placeholder email.
595
+ 5. ``identity:per-agent`` — every provisioned agent (one with a CapAuth home)
596
+ has its own per-agent ``identity/identity.json``.
597
+
598
+ Args:
599
+ home: Shared root directory (~/.skcapstone).
600
+
601
+ Returns:
602
+ Up to five Check results in the ``identity`` category.
603
+ """
604
+ checks: list[Check] = []
605
+
606
+ # 1. The canonical resolver must be importable.
607
+ resolver = None
608
+ try:
609
+ from capauth import resolve_agent_identity as resolver # type: ignore
610
+ checks.append(
611
+ Check(
612
+ name="identity:resolver",
613
+ description="Unified identity resolver (capauth.resolve_agent_identity)",
614
+ passed=True,
615
+ detail="importable — the single canonical resolver",
616
+ category="identity",
617
+ )
618
+ )
619
+ except ImportError as exc:
620
+ checks.append(
621
+ Check(
622
+ name="identity:resolver",
623
+ description="Unified identity resolver (capauth.resolve_agent_identity)",
624
+ passed=False,
625
+ detail=str(exc),
626
+ fix="pip install -e capauth (epic 2b264064 — capauth is the source of truth)",
627
+ category="identity",
628
+ )
629
+ )
630
+
631
+ # 2. Self-identity resolves agent-aware (not the "local" floor).
632
+ if resolver is not None:
633
+ try:
634
+ ident = resolver()
635
+ aware = bool(ident.agent) and ident.agent != "local" and bool(ident.capauth_uri)
636
+ fqid = getattr(ident, "fqid", None)
637
+ detail = f"{ident.agent} → {ident.capauth_uri}" + (f" / {fqid}" if fqid else "")
638
+ checks.append(
639
+ Check(
640
+ name="identity:self",
641
+ description="Self-identity resolves agent-aware",
642
+ passed=aware,
643
+ detail=detail if aware else f"resolved to floor: {ident.agent!r}",
644
+ fix=(
645
+ ""
646
+ if aware
647
+ else "Set SKAGENT (or run `skswitch <agent>`) so the resolver "
648
+ "binds a real agent instead of the 'local' floor"
649
+ ),
650
+ category="identity",
651
+ )
652
+ )
653
+ except Exception as exc: # noqa: BLE001 - any resolver failure is a finding
654
+ checks.append(
655
+ Check(
656
+ name="identity:self",
657
+ description="Self-identity resolves agent-aware",
658
+ passed=False,
659
+ detail=str(exc)[:120],
660
+ fix="Investigate capauth.resolve_agent_identity() failure",
661
+ category="identity",
662
+ )
663
+ )
664
+
665
+ # 3. Shared identity.json describes the operator.
666
+ shared = home / "identity" / "identity.json"
667
+ operator_ok = False
668
+ detail = "missing"
669
+ if shared.exists():
670
+ try:
671
+ data = json.loads(shared.read_text(encoding="utf-8"))
672
+ role = (data.get("role") or "").lower()
673
+ operator_ok = role == "operator"
674
+ detail = (
675
+ f"{data.get('name', '?')} (role={role or 'unset'})"
676
+ if operator_ok
677
+ else f"role={role or 'unset'} (expected 'operator')"
678
+ )
679
+ except (json.JSONDecodeError, OSError) as exc:
680
+ detail = f"unreadable: {exc}"
681
+ checks.append(
682
+ Check(
683
+ name="identity:operator",
684
+ description="Shared identity.json = operator",
685
+ passed=operator_ok,
686
+ detail=detail,
687
+ fix=(
688
+ ""
689
+ if operator_ok
690
+ else "Set \"role\": \"operator\" on ~/.skcapstone/identity/identity.json "
691
+ "(shared file is the operator; agents resolve per-agent)"
692
+ ),
693
+ category="identity",
694
+ )
695
+ )
696
+
697
+ # 4. No @capauth.local placeholder lingers anywhere.
698
+ placeholders = _scan_capauth_local(home)
699
+ checks.append(
700
+ Check(
701
+ name="identity:no-placeholder",
702
+ description="No @capauth.local placeholder identities",
703
+ passed=not placeholders,
704
+ detail="clean" if not placeholders else f"{len(placeholders)} file(s): {', '.join(placeholders)}",
705
+ fix=(
706
+ ""
707
+ if not placeholders
708
+ else "Re-mint the listed identity.json from the real CapAuth profile "
709
+ "(remove the @capauth.local placeholder email)"
710
+ ),
711
+ category="identity",
712
+ )
713
+ )
714
+
715
+ # 5. Every provisioned agent carries a per-agent identity.json.
716
+ provisioned = _provisioned_agents(home)
717
+ missing = [
718
+ a for a in provisioned
719
+ if not (home / "agents" / a / "identity" / "identity.json").exists()
720
+ ]
721
+ if not provisioned:
722
+ checks.append(
723
+ Check(
724
+ name="identity:per-agent",
725
+ description="Per-agent identity.json for provisioned agents",
726
+ passed=True,
727
+ detail="no provisioned agents (none with a CapAuth home)",
728
+ category="identity",
729
+ )
730
+ )
731
+ else:
732
+ checks.append(
733
+ Check(
734
+ name="identity:per-agent",
735
+ description="Per-agent identity.json for provisioned agents",
736
+ passed=not missing,
737
+ detail=(
738
+ f"{len(provisioned)} agent(s), all present"
739
+ if not missing
740
+ else f"missing for: {', '.join(missing)}"
741
+ ),
742
+ fix=(
743
+ ""
744
+ if not missing
745
+ else "Run `capauth init` for the listed agents so each has a "
746
+ "per-agent identity/identity.json"
747
+ ),
748
+ category="identity",
749
+ )
750
+ )
751
+
752
+ return checks
753
+
754
+
755
+ def _resolve_memory_dir(home: Path) -> Path:
756
+ """Resolve the memory directory for either shared-root or agent-home inputs."""
757
+ from . import active_agent_name
758
+
759
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name() or ""
760
+ if home.parent.name == "agents":
761
+ return home / "memory"
762
+ if agent_name:
763
+ return home / "agents" / agent_name / "memory"
764
+ return home / "memory"
765
+
766
+
384
767
  def _check_memory(home: Path) -> list[Check]:
385
768
  """Check memory store health."""
386
769
  checks = []
387
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
388
- memory_dir = home / "agents" / agent_name / "memory"
770
+ memory_dir = _resolve_memory_dir(home)
389
771
 
390
772
  if not memory_dir.exists():
391
773
  checks.append(
@@ -434,18 +816,18 @@ def _check_memory(home: Path) -> list[Check]:
434
816
 
435
817
 
436
818
  def _check_transport() -> list[Check]:
437
- """Check SKComm transport availability."""
819
+ """Check SKComms transport availability."""
438
820
  checks = []
439
821
 
440
822
  try:
441
- from skcomm.core import SKComm
823
+ from skcomms.core import SKComms
442
824
 
443
- comm = SKComm.from_config()
825
+ comm = SKComms.from_config()
444
826
  transport_count = len(comm.router.transports)
445
827
  checks.append(
446
828
  Check(
447
- name="transport:skcomm",
448
- description="SKComm engine",
829
+ name="transport:skcomms",
830
+ description="SKComms engine",
449
831
  passed=True,
450
832
  detail=f"{transport_count} transport(s) configured",
451
833
  category="transport",
@@ -459,7 +841,7 @@ def _check_transport() -> list[Check]:
459
841
  description="Active transports",
460
842
  passed=False,
461
843
  detail="No transports configured",
462
- fix="Configure transports in ~/.skcomm/config.yml",
844
+ fix="Configure transports in ~/.skcomms/config.yml",
463
845
  category="transport",
464
846
  )
465
847
  )
@@ -481,21 +863,21 @@ def _check_transport() -> list[Check]:
481
863
  except ImportError:
482
864
  checks.append(
483
865
  Check(
484
- name="transport:skcomm",
485
- description="SKComm engine",
866
+ name="transport:skcomms",
867
+ description="SKComms engine",
486
868
  passed=False,
487
- fix="pip install skcomm",
869
+ fix="pip install skcomms",
488
870
  category="transport",
489
871
  )
490
872
  )
491
873
  except Exception as exc:
492
874
  checks.append(
493
875
  Check(
494
- name="transport:skcomm",
495
- description="SKComm engine",
876
+ name="transport:skcomms",
877
+ description="SKComms engine",
496
878
  passed=False,
497
879
  detail=str(exc),
498
- fix="Check ~/.skcomm/config.yml",
880
+ fix="Check ~/.skcomms/config.yml",
499
881
  category="transport",
500
882
  )
501
883
  )
@@ -590,6 +972,49 @@ def _check_sync(home: Path) -> list[Check]:
590
972
  return checks
591
973
 
592
974
 
975
+ def _check_codex() -> list[Check]:
976
+ """Check Codex global SK agent prompt bootstrap."""
977
+ codex_detected = bool(os.environ.get("CODEX_HOME")) or (Path.home() / ".codex").exists()
978
+ codex_detected = codex_detected or shutil.which("codex") is not None
979
+
980
+ if not codex_detected:
981
+ return [
982
+ Check(
983
+ name="codex:agent_context",
984
+ description="Codex SK agent context bootstrap",
985
+ passed=True,
986
+ detail="Codex not detected (optional)",
987
+ category="codex",
988
+ )
989
+ ]
990
+
991
+ try:
992
+ from .codex_setup import check_codex_setup
993
+
994
+ configured, detail = check_codex_setup()
995
+ return [
996
+ Check(
997
+ name="codex:agent_context",
998
+ description="Codex SK agent context bootstrap",
999
+ passed=configured,
1000
+ detail=detail,
1001
+ fix="" if configured else "skcapstone doctor --fix",
1002
+ category="codex",
1003
+ )
1004
+ ]
1005
+ except OSError as exc:
1006
+ return [
1007
+ Check(
1008
+ name="codex:agent_context",
1009
+ description="Codex SK agent context bootstrap",
1010
+ passed=False,
1011
+ detail=str(exc),
1012
+ fix="skcapstone doctor --fix",
1013
+ category="codex",
1014
+ )
1015
+ ]
1016
+
1017
+
593
1018
  def _check_versions() -> list[Check]:
594
1019
  """Check for outdated ecosystem packages."""
595
1020
  checks = []
@@ -613,8 +1038,339 @@ def _check_versions() -> list[Check]:
613
1038
  category="packages",
614
1039
  )
615
1040
  )
616
- except Exception:
617
- pass
1041
+ except Exception as exc:
1042
+ logger.warning("Version check failed (non-fatal): %s", exc)
1043
+
1044
+ return checks
1045
+
1046
+
1047
+ # ───────────────────────────────────────────────────────────────────────────
1048
+ # AI-harness (Claude Code) environment checks
1049
+ # ───────────────────────────────────────────────────────────────────────────
1050
+
1051
+
1052
+ def _claude_config_home() -> Path:
1053
+ """Resolve the Claude Code config directory (honours CLAUDE_CONFIG_DIR)."""
1054
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude")).expanduser()
1055
+
1056
+
1057
+ def _load_json_safe(path: Path) -> dict:
1058
+ """Load JSON from *path*, returning {} on any error (missing/invalid)."""
1059
+ try:
1060
+ data = json.loads(path.read_text(encoding="utf-8"))
1061
+ return data if isinstance(data, dict) else {}
1062
+ except (OSError, json.JSONDecodeError):
1063
+ return {}
1064
+
1065
+
1066
+ def _expected_mcp_servers() -> dict[str, dict]:
1067
+ """Spec for the SK* MCP servers an agent expects, with derived env.
1068
+
1069
+ Agent name and home are derived from the environment so the spec stays
1070
+ portable across agents/machines (no hardcoded identity).
1071
+ """
1072
+ from . import DEFAULT_AGENT
1073
+
1074
+ agent = (
1075
+ os.environ.get("SKAGENT")
1076
+ or os.environ.get("SKCAPSTONE_AGENT")
1077
+ or DEFAULT_AGENT
1078
+ )
1079
+ sk_home = os.environ.get("SKCAPSTONE_HOME") or str(Path("~/.skcapstone").expanduser())
1080
+ return {
1081
+ "skmemory": {
1082
+ "binary": "skmemory-mcp",
1083
+ "env": {"SKAGENT": agent, "SKMEMORY_AGENT": agent, "SKCAPSTONE_HOME": sk_home},
1084
+ "autofix": True,
1085
+ },
1086
+ "skcapstone": {
1087
+ "binary": "skcapstone-mcp",
1088
+ "env": {"SKAGENT": agent, "SKCAPSTONE_AGENT": agent, "SKCAPSTONE_HOME": sk_home},
1089
+ "autofix": True,
1090
+ },
1091
+ # skchat identity (SKCHAT_IDENTITY) is account-specific — never guessed.
1092
+ "skchat": {"binary": "skchat-mcp", "env": {}, "autofix": False},
1093
+ }
1094
+
1095
+
1096
+ def _registered_mcp_servers() -> set[str]:
1097
+ """MCP server names from the locations Claude Code actually reads.
1098
+
1099
+ Reads global + per-project ``mcpServers`` in ``~/.claude.json`` and a
1100
+ checked-in ``.mcp.json`` in the current directory.
1101
+ """
1102
+ names: set[str] = set()
1103
+ cc = _load_json_safe(Path("~/.claude.json").expanduser())
1104
+ names.update((cc.get("mcpServers") or {}).keys())
1105
+ for proj in (cc.get("projects") or {}).values():
1106
+ if isinstance(proj, dict):
1107
+ names.update((proj.get("mcpServers") or {}).keys())
1108
+ dotmcp = _load_json_safe(Path(".mcp.json").resolve())
1109
+ names.update((dotmcp.get("mcpServers") or {}).keys())
1110
+ return names
1111
+
1112
+
1113
+ def _dead_config_mcp_servers() -> set[str]:
1114
+ """MCP server names defined ONLY where Claude Code does NOT read them.
1115
+
1116
+ Namely ``~/.claude/settings.json``'s ``mcpServers`` block and a
1117
+ top-level ``~/.claude/mcp.json`` — both silently ignored by Claude Code.
1118
+ """
1119
+ ch = _claude_config_home()
1120
+ names: set[str] = set()
1121
+ names.update((_load_json_safe(ch / "settings.json").get("mcpServers") or {}).keys())
1122
+ names.update(_load_json_safe(ch / "mcp.json").keys())
1123
+ return names
1124
+
1125
+
1126
+ def _is_skcapstone_binary_cmd(command: str) -> bool:
1127
+ """True only when a hook command's *executable* is the skcapstone binary.
1128
+
1129
+ The SessionStart-hook check must not match on a bare ``"skcapstone" in
1130
+ command`` substring: that false-positives on a hook script living under a
1131
+ ``skcapstone-repos/`` path (e.g. skmemory's ``sk-activity-inject.sh``) and
1132
+ on the sibling ``skcapstone-mcp`` binary — neither of which is the
1133
+ ``skcapstone`` CLI. Matching the first token's basename is precise.
1134
+
1135
+ Args:
1136
+ command: The full hook command string.
1137
+
1138
+ Returns:
1139
+ True iff the command's first whitespace-delimited token is the
1140
+ ``skcapstone`` executable (by basename).
1141
+ """
1142
+ parts = command.strip().split()
1143
+ if not parts:
1144
+ return False
1145
+ return Path(parts[0]).name == "skcapstone"
1146
+
1147
+
1148
+ def _check_harness_env(home: Path) -> list[Check]:
1149
+ """Validate the AI-harness (Claude Code) environment configuration.
1150
+
1151
+ Catches the silent traps that leave an agent waking up cold:
1152
+ * MCP servers defined where Claude Code never reads them,
1153
+ * a SessionStart hook pointing at a stale/missing ``skcapstone`` binary,
1154
+ * a missing ``skwhisper`` CLI shim when the whisper layer is in use.
1155
+
1156
+ No-ops gracefully (single informational check) when Claude Code is not
1157
+ detected, so non-Claude-Code users are not spammed with failures.
1158
+
1159
+ Args:
1160
+ home: Agent home directory.
1161
+
1162
+ Returns:
1163
+ List of Check results in the ``harness`` category.
1164
+ """
1165
+ checks: list[Check] = []
1166
+
1167
+ if not Path("~/.claude.json").expanduser().exists():
1168
+ checks.append(Check(
1169
+ name="harness:claude-code",
1170
+ description="Claude Code config (~/.claude.json)",
1171
+ passed=True,
1172
+ detail="not detected — skipping harness checks",
1173
+ category="harness",
1174
+ ))
1175
+ return checks
1176
+
1177
+ registered = _registered_mcp_servers()
1178
+ dead = _dead_config_mcp_servers()
1179
+
1180
+ for name, spec in _expected_mcp_servers().items():
1181
+ if name in registered:
1182
+ checks.append(Check(
1183
+ name=f"harness:mcp:{name}",
1184
+ description=f"MCP server '{name}' registered with Claude Code",
1185
+ passed=True,
1186
+ detail="present in a config Claude Code reads",
1187
+ category="harness",
1188
+ ))
1189
+ continue
1190
+
1191
+ if name in dead:
1192
+ detail = "defined ONLY in settings.json/mcp.json (not read by Claude Code)"
1193
+ else:
1194
+ detail = "not registered"
1195
+ binary = shutil.which(spec["binary"]) or spec["binary"]
1196
+ if spec["autofix"]:
1197
+ env_flags = " ".join(f"-e {k}={v}" for k, v in spec["env"].items())
1198
+ fix = f"claude mcp add {name} --scope user {env_flags} -- {binary}"
1199
+ else:
1200
+ fix = (
1201
+ f"claude mcp add {name} --scope user -e SKCHAT_IDENTITY=<your-identity> "
1202
+ f"-- {binary} # identity is account-specific"
1203
+ )
1204
+ checks.append(Check(
1205
+ name=f"harness:mcp:{name}",
1206
+ description=f"MCP server '{name}' registered with Claude Code",
1207
+ passed=False,
1208
+ detail=detail,
1209
+ fix=fix,
1210
+ category="harness",
1211
+ ))
1212
+
1213
+ # SessionStart hook must reference an existing skcapstone binary.
1214
+ settings = _load_json_safe(_claude_config_home() / "settings.json")
1215
+ hook_cmds = [
1216
+ h.get("command", "")
1217
+ for entry in (settings.get("hooks", {}).get("SessionStart") or [])
1218
+ for h in (entry.get("hooks") or [])
1219
+ if _is_skcapstone_binary_cmd(h.get("command", ""))
1220
+ ]
1221
+ if hook_cmds:
1222
+ live = shutil.which("skcapstone")
1223
+ live_real = str(Path(live).resolve()) if live else None
1224
+ missing = None
1225
+ stale = None
1226
+ hook_binary = ""
1227
+ for cmd in hook_cmds:
1228
+ hook_binary = cmd.strip().split()[0] if cmd.strip() else ""
1229
+ if "/" in hook_binary:
1230
+ resolved = Path(hook_binary).expanduser()
1231
+ if not resolved.exists():
1232
+ missing = hook_binary
1233
+ break
1234
+ # Present but pointing at a *different* skcapstone than PATH —
1235
+ # this is the stale-install trap (e.g. an old pyenv shim).
1236
+ if live_real and str(resolved.resolve()) != live_real:
1237
+ stale = hook_binary
1238
+ elif not shutil.which(hook_binary):
1239
+ missing = hook_binary
1240
+ break
1241
+
1242
+ if missing:
1243
+ checks.append(Check(
1244
+ name="harness:hook:sessionstart",
1245
+ description="SessionStart hook skcapstone binary",
1246
+ passed=False,
1247
+ detail=f"hook references missing binary: {missing}",
1248
+ fix=f"Repoint the hook at {live or 'the live skcapstone'} (skcapstone doctor --fix)",
1249
+ category="harness",
1250
+ ))
1251
+ elif stale:
1252
+ checks.append(Check(
1253
+ name="harness:hook:sessionstart",
1254
+ description="SessionStart hook skcapstone binary",
1255
+ passed=False,
1256
+ detail=f"hook uses {stale}, but PATH skcapstone is {live} (possible stale install)",
1257
+ fix=f"Repoint the hook at {live} (skcapstone doctor --fix)",
1258
+ category="harness",
1259
+ ))
1260
+ else:
1261
+ checks.append(Check(
1262
+ name="harness:hook:sessionstart",
1263
+ description="SessionStart hook skcapstone binary",
1264
+ passed=True,
1265
+ detail=hook_binary,
1266
+ category="harness",
1267
+ ))
1268
+
1269
+ checks.extend(_check_yolo())
1270
+
1271
+ # skwhisper CLI shim — only required when this agent uses the whisper layer.
1272
+ if (home / "skwhisper").exists():
1273
+ wpath = shutil.which("skwhisper")
1274
+ checks.append(Check(
1275
+ name="harness:skwhisper",
1276
+ description="skwhisper CLI on PATH",
1277
+ passed=bool(wpath),
1278
+ detail=wpath or "not found (whisper layer present but no CLI shim)",
1279
+ fix=(
1280
+ ""
1281
+ if wpath
1282
+ else "Add a shim on PATH that runs `python -m skwhisper` "
1283
+ "with the skwhisper repo on PYTHONPATH"
1284
+ ),
1285
+ category="harness",
1286
+ ))
1287
+
1288
+ return checks
1289
+
1290
+
1291
+ def _check_yolo() -> list[Check]:
1292
+ """Report the permission-bypass (YOLO) wiring for the AI harness wrappers.
1293
+
1294
+ The picker's ``claude``/``codex``/``opencode`` wrapper functions append a
1295
+ permission-bypass flag only when the matching ``SK_*_YOLO`` env var is ``1``
1296
+ (see ``sk-agent-picker.sh``). This check surfaces two things that silently
1297
+ diverge otherwise:
1298
+
1299
+ * whether the bypass is active in *this* environment, and
1300
+ * whether it is persisted in a shell rc file so future shells match.
1301
+
1302
+ It is intentionally non-judgemental about ON vs OFF — both are valid
1303
+ depending on the box — and only flags an *inconsistency* (active in the
1304
+ current env but not persisted, so the next fresh shell would behave
1305
+ differently). Detection is best-effort: ``doctor`` runs as a subprocess and
1306
+ cannot see live shell functions, so it reads the env var and greps rc files.
1307
+
1308
+ Returns:
1309
+ One Check per harness tool (claude/codex/opencode) that has YOLO active
1310
+ in the env or persisted in an rc file; nothing for tools left at the
1311
+ safe default, plus a single summary line when all are default-off.
1312
+ """
1313
+ rc_files = [
1314
+ Path.home() / ".bashrc",
1315
+ Path.home() / ".zshrc",
1316
+ Path.home() / ".bash_profile",
1317
+ Path.home() / ".profile",
1318
+ ]
1319
+ rc_text = ""
1320
+ for rc in rc_files:
1321
+ try:
1322
+ rc_text += rc.read_text(encoding="utf-8", errors="ignore")
1323
+ except OSError:
1324
+ continue
1325
+
1326
+ tools = [
1327
+ ("claude", "SK_CLAUDE_YOLO", "--dangerously-skip-permissions"),
1328
+ ("codex", "SK_CODEX_YOLO", "--dangerously-bypass-approvals-and-sandbox"),
1329
+ ("opencode", "SK_OPENCODE_YOLO", "all-tools-allowed"),
1330
+ ]
1331
+
1332
+ checks: list[Check] = []
1333
+ any_active = False
1334
+ for tool, var, flag in tools:
1335
+ env_on = os.environ.get(var, "0") == "1"
1336
+ persisted = f"export {var}=1" in rc_text or f"{var}=1" in rc_text
1337
+ if not env_on and not persisted:
1338
+ continue
1339
+ any_active = True
1340
+ if env_on and persisted:
1341
+ checks.append(Check(
1342
+ name=f"harness:yolo:{tool}",
1343
+ description=f"{tool} permission bypass ({var})",
1344
+ passed=True,
1345
+ detail=f"ENABLED globally — adds {flag}",
1346
+ category="harness",
1347
+ ))
1348
+ elif env_on and not persisted:
1349
+ checks.append(Check(
1350
+ name=f"harness:yolo:{tool}",
1351
+ description=f"{tool} permission bypass ({var})",
1352
+ passed=False,
1353
+ detail="active in this shell but NOT persisted in any rc file",
1354
+ fix=f"Add `export {var}=1` to ~/.bashrc to make it permanent",
1355
+ category="harness",
1356
+ ))
1357
+ else: # persisted but not in current env (stale shell / rc not sourced)
1358
+ checks.append(Check(
1359
+ name=f"harness:yolo:{tool}",
1360
+ description=f"{tool} permission bypass ({var})",
1361
+ passed=True,
1362
+ detail="persisted in rc file (re-source the shell to activate)",
1363
+ category="harness",
1364
+ ))
1365
+
1366
+ if not any_active:
1367
+ checks.append(Check(
1368
+ name="harness:yolo",
1369
+ description="AI-harness permission bypass (SK_*_YOLO)",
1370
+ passed=True,
1371
+ detail="disabled — wrappers run with permission prompts (safe default)",
1372
+ category="harness",
1373
+ ))
618
1374
 
619
1375
  return checks
620
1376
 
@@ -653,7 +1409,23 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
653
1409
  continue
654
1410
 
655
1411
  # Fix missing directories
656
- if check.name.startswith("home:") and check.name != "home:exists" and check.name != "home:manifest":
1412
+ if check.name == "home:exists":
1413
+ try:
1414
+ home.mkdir(parents=True, exist_ok=True)
1415
+ results.append(FixResult(
1416
+ check_name=check.name,
1417
+ success=True,
1418
+ action=f"Created agent home directory {home}",
1419
+ ))
1420
+ except OSError as exc:
1421
+ results.append(FixResult(
1422
+ check_name=check.name,
1423
+ success=False,
1424
+ error=str(exc),
1425
+ ))
1426
+
1427
+ # Fix missing directories
1428
+ elif check.name.startswith("home:") and check.name != "home:manifest":
657
1429
  dirname = check.name.split(":", 1)[1]
658
1430
  dirpath = home / dirname
659
1431
  try:
@@ -674,6 +1446,8 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
674
1446
  elif check.name == "home:manifest":
675
1447
  manifest_path = home / "manifest.json"
676
1448
  try:
1449
+ if manifest_path.exists():
1450
+ raise FileExistsError(f"Refusing to overwrite existing manifest: {manifest_path}")
677
1451
  data = {
678
1452
  "name": os.environ.get("SKCAPSTONE_AGENT", "sovereign"),
679
1453
  "version": "0.0.0",
@@ -686,7 +1460,7 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
686
1460
  success=True,
687
1461
  action=f"Created default manifest at {manifest_path}",
688
1462
  ))
689
- except OSError as exc:
1463
+ except (OSError, FileExistsError) as exc:
690
1464
  results.append(FixResult(
691
1465
  check_name=check.name,
692
1466
  success=False,
@@ -695,8 +1469,7 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
695
1469
 
696
1470
  # Fix missing memory store
697
1471
  elif check.name == "memory:store":
698
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
699
- memory_dir = home / "agents" / agent_name / "memory"
1472
+ memory_dir = _resolve_memory_dir(home)
700
1473
  try:
701
1474
  for layer in ("short-term", "mid-term", "long-term"):
702
1475
  (memory_dir / layer).mkdir(parents=True, exist_ok=True)
@@ -712,6 +1485,42 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
712
1485
  error=str(exc),
713
1486
  ))
714
1487
 
1488
+ # Rebuild missing memory index
1489
+ elif check.name == "memory:index":
1490
+ memory_dir = _resolve_memory_dir(home)
1491
+ index_path = memory_dir / "index.json"
1492
+ try:
1493
+ index_data: dict[str, dict] = {}
1494
+ for layer in ("short-term", "mid-term", "long-term"):
1495
+ layer_dir = memory_dir / layer
1496
+ if not layer_dir.exists():
1497
+ continue
1498
+ for memory_file in layer_dir.glob("*.json"):
1499
+ try:
1500
+ payload = json.loads(memory_file.read_text(encoding="utf-8"))
1501
+ except (OSError, json.JSONDecodeError):
1502
+ continue
1503
+ memory_id = payload.get("memory_id") or payload.get("id") or memory_file.stem
1504
+ index_data[memory_id] = {
1505
+ "layer": layer,
1506
+ "tags": payload.get("tags", []),
1507
+ "importance": payload.get("importance"),
1508
+ "created_at": payload.get("created_at"),
1509
+ }
1510
+ memory_dir.mkdir(parents=True, exist_ok=True)
1511
+ index_path.write_text(json.dumps(index_data, indent=2), encoding="utf-8")
1512
+ results.append(FixResult(
1513
+ check_name=check.name,
1514
+ success=True,
1515
+ action=f"Rebuilt memory index at {index_path}",
1516
+ ))
1517
+ except OSError as exc:
1518
+ results.append(FixResult(
1519
+ check_name=check.name,
1520
+ success=False,
1521
+ error=str(exc),
1522
+ ))
1523
+
715
1524
  # Fix missing sync directory
716
1525
  elif check.name == "sync:dir":
717
1526
  sync_dir = home / "sync"
@@ -730,6 +1539,116 @@ def run_fixes(report: DiagnosticReport, home: Path) -> list[FixResult]:
730
1539
  error=str(exc),
731
1540
  ))
732
1541
 
1542
+ # Fix Codex global SK agent context bootstrap
1543
+ elif check.name == "codex:agent_context":
1544
+ try:
1545
+ from .codex_setup import ensure_codex_setup
1546
+
1547
+ actions = ensure_codex_setup()
1548
+ results.append(FixResult(
1549
+ check_name=check.name,
1550
+ success=True,
1551
+ action=", ".join(actions) if actions else "Codex bootstrap already configured",
1552
+ ))
1553
+ except OSError as exc:
1554
+ results.append(FixResult(
1555
+ check_name=check.name,
1556
+ success=False,
1557
+ error=str(exc),
1558
+ ))
1559
+
1560
+ # Register a missing MCP server with Claude Code (user scope).
1561
+ elif check.name.startswith("harness:mcp:"):
1562
+ name = check.name.split(":", 2)[2]
1563
+ spec = _expected_mcp_servers().get(name)
1564
+ if not spec or not spec.get("autofix"):
1565
+ results.append(FixResult(
1566
+ check_name=check.name,
1567
+ success=False,
1568
+ error="manual fix required (identity is account-specific) — see hint",
1569
+ ))
1570
+ elif not shutil.which("claude"):
1571
+ results.append(FixResult(
1572
+ check_name=check.name,
1573
+ success=False,
1574
+ error="claude CLI not found on PATH",
1575
+ ))
1576
+ else:
1577
+ binary = shutil.which(spec["binary"]) or spec["binary"]
1578
+ cmd = ["claude", "mcp", "add", name, "--scope", "user"]
1579
+ for key, val in spec["env"].items():
1580
+ cmd += ["-e", f"{key}={val}"]
1581
+ cmd += ["--", binary]
1582
+ try:
1583
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
1584
+ if proc.returncode == 0:
1585
+ results.append(FixResult(
1586
+ check_name=check.name,
1587
+ success=True,
1588
+ action=f"Registered MCP server '{name}' (user scope)",
1589
+ ))
1590
+ else:
1591
+ results.append(FixResult(
1592
+ check_name=check.name,
1593
+ success=False,
1594
+ error=(proc.stderr or proc.stdout).strip()[:200],
1595
+ ))
1596
+ except (subprocess.SubprocessError, OSError) as exc:
1597
+ results.append(FixResult(
1598
+ check_name=check.name,
1599
+ success=False,
1600
+ error=str(exc),
1601
+ ))
1602
+
1603
+ # Repoint a stale SessionStart hook at the live skcapstone binary.
1604
+ elif check.name == "harness:hook:sessionstart":
1605
+ live = shutil.which("skcapstone")
1606
+ settings_path = _claude_config_home() / "settings.json"
1607
+ if not live:
1608
+ results.append(FixResult(
1609
+ check_name=check.name,
1610
+ success=False,
1611
+ error="live skcapstone not found on PATH",
1612
+ ))
1613
+ else:
1614
+ try:
1615
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
1616
+ changed = False
1617
+ for entry in data.get("hooks", {}).get("SessionStart", []):
1618
+ for hook in entry.get("hooks", []):
1619
+ cmd_str = hook.get("command", "")
1620
+ # Match the executable, not a substring — otherwise a
1621
+ # hook script under skcapstone-repos/ would have its
1622
+ # path destructively rewritten to the skcapstone binary.
1623
+ if not _is_skcapstone_binary_cmd(cmd_str):
1624
+ continue
1625
+ parts = cmd_str.split()
1626
+ if parts and parts[0] != live:
1627
+ parts[0] = live
1628
+ hook["command"] = " ".join(parts)
1629
+ changed = True
1630
+ if changed:
1631
+ settings_path.write_text(
1632
+ json.dumps(data, indent=2) + "\n", encoding="utf-8"
1633
+ )
1634
+ results.append(FixResult(
1635
+ check_name=check.name,
1636
+ success=True,
1637
+ action=f"Repointed SessionStart hook to {live}",
1638
+ ))
1639
+ else:
1640
+ results.append(FixResult(
1641
+ check_name=check.name,
1642
+ success=False,
1643
+ error="no stale skcapstone hook command found to repair",
1644
+ ))
1645
+ except (OSError, json.JSONDecodeError) as exc:
1646
+ results.append(FixResult(
1647
+ check_name=check.name,
1648
+ success=False,
1649
+ error=str(exc),
1650
+ ))
1651
+
733
1652
  return results
734
1653
 
735
1654