Compare commits

...

2 Commits

6 changed files with 182 additions and 49 deletions

View File

@@ -28,6 +28,21 @@ Install to `~/Applications/AIWorkspace.app`:
apps/mac/AIWorkspace/scripts/package-app.sh --install apps/mac/AIWorkspace/scripts/package-app.sh --install
``` ```
The script defaults to the per-user install location because this workspace is a
developer-local utility and frequent rebuilds should not require `sudo`. To
install system-wide instead, pass an explicit install directory:
```bash
INSTALL_DIR=/Applications apps/mac/AIWorkspace/scripts/package-app.sh --install
APP_PATH=/Applications/AIWorkspace.app apps/mac/AIWorkspace/scripts/install-start-at-login.sh
```
Avoid keeping both `~/Applications/AIWorkspace.app` and
`/Applications/AIWorkspace.app` installed unless you are intentionally comparing
builds. They share the same bundle identifier (`com.aiworkspace.menu`) and can
confuse LaunchServices, login item registration, and which copy opens at login.
If both exist, quit any running AI Workspace instances and remove the stale copy.
## Build a DMG ## Build a DMG
```bash ```bash

View File

@@ -4,11 +4,51 @@ import ServiceManagement
import SwiftUI import SwiftUI
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true) private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
private let defaultProfile = "fidelity"
struct ProfileConfig {
let id: String
let displayName: String
let knowledgeDir: String
static func discoverDefault() -> ProfileConfig {
let profilesRoot = workspaceRoot.appendingPathComponent("profiles", isDirectory: true)
let candidates = (try? FileManager.default.contentsOfDirectory(at: profilesRoot, includingPropertiesForKeys: nil)) ?? []
let configs = candidates.compactMap { directory -> ProfileConfig? in
let configURL = directory.appendingPathComponent("workspace.json")
guard let data = try? Data(contentsOf: configURL),
let decoded = try? JSONDecoder().decode(ProfileWorkspaceConfig.self, from: data)
else { return nil }
return ProfileConfig(
id: decoded.profile ?? directory.lastPathComponent,
displayName: decoded.displayName ?? decoded.profile ?? directory.lastPathComponent,
knowledgeDir: decoded.knowledgeDir ?? "workspaces/\(decoded.profile ?? directory.lastPathComponent)/project-knowledge"
)
}
if let fidelity = configs.first(where: { $0.id == "fidelity" }) {
return fidelity
}
if let first = configs.sorted(by: { $0.id < $1.id }).first {
return first
}
return ProfileConfig(id: "fidelity", displayName: "Fidelity", knowledgeDir: "workspaces/fidelity/project-knowledge")
}
}
private struct ProfileWorkspaceConfig: Decodable {
let profile: String?
let displayName: String?
let knowledgeDir: String?
enum CodingKeys: String, CodingKey {
case profile
case displayName = "display_name"
case knowledgeDir = "knowledge_dir"
}
}
@main @main
struct AIWorkspaceApp: App { struct AIWorkspaceApp: App {
@StateObject private var model = ServiceStatusModel(profile: defaultProfile) @StateObject private var model = ServiceStatusModel()
var body: some Scene { var body: some Scene {
MenuBarExtra { MenuBarExtra {
@@ -30,11 +70,12 @@ final class ServiceStatusModel: ObservableObject {
@Published private(set) var lanIP: String? @Published private(set) var lanIP: String?
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered @Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
@Published private(set) var isRefreshing = false @Published private(set) var isRefreshing = false
@Published private(set) var activeAction: String?
let profile: String let profile: ProfileConfig
init(profile: String) { init() {
self.profile = profile self.profile = ProfileConfig.discoverDefault()
} }
var menuBarSymbol: String { var menuBarSymbol: String {
@@ -52,7 +93,7 @@ final class ServiceStatusModel: ObservableObject {
isRefreshing = true isRefreshing = true
defer { isRefreshing = false } defer { isRefreshing = false }
do { do {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"]) let data = try await ServiceManager.run(["status", "--profile", profile.id, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data) report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP() lanIP = await NetworkInfo.primaryLANIP()
loginItemStatus = SMAppService.mainApp.status loginItemStatus = SMAppService.mainApp.status
@@ -92,25 +133,33 @@ final class ServiceStatusModel: ObservableObject {
} }
func startProfile() { func startProfile() {
runAction(["start", "--profile", profile]) runAction("Starting services…", ["start", "--profile", profile.id])
} }
func stopProfile() { func stopProfile() {
runAction(["stop", "--profile", profile]) runAction("Stopping services…", ["stop", "--profile", profile.id])
}
func primaryServiceAction() {
if allManagedServicesRunning {
stopProfile()
} else {
startProfile()
}
} }
func restartMCP() { func restartMCP() {
runAction(["restart", "aiw-context-mcp", "--profile", profile]) runAction("Restarting MCP…", ["restart", "aiw-context-mcp", "--profile", profile.id])
} }
func runDoctor() { func runDoctor() {
runAction(["doctor", "--profile", profile]) runAction("Running doctor…", ["doctor", "--profile", profile.id])
} }
func copyDoctorJSON() { func copyDoctorJSON() {
Task { Task {
do { do {
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"]) let data = try await ServiceManager.run(["doctor", "--profile", profile.id, "--json"])
copyToPasteboard(String(data: data, encoding: .utf8) ?? "") copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
} catch { } catch {
lastError = String(describing: error) lastError = String(describing: error)
@@ -128,7 +177,7 @@ final class ServiceStatusModel: ObservableObject {
do { do {
var chunks: [String] = [] var chunks: [String] = []
for service in report?.services ?? [] { for service in report?.services ?? [] {
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"]) let data = try await ServiceManager.run(["logs", service.name, "--profile", profile.id, "--lines", "30"])
if let text = String(data: data, encoding: .utf8), !text.isEmpty { if let text = String(data: data, encoding: .utf8), !text.isEmpty {
chunks.append(text) chunks.append(text)
} }
@@ -147,15 +196,33 @@ final class ServiceStatusModel: ObservableObject {
} }
func openLogsFolder() { func openLogsFolder() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true)) NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs/\(profile.id)", isDirectory: true))
} }
func openProjectKnowledge() { func openProjectKnowledge() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true)) NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(profile.knowledgeDir, isDirectory: true))
} }
func openMattermost() { func openMattermost() {
runAction(["start", "mattermost-desktop", "--profile", profile]) runAction("Launching Mattermost…", ["start", "mattermost-desktop", "--profile", profile.id])
}
var allManagedServicesRunning: Bool {
guard let services = report?.services else { return false }
let managed = services.filter { $0.enabled && $0.kind != "app-launcher" }
return !managed.isEmpty && managed.allSatisfy { $0.status == "running" || $0.status == "externally running" }
}
var primaryActionTitle: String {
allManagedServicesRunning ? "Stop Services" : "Start Services"
}
var primaryActionSymbol: String {
allManagedServicesRunning ? "stop.fill" : "play.fill"
}
var isBusy: Bool {
isRefreshing || activeAction != nil
} }
private func copyToPasteboard(_ value: String) { private func copyToPasteboard(_ value: String) {
@@ -163,8 +230,10 @@ final class ServiceStatusModel: ObservableObject {
NSPasteboard.general.setString(value, forType: .string) NSPasteboard.general.setString(value, forType: .string)
} }
private func runAction(_ arguments: [String]) { private func runAction(_ label: String, _ arguments: [String]) {
Task { Task {
activeAction = label
defer { activeAction = nil }
do { do {
_ = try await ServiceManager.run(arguments) _ = try await ServiceManager.run(arguments)
await refresh() await refresh()
@@ -184,20 +253,31 @@ struct ServiceMenuView: View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace") Text("AI Workspace")
.font(.title3.bold()) .font(.title3.bold())
Text("Profile: \(model.profile)") Text("Profile: \(model.profile.displayName)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if model.isBusy {
ProgressView()
.controlSize(.small)
}
Button { Button {
Task { await model.refresh() } Task { await model.refresh() }
} label: { } label: {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.disabled(model.isBusy)
.help("Refresh") .help("Refresh")
} }
if let activeAction = model.activeAction {
Label(activeAction, systemImage: "hourglass")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider() Divider()
if let report = model.report { if let report = model.report {
@@ -231,20 +311,26 @@ struct ServiceMenuView: View {
Divider() Divider()
Button(role: model.allManagedServicesRunning ? .destructive : nil, action: model.primaryServiceAction) {
Label(model.primaryActionTitle, systemImage: model.primaryActionSymbol)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(model.isBusy)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile) ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", disabled: model.isBusy, action: model.restartMCP)
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile) ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", disabled: model.isBusy, action: model.openMattermost)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
} }
Divider() Divider()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor) ActionButton(title: "Run Doctor", systemImage: "stethoscope", disabled: model.isBusy, action: model.runDoctor)
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON) ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", disabled: model.isBusy, action: model.copyDoctorJSON)
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL) ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs) ActionButton(title: "Copy Logs", systemImage: "doc.text", disabled: model.isBusy, action: model.copyRecentLogs)
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth) ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder) ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
} }
@@ -290,6 +376,7 @@ struct ActionButton: View {
let title: String let title: String
let systemImage: String let systemImage: String
var role: ButtonRole? var role: ButtonRole?
var disabled = false
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
@@ -299,6 +386,7 @@ struct ActionButton: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
.disabled(disabled)
} }
} }
@@ -369,6 +457,15 @@ enum ServiceManager {
process.currentDirectoryURL = workspaceRoot process.currentDirectoryURL = workspaceRoot
process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
var environment = ProcessInfo.processInfo.environment
let guiSafePATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
if let existingPATH = environment["PATH"], !existingPATH.isEmpty {
environment["PATH"] = "\(guiSafePATH):\(existingPATH)"
} else {
environment["PATH"] = guiSafePATH
}
environment["AIW_WORKSPACE_ROOT"] = workspaceRoot.path
process.environment = environment
let output = Pipe() let output = Pipe()
let error = Pipe() let error = Pipe()

View File

@@ -38,9 +38,9 @@ Non-sensitive example profile showing how a new project can reuse the workspace
## Context ## Context
Project-specific durable context should live under `project-knowledge/03-context/`. Project-specific durable context should live under `workspaces/example/project-knowledge/03-context/`.
People, decisions, daily notes, maps, and templates should live under the corresponding `project-knowledge/` folders. People, decisions, daily notes, maps, and templates should live under the corresponding `workspaces/example/project-knowledge/` folders.
Do not place company secrets, raw exports, credentials, or private communication transcripts in the profile. Do not place company secrets, raw exports, credentials, or private communication transcripts in the profile.

View File

@@ -155,8 +155,6 @@ def doctor(profile: str, root: Path | None = None) -> dict[str, Any]:
"start_here_exists": (knowledge / "00-start" / "start-here.md").is_file(), "start_here_exists": (knowledge / "00-start" / "start-here.md").is_file(),
"current_work_exists": (knowledge / "01-current" / "current-work.md").is_file(), "current_work_exists": (knowledge / "01-current" / "current-work.md").is_file(),
"work_items_exists": (knowledge / "01-current" / "work-items.md").is_file(), "work_items_exists": (knowledge / "01-current" / "work-items.md").is_file(),
"root_project_knowledge_absent": not (base / "project-knowledge").exists(),
"root_ai_inbox_absent": not (base / "ai" / "inbox").exists(),
} }
return {"profile": profile, "ok": all(checks.values()), "checks": checks, "paths": {"knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base), "index_dir": relative_to_root(index_dir(profile, root=base), root=base)}} return {"profile": profile, "ok": all(checks.values()), "checks": checks, "paths": {"knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base), "index_dir": relative_to_root(index_dir(profile, root=base), root=base)}}

View File

@@ -31,6 +31,16 @@ STATE_DIR = RUNTIME_DIR / "state"
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_LOG_BACKUPS = 3 DEFAULT_LOG_BACKUPS = 3
DEFAULT_STOP_TIMEOUT_SECONDS = 5.0 DEFAULT_STOP_TIMEOUT_SECONDS = 5.0
DEFAULT_SERVICE_PATHS = [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -126,14 +136,35 @@ def resolve_workspace_path(raw: str) -> Path:
return path if path.is_absolute() else ROOT / path return path if path.is_absolute() else ROOT / path
def command_exists(command: str) -> bool: def service_env(profile: str | None = None) -> dict[str, str]:
"""Return a robust environment for services launched from shells or GUI apps.
macOS login items do not reliably inherit the user's interactive shell PATH.
Homebrew-installed tools such as mitmdump commonly live under /opt/homebrew/bin
or /usr/local/bin, so make those paths available to both direct service
commands and nested scripts.
"""
env = os.environ.copy()
existing = [part for part in env.get("PATH", "").split(os.pathsep) if part]
merged: list[str] = []
for part in [*existing, *DEFAULT_SERVICE_PATHS]:
if part not in merged:
merged.append(part)
env["PATH"] = os.pathsep.join(merged)
env.setdefault("AIW_WORKSPACE_ROOT", str(ROOT))
if profile:
env.setdefault("AIW_PROJECT_PROFILE", profile)
return env
def command_exists(command: str, env: dict[str, str] | None = None) -> bool:
if not command: if not command:
return False return False
path = Path(command) path = Path(command)
if path.is_absolute() or "/" in command: if path.is_absolute() or "/" in command:
resolved = resolve_workspace_path(command) resolved = resolve_workspace_path(command)
return resolved.exists() and os.access(resolved, os.X_OK) return resolved.exists() and os.access(resolved, os.X_OK)
return shutil_which(command) is not None return shutil_which(command, env=env) is not None
def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None: def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None:
@@ -273,9 +304,10 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
kind = ref.config.get("kind", "process") kind = ref.config.get("kind", "process")
command = ref.config.get("command") or [] command = ref.config.get("command") or []
env = service_env(profile)
if not command: if not command:
raise SystemExit(f"{ref.name} has no command") raise SystemExit(f"{ref.name} has no command")
if not command_exists(str(command[0])): if not command_exists(str(command[0]), env=env):
raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}") raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}")
if kind != "app-launcher": if kind != "app-launcher":
@@ -298,11 +330,11 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
with path.open("ab") as log_file: with path.open("ab") as log_file:
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8")) log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
if kind == "app-launcher": if kind == "app-launcher":
result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False) result = subprocess.run(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, check=False)
write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()}) write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()})
print(f"{ref.name}: launched (exit {result.returncode})") print(f"{ref.name}: launched (exit {result.returncode})")
else: else:
process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True) process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
pid_file = pid_path(profile, ref.name) pid_file = pid_path(profile, ref.name)
pid_file.parent.mkdir(parents=True, exist_ok=True) pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(str(process.pid) + "\n", encoding="utf-8") pid_file.write_text(str(process.pid) + "\n", encoding="utf-8")
@@ -385,6 +417,7 @@ def tail_log(profile: str, service: str, lines: int) -> None:
def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]: def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
errors = validate_manifest(manifest) errors = validate_manifest(manifest)
env = service_env(profile)
service_reports = [] service_reports = []
for ref in service_items(manifest, include_disabled=True): for ref in service_items(manifest, include_disabled=True):
command = ref.config.get("command") or [] command = ref.config.get("command") or []
@@ -392,15 +425,15 @@ def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
doctor = ref.config.get("doctor") or {} doctor = ref.config.get("doctor") or {}
checks = [] checks = []
for command_name in doctor.get("required_commands") or []: for command_name in doctor.get("required_commands") or []:
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)}) checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name, env=env)})
for command_name in doctor.get("optional_commands") or []: for command_name in doctor.get("optional_commands") or []:
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)}) checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name, env=env)})
for raw_path in doctor.get("required_paths") or []: for raw_path in doctor.get("required_paths") or []:
checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
for raw_path in doctor.get("optional_paths") or []: for raw_path in doctor.get("optional_paths") or []:
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
status = service_status(profile, ref) status = service_status(profile, ref)
status["command_ok"] = command_exists(first) if first else False status["command_ok"] = command_exists(first, env=env) if first else False
status["checks"] = checks status["checks"] = checks
service_reports.append(status) service_reports.append(status)
return { return {
@@ -443,8 +476,8 @@ def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}") print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
def shutil_which(command: str) -> str | None: def shutil_which(command: str, env: dict[str, str] | None = None) -> str | None:
paths = os.environ.get("PATH", "").split(os.pathsep) paths = (env or os.environ).get("PATH", "").split(os.pathsep)
for directory in paths: for directory in paths:
candidate = Path(directory) / command candidate = Path(directory) / command
if candidate.exists() and os.access(candidate, os.X_OK): if candidate.exists() and os.access(candidate, os.X_OK):

View File

@@ -65,17 +65,7 @@ class ProfileTests(unittest.TestCase):
result = profile.doctor("demo", root=root) result = profile.doctor("demo", root=root)
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertTrue(result["checks"]["root_project_knowledge_absent"]) self.assertEqual(result["paths"]["knowledge_dir"], "workspaces/demo/project-knowledge")
def test_doctor_rejects_root_level_project_data_dirs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile.create_profile("demo", root=root)
(root / "project-knowledge").mkdir()
result = profile.doctor("demo", root=root)
self.assertFalse(result["ok"])
self.assertFalse(result["checks"]["root_project_knowledge_absent"])
if __name__ == "__main__": if __name__ == "__main__":