Compare commits

...

3 Commits

32 changed files with 432 additions and 156 deletions

View File

@@ -10,14 +10,13 @@ Inputs:
- `$ARGUMENTS` may contain an export path, channel names, or date filters
- if no explicit path is given, use `AIW_SLACK_EXPORT_PATH` when available
- Fidelity profile alias: `FIDELITY_SLACK_EXPORT_PATH`
- otherwise, if `archives/slack/export/` exists, use it as the default import source
- if no channels are specified, auto-detect channels using `AIW_CHANNEL_PREFIX`
- Fidelity default prefix: `fidelity`
Run the importer:
!`prefix="${AIW_CHANNEL_PREFIX:-fidelity}"; if [ -n "$ARGUMENTS" ]; then python3 scripts/slack/import_slack_export.py $ARGUMENTS; elif [ -n "$AIW_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$AIW_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -n "$FIDELITY_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$FIDELITY_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -d archives/slack/export ]; then python3 scripts/slack/import_slack_export.py --export-path archives/slack/export --channel-prefix "$prefix"; else echo "Provide archive import arguments, set AIW_SLACK_EXPORT_PATH, set FIDELITY_SLACK_EXPORT_PATH, or place an extracted export in archives/slack/export."; fi`
!`prefix="${AIW_CHANNEL_PREFIX:-fidelity}"; if [ -n "$ARGUMENTS" ]; then python3 scripts/slack/import_slack_export.py $ARGUMENTS; elif [ -n "$AIW_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$AIW_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -d archives/slack/export ]; then python3 scripts/slack/import_slack_export.py --export-path archives/slack/export --channel-prefix "$prefix"; else echo "Provide archive import arguments, set AIW_SLACK_EXPORT_PATH, or place an extracted export in archives/slack/export."; fi`
Read:

View File

@@ -7,12 +7,11 @@ Use the configured live communication connector to fetch fresh evidence and main
Preferred command sources:
- `AIW_MATTERMOST_SYNC_CMD`
- Fidelity profile alias: `FIDELITY_MATTERMOST_SYNC_CMD`
- workspace fallback: `bash scripts/mattermost/sync.sh`
- Built-in wrapper: `bash scripts/mattermost/sync.sh`
Run the connector:
!`if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -n "$FIDELITY_MATTERMOST_SYNC_CMD" ]; then bash -lc "$FIDELITY_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No live communication sync command is configured."; fi`
!`if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No live communication sync command is configured."; fi`
Read:
@@ -31,7 +30,7 @@ Fresh communication evidence:
Instructions:
- if the sync command failed, stop and do not edit workspace memory
- prefer local proxy mirror evidence when present; legacy sync output is fallback evidence
- prefer local proxy mirror evidence when present; use the configured sync wrapper only when a fresh API pull is required
- treat connector output as evidence, not automatically as project truth
- promote only explicit, project-relevant, high-confidence facts
- default destination is `workspaces/fidelity/project-knowledge/06-daily/$(date +%F).md`

View File

@@ -2,7 +2,7 @@
description: Force-sync Mattermost and answer from the latest matching message
---
Refresh/read Mattermost first, then answer the user's question from the freshest evidence. Prefer the local proxy mirror when it is present; legacy sync output is fallback evidence.
Refresh/read Mattermost first, then answer the user's question from the freshest evidence. Prefer the local proxy mirror when it is present; use the configured sync wrapper only when a fresh API pull is required.
Use this when the user asks for:
@@ -13,7 +13,7 @@ Use this when the user asks for:
Run sync/fallback refresh:
!`start=$(date +%s); if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -n "$FIDELITY_MATTERMOST_SYNC_CMD" ]; then bash -lc "$FIDELITY_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No Mattermost sync command is configured."; fi; status=$?; end=$(date +%s); echo "__MATTERMOST_SYNC_SECONDS__=$((end - start))"; exit "$status"`
!`start=$(date +%s); if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No Mattermost sync command is configured."; fi; status=$?; end=$(date +%s); echo "__MATTERMOST_SYNC_SECONDS__=$((end - start))"; exit "$status"`
Read a focused slice of refreshed Mattermost context, preferring the proxy mirror:

View File

@@ -7,12 +7,11 @@ Use the configured Mattermost sync command and/or local proxy mirror evidence to
Preferred command sources:
- `AIW_MATTERMOST_SYNC_CMD`
- Fidelity profile alias: `FIDELITY_MATTERMOST_SYNC_CMD`
- fallback: `bash scripts/mattermost/sync.sh`
- Built-in wrapper: `bash scripts/mattermost/sync.sh`
Run the command and use its output as fresh communication context:
!`if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -n "$FIDELITY_MATTERMOST_SYNC_CMD" ]; then bash -lc "$FIDELITY_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No Mattermost sync command is configured."; fi`
!`if [ -n "$AIW_MATTERMOST_SYNC_CMD" ]; then bash -lc "$AIW_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No Mattermost sync command is configured."; fi`
Fresh Mattermost evidence, preferring the proxy mirror:

View File

@@ -10,7 +10,6 @@ Inputs:
- `$ARGUMENTS` may contain an export path, channel names, or date filters
- if no explicit path is given in the arguments, use `AIW_SLACK_EXPORT_PATH` when available
- Fidelity profile alias: `FIDELITY_SLACK_EXPORT_PATH`
- otherwise, if `archives/slack/export/` exists, use it as the default import source
- if no channels are specified, auto-detect channels using `AIW_CHANNEL_PREFIX`
- Fidelity default prefix: `fidelity`
@@ -20,7 +19,7 @@ Inputs:
First, run the importer:
!`prefix="${AIW_CHANNEL_PREFIX:-fidelity}"; if [ -n "$ARGUMENTS" ]; then python3 scripts/slack/import_slack_export.py $ARGUMENTS; elif [ -n "$AIW_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$AIW_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -n "$FIDELITY_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$FIDELITY_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -d archives/slack/export ]; then python3 scripts/slack/import_slack_export.py --export-path archives/slack/export --channel-prefix "$prefix"; else echo "Provide Slack import arguments, set AIW_SLACK_EXPORT_PATH, set FIDELITY_SLACK_EXPORT_PATH, or place an extracted export in archives/slack/export."; fi`
!`prefix="${AIW_CHANNEL_PREFIX:-fidelity}"; if [ -n "$ARGUMENTS" ]; then python3 scripts/slack/import_slack_export.py $ARGUMENTS; elif [ -n "$AIW_SLACK_EXPORT_PATH" ]; then python3 scripts/slack/import_slack_export.py --export-path "$AIW_SLACK_EXPORT_PATH" --channel-prefix "$prefix"; elif [ -d archives/slack/export ]; then python3 scripts/slack/import_slack_export.py --export-path archives/slack/export --channel-prefix "$prefix"; else echo "Provide Slack import arguments, set AIW_SLACK_EXPORT_PATH, or place an extracted export in archives/slack/export."; fi`
Read:

View File

@@ -32,7 +32,7 @@ import subprocess
from pathlib import Path
from datetime import datetime
cmd = os.environ.get("AIW_MATTERMOST_SYNC_CMD") or os.environ.get("FIDELITY_MATTERMOST_SYNC_CMD")
cmd = os.environ.get("AIW_MATTERMOST_SYNC_CMD")
today = datetime.now().astimezone().date().isoformat()
commands = []

View File

@@ -26,7 +26,7 @@ Behavior rules:
- Work item notes should preserve Jira ID/title and explicit relationships so standups, Bases, and graph navigation stay useful.
- Daily notes should include `focus`, `work-items`, and `blockers` when those values are clear.
- Before answering a prompt that depends on current state, verify the latest relevant files instead of relying only on conversation history.
- If the prompt asks for the latest Mattermost message, the last message from Jeff/current manager, or what someone just said, refresh or read the freshest Mattermost evidence before answering; the proxy mirror is the primary source when it is present, and legacy sync artifacts are fallback evidence.
- If the prompt asks for the latest Mattermost message, the last message from Jeff/current manager, or what someone just said, refresh or read the freshest Mattermost evidence before answering; the proxy mirror is the primary source when it is present, and configured sync artifacts are secondary evidence.
- Treat latest-message prompts as read-first: answer from refreshed evidence and report memory update candidates instead of editing canonical memory by default.
- For learning-style questions, answer from known context and verified facts only; explicitly label unknowns, assumptions, and inferences.
- For learning sessions, prioritize durable architecture, process, ownership, debugging strategy, release mechanics, domain concepts, and decision rules over transient ticket status.
@@ -51,7 +51,7 @@ Behavior rules:
- role-to-person mapping and recurring stakeholders go to `workspaces/fidelity/project-knowledge/04-people/`
- confirmed decisions go to `workspaces/fidelity/project-knowledge/05-decisions/`
- behavioral rules for how this workspace should respond go to the exact command, prompt, agent, skill, or `agent-memory/` file that enforces that behavior
- Use generic `AIW_*` integration variables for new tooling and keep `FIDELITY_*` only as Fidelity-profile aliases.
- Use generic `AIW_*` integration variables for workspace tooling.
- Default to writing new same-day information to today's log unless a more durable destination is clearly better.
- Write canonical memory to `workspaces/fidelity/project-knowledge/`.
- Update preexisting memory when a new prompt clarifies or corrects something already stored.

View File

@@ -20,9 +20,7 @@ function envFlag(name, defaultValue = false) {
}
async function resolveSyncCommand(directory) {
const configured =
process.env.AIW_MATTERMOST_SYNC_CMD?.trim() ||
process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim()
const configured = process.env.AIW_MATTERMOST_SYNC_CMD?.trim()
if (configured) return configured
const fallbackScript = path.join(directory, "scripts/mattermost/sync.sh")
@@ -116,7 +114,6 @@ function requiresFreshMattermost(promptText) {
process.env.AIW_COMMUNICATION_FRESH_TERMS,
process.env.AIW_MANAGER_NAME,
process.env.AIW_PRIMARY_STAKEHOLDER,
process.env.FIDELITY_MANAGER_NAME,
"jeff",
"fidelity-preguntas",
]
@@ -148,7 +145,6 @@ export const MattermostInbox = async ({ $, directory, client }) => {
const intervalMinutes = Number.parseInt(
process.env.AIW_MATTERMOST_SYNC_INTERVAL_MINUTES ||
process.env.FIDELITY_MATTERMOST_SYNC_INTERVAL_MINUTES ||
"15",
10,
)
@@ -165,9 +161,7 @@ export const MattermostInbox = async ({ $, directory, client }) => {
const commandSource = process.env.AIW_MATTERMOST_SYNC_CMD?.trim()
? "aiw-env"
: process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim()
? "fidelity-env"
: "workspace-default"
: "workspace-default"
try {
await mkdir(inboxDir, { recursive: true })

View File

@@ -190,12 +190,22 @@ python3 scripts/iphone-photo-inbox/test_receiver.py
Recommended order for new projects:
1. Copy `profiles/example/` to a new profile.
2. Create or point to a project knowledge vault.
3. Configure only the services the project needs.
4. Keep raw evidence outside canonical memory.
5. Build the local index.
6. Connect AI clients through MCP.
7. Promote durable facts into Markdown as work progresses.
1. Create an isolated profile:
```bash
python3 scripts/aiw/profile.py create my-project --display-name "My Project"
```
2. Configure only the services the project needs.
3. Keep raw evidence outside canonical memory.
4. Build the local index.
5. Connect AI clients through MCP.
6. Promote durable facts into Markdown as work progresses.
Validate the profile layout with:
```bash
python3 scripts/aiw/profile.py doctor --profile my-project
```
The reusable core should not depend on a company name, ticket prefix, channel name, programming stack, or AI client.

View File

@@ -19,7 +19,7 @@ Mattermost is the current live communication connector.
- Primary local evidence is the Mattermost proxy mirror under `workspaces/fidelity/inbox/mattermost-mirror/` when present.
- Prefer `workspaces/fidelity/inbox/mattermost-mirror/latest.md` / `latest.jsonl` for latest-message context, `by-date/YYYY/MM/YYYY-MM-DD.jsonl` for daily/standup context, `channels/<channel>/YYYY/MM/YYYY-MM-DD.jsonl` for channel-specific context, and `threads/<root-or-post-id>.jsonl` for thread-specific context.
- Use `scripts/mattermost-proxy/read-context.py` from commands/workflows instead of reading ad hoc files; it prefers the proxy mirror and falls back to legacy sync artifacts.
- Use `scripts/mattermost-proxy/read-context.py` from commands/workflows instead of reading ad hoc files; it prefers the profile proxy mirror and can read configured sync artifacts when a mirror is unavailable.
- Legacy fresh output may still go to `workspaces/fidelity/inbox/mattermost-latest.md`.
- Legacy generated extraction artifacts stay under `scripts/mattermost/generated/`.
- Failed syncs must not update project knowledge.
@@ -29,12 +29,12 @@ Mattermost is the current live communication connector.
- Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code.
- If adding MCP support for Mattermost, treat it as a read-only query wrapper over the existing proxy mirror and `read-context.py`, not as a replacement for the capture/mirror pipeline. Keep the mirror's file layout as canonical raw evidence and expose only narrow tools such as latest, standup/date, channel, and thread reads with channel filters and limits.
- Do not build a write-capable Mattermost MCP or expose tokens, cookies, raw headers, or broad unfiltered raw dumps through MCP. MCP output should remain evidence for agent reasoning; promotion to `workspaces/fidelity/project-knowledge/` still follows normal memory rules.
- If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran.
- If the proxy mirror is running, treat it as fresher than generated sync artifacts. Do not ignore mirror evidence merely because a sync command also ran.
- Do not refresh Mattermost just because a prompt mentions a manager or stakeholder.
- Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence.
- The OpenCode plugin syncs automatically only for explicit latest-message requests by default.
- Optional aggressive sync can be enabled with `AIW_MATTERMOST_SYNC_ON_SESSION=true` or `AIW_MATTERMOST_SYNC_ON_PROMPT=true`, but these should stay off for low-latency daily use.
- When invoking Mattermost sync from OpenCode, do not use parameter expansion that places a command with spaces into a single shell word, such as `${VAR:-bash scripts/mattermost/sync.sh}`. Run configured command strings via `bash -lc "$AIW_MATTERMOST_SYNC_CMD"` / `bash -lc "$FIDELITY_MATTERMOST_SYNC_CMD"`, and run the fallback as separate words: `bash scripts/mattermost/sync.sh`.
- When invoking Mattermost sync from OpenCode, do not use parameter expansion that places a command with spaces into a single shell word, such as `${VAR:-bash scripts/mattermost/sync.sh}`. Run configured command strings via `bash -lc "$AIW_MATTERMOST_SYNC_CMD"`, and run the built-in wrapper as separate words: `bash scripts/mattermost/sync.sh`.
---

View File

@@ -17,18 +17,14 @@ Use `scripts/memory/memory.sh` as the project-agnostic interface to project know
The primary project knowledge directory is `workspaces/fidelity/project-knowledge/`.
Environment variable precedence:
Configuration precedence:
1. `AIW_PROJECT_KNOWLEDGE_DIR`
2. `AIW_MEMORY_VAULT_DIR`
3. `AIW_OBSIDIAN_VAULT_DIR`
4. `<workspace-root>/project-knowledge`
`AIW_MEMORY_VAULT_DIR` and `AIW_OBSIDIAN_VAULT_DIR` are transition aliases only.
1. `AIW_PROJECT_PROFILE`
2. `profiles/<profile>/workspace.json`
3. optional local `AIW_PROJECT_KNOWLEDGE_DIR` override
---
## Agent Rule
Prefer the memory interface for typed note creation, search, Base queries, and health checks. Use direct Markdown edits when the adapter fails or when precise edits are simpler and safer.

View File

@@ -28,6 +28,21 @@ Install to `~/Applications/AIWorkspace.app`:
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
```bash

View File

@@ -4,11 +4,51 @@ import ServiceManagement
import SwiftUI
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
struct AIWorkspaceApp: App {
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
@StateObject private var model = ServiceStatusModel()
var body: some Scene {
MenuBarExtra {
@@ -30,11 +70,12 @@ final class ServiceStatusModel: ObservableObject {
@Published private(set) var lanIP: String?
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
@Published private(set) var isRefreshing = false
@Published private(set) var activeAction: String?
let profile: String
let profile: ProfileConfig
init(profile: String) {
self.profile = profile
init() {
self.profile = ProfileConfig.discoverDefault()
}
var menuBarSymbol: String {
@@ -52,7 +93,7 @@ final class ServiceStatusModel: ObservableObject {
isRefreshing = true
defer { isRefreshing = false }
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)
lanIP = await NetworkInfo.primaryLANIP()
loginItemStatus = SMAppService.mainApp.status
@@ -92,25 +133,33 @@ final class ServiceStatusModel: ObservableObject {
}
func startProfile() {
runAction(["start", "--profile", profile])
runAction("Starting services…", ["start", "--profile", profile.id])
}
func stopProfile() {
runAction(["stop", "--profile", profile])
runAction("Stopping services…", ["stop", "--profile", profile.id])
}
func primaryServiceAction() {
if allManagedServicesRunning {
stopProfile()
} else {
startProfile()
}
}
func restartMCP() {
runAction(["restart", "aiw-context-mcp", "--profile", profile])
runAction("Restarting MCP…", ["restart", "aiw-context-mcp", "--profile", profile.id])
}
func runDoctor() {
runAction(["doctor", "--profile", profile])
runAction("Running doctor…", ["doctor", "--profile", profile.id])
}
func copyDoctorJSON() {
Task {
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) ?? "")
} catch {
lastError = String(describing: error)
@@ -128,7 +177,7 @@ final class ServiceStatusModel: ObservableObject {
do {
var chunks: [String] = []
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 {
chunks.append(text)
}
@@ -147,15 +196,33 @@ final class ServiceStatusModel: ObservableObject {
}
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() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(profile.knowledgeDir, isDirectory: true))
}
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) {
@@ -163,8 +230,10 @@ final class ServiceStatusModel: ObservableObject {
NSPasteboard.general.setString(value, forType: .string)
}
private func runAction(_ arguments: [String]) {
private func runAction(_ label: String, _ arguments: [String]) {
Task {
activeAction = label
defer { activeAction = nil }
do {
_ = try await ServiceManager.run(arguments)
await refresh()
@@ -184,20 +253,31 @@ struct ServiceMenuView: View {
VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace")
.font(.title3.bold())
Text("Profile: \(model.profile)")
Text("Profile: \(model.profile.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if model.isBusy {
ProgressView()
.controlSize(.small)
}
Button {
Task { await model.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.disabled(model.isBusy)
.help("Refresh")
}
if let activeAction = model.activeAction {
Label(activeAction, systemImage: "hourglass")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
if let report = model.report {
@@ -231,20 +311,26 @@ struct ServiceMenuView: View {
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) {
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", disabled: model.isBusy, action: model.restartMCP)
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", disabled: model.isBusy, action: model.openMattermost)
}
Divider()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
ActionButton(title: "Run Doctor", systemImage: "stethoscope", disabled: model.isBusy, action: model.runDoctor)
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 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: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
}
@@ -290,6 +376,7 @@ struct ActionButton: View {
let title: String
let systemImage: String
var role: ButtonRole?
var disabled = false
let action: () -> Void
var body: some View {
@@ -299,6 +386,7 @@ struct ActionButton: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(disabled)
}
}
@@ -369,6 +457,15 @@ enum ServiceManager {
process.currentDirectoryURL = workspaceRoot
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
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 error = Pipe()

View File

@@ -12,7 +12,7 @@ Obsidian is the current human-facing implementation, but the workspace should no
### Canonical Memory
The source of truth is plain Markdown under `project-knowledge/`.
The source of truth is plain Markdown under `workspaces/<profile>/project-knowledge/`.
Agents should edit canonical notes directly when precision matters because direct edits produce auditable diffs.
@@ -31,16 +31,16 @@ Use `scripts/memory/memory.sh` for project-knowledge operations:
The current adapter is Obsidian CLI through `scripts/obsidian/cli.sh`.
If Obsidian CLI is unavailable or fails, `scripts/memory/memory.sh` falls back to direct Markdown operations unless `AIW_MEMORY_BACKEND=obsidian` is explicitly set.
If Obsidian CLI is unavailable or fails, `scripts/memory/memory.sh` uses direct Markdown operations unless `AIW_MEMORY_BACKEND=obsidian` is explicitly set.
---
## Configuration
- `AIW_PROJECT_KNOWLEDGE_DIR`: canonical project knowledge directory. Defaults to `<workspace-root>/project-knowledge`.
- `AIW_MEMORY_VAULT_DIR`: transition alias for the canonical project knowledge directory.
- `AIW_PROJECT_PROFILE`: selected profile. Defaults to `fidelity` in this workspace.
- `profiles/<profile>/workspace.json`: canonical source for `knowledge_dir`, `inbox_dir`, and `index_dir`.
- `AIW_PROJECT_KNOWLEDGE_DIR`: optional local override for the canonical project knowledge directory.
- `AIW_MEMORY_BACKEND`: `auto`, `files`, or `obsidian`. Defaults to `auto`.
- `AIW_OBSIDIAN_VAULT_DIR`: Obsidian-specific transition alias, still supported by the adapter.
- `AIW_OBSIDIAN_VAULT_NAME`: Obsidian URI vault name override for URI wrappers.
Backend meanings:
@@ -55,15 +55,15 @@ Backend meanings:
The memory interface owns type-to-folder routing:
- `daily` -> `project-knowledge/06-daily/`
- `work-item` -> `project-knowledge/02-work-items/`
- `person` -> `project-knowledge/04-people/`
- `decision` -> `project-knowledge/05-decisions/`
- `system` -> `project-knowledge/03-context/systems/`
- `workstream` -> `project-knowledge/03-context/workstreams/`
- `meeting-note` -> `project-knowledge/06-daily/`
- `daily` -> `workspaces/<profile>/project-knowledge/06-daily/`
- `work-item` -> `workspaces/<profile>/project-knowledge/02-work-items/`
- `person` -> `workspaces/<profile>/project-knowledge/04-people/`
- `decision` -> `workspaces/<profile>/project-knowledge/05-decisions/`
- `system` -> `workspaces/<profile>/project-knowledge/03-context/systems/`
- `workstream` -> `workspaces/<profile>/project-knowledge/03-context/workstreams/`
- `meeting-note` -> `workspaces/<profile>/project-knowledge/06-daily/`
Templates live in `project-knowledge/09-templates/`.
Templates live in `workspaces/<profile>/project-knowledge/09-templates/`.
This gives agents an automatic destination for new notes without coupling the rule to Obsidian.
@@ -77,7 +77,7 @@ This gives agents an automatic destination for new notes without coupling the ru
- Edit Markdown directly for precise memory curation, corrections, and content updates.
- Do not treat Obsidian CLI failure as project context.
- Do not require Obsidian to be running for core workspace operation.
- Keep raw evidence outside `project-knowledge/`; promote only curated memory.
- Keep raw evidence outside `workspaces/<profile>/project-knowledge/`; promote only curated memory.
---

View File

@@ -10,10 +10,10 @@ Obsidian should not become a second memory store.
## Recommended Vault
Open the `project-knowledge/` folder as the Obsidian vault:
Open the `workspaces/<profile>/project-knowledge/` folder as the Obsidian vault:
```text
<workspace-root>/project-knowledge/
<workspace-root>/workspaces/<profile>/project-knowledge/
```
This keeps one source of truth:
@@ -30,14 +30,14 @@ This keeps one source of truth:
Canonical project knowledge lives in:
- `project-knowledge/00-start/`
- `project-knowledge/01-current/`
- `project-knowledge/02-work-items/`
- `project-knowledge/03-context/`
- `project-knowledge/04-people/`
- `project-knowledge/05-decisions/`
- `project-knowledge/06-daily/`
- `project-knowledge/07-maps/`
- `workspaces/<profile>/project-knowledge/00-start/`
- `workspaces/<profile>/project-knowledge/01-current/`
- `workspaces/<profile>/project-knowledge/02-work-items/`
- `workspaces/<profile>/project-knowledge/03-context/`
- `workspaces/<profile>/project-knowledge/04-people/`
- `workspaces/<profile>/project-knowledge/05-decisions/`
- `workspaces/<profile>/project-knowledge/06-daily/`
- `workspaces/<profile>/project-knowledge/07-maps/`
Technical runtime remains outside the vault:
@@ -58,21 +58,21 @@ Communication evidence may exist under `workspaces/<profile>/inbox/` or connecto
Version portable Obsidian configuration only when it improves the workspace for every clone:
- `project-knowledge/.obsidian/app.json`
- `project-knowledge/.obsidian/core-plugins.json`
- `project-knowledge/.obsidian/graph.json`
- `project-knowledge/.obsidian/appearance.json`
- `project-knowledge/.obsidian/daily-notes.json`
- `project-knowledge/.obsidian/templates.json`
- `project-knowledge/.obsidian/bookmarks.json`
- `workspaces/<profile>/project-knowledge/.obsidian/app.json`
- `workspaces/<profile>/project-knowledge/.obsidian/core-plugins.json`
- `workspaces/<profile>/project-knowledge/.obsidian/graph.json`
- `workspaces/<profile>/project-knowledge/.obsidian/appearance.json`
- `workspaces/<profile>/project-knowledge/.obsidian/daily-notes.json`
- `workspaces/<profile>/project-knowledge/.obsidian/templates.json`
- `workspaces/<profile>/project-knowledge/.obsidian/bookmarks.json`
Do not version local runtime state:
- `project-knowledge/.obsidian/workspace*.json`
- `project-knowledge/.obsidian/workspace-mobile*.json`
- `project-knowledge/.obsidian/plugins/`
- `project-knowledge/.obsidian/snippets/`
- `project-knowledge/.obsidian/cache/`
- `workspaces/<profile>/project-knowledge/.obsidian/workspace*.json`
- `workspaces/<profile>/project-knowledge/.obsidian/workspace-mobile*.json`
- `workspaces/<profile>/project-knowledge/.obsidian/plugins/`
- `workspaces/<profile>/project-knowledge/.obsidian/snippets/`
- `workspaces/<profile>/project-knowledge/.obsidian/cache/`
Recommended graph and search exclusions:
@@ -107,7 +107,7 @@ The agent should not treat Obsidian runtime layout changes as project context.
If Obsidian metadata or properties are added, use them selectively for high-value notes such as work items, decisions, and index pages. Do not mass-convert existing files just to add metadata.
Use map notes under `project-knowledge/07-maps/` as graph hubs. This keeps the graph navigable without forcing every file into Obsidian-specific wiki-link syntax.
Use map notes under `workspaces/<profile>/project-knowledge/07-maps/` as graph hubs. This keeps the graph navigable without forcing every file into Obsidian-specific wiki-link syntax.
---

View File

@@ -87,7 +87,7 @@ core/**/*.md
scripts/**/*.md
```
Raw evidence stays outside `project-knowledge/`; promoted memory is written to `project-knowledge/`.
Raw evidence stays outside `workspaces/<profile>/project-knowledge/`; promoted memory is written to `workspaces/<profile>/project-knowledge/`.
---
@@ -178,7 +178,7 @@ The agent should also identify recurring quality gaps proactively. If an answer
For technical topics, recurring gaps often belong in:
- domain-specific guidance under `project-knowledge/03-context/`
- domain-specific guidance under `workspaces/<profile>/project-knowledge/03-context/`
- reusable skills under `.agents/skills/`
- agent behavior rules under `.opencode/agents/`, `AGENTS.md`, or `agent-memory/`
- source anchors for official/current documentation

View File

@@ -48,7 +48,7 @@ project-knowledge/09-templates/
Keep project-specific facts out of `core/`.
Treat `project-knowledge/` as canonical project memory. Keep connector inboxes and generated evidence outside the project knowledge vault.
Treat `workspaces/<profile>/project-knowledge/` as canonical project memory. Keep connector inboxes and generated evidence outside the project knowledge vault.
---
@@ -102,15 +102,15 @@ Before using the workspace for real work:
## 6. Obsidian Vault
Open `project-knowledge/` as the Obsidian vault.
Open `workspaces/<profile>/project-knowledge/` as the Obsidian vault.
Use `scripts/memory/` as the project-agnostic memory interface. Obsidian is the default visual and CLI-backed adapter, but profile logic should not depend on Obsidian.
Recommended rules:
- keep `project-knowledge/` as the clean canonical project knowledge
- keep runtime evidence, scripts, profiles, and generated files outside `project-knowledge/`
- version only portable `project-knowledge/.obsidian` configuration
- keep `workspaces/<profile>/project-knowledge/` as the clean canonical project knowledge
- keep runtime evidence, scripts, profiles, and generated files outside `workspaces/<profile>/project-knowledge/`
- version only portable `workspaces/<profile>/project-knowledge/.obsidian` configuration
- ignore local Obsidian workspace state and plugin runtime files
- create or update map notes under `project-knowledge/07-maps/` for human navigation
- create Bases under `project-knowledge/08-bases/` using simple `type` properties
- create or update map notes under `workspaces/<profile>/project-knowledge/07-maps/` for human navigation
- create Bases under `workspaces/<profile>/project-knowledge/08-bases/` using simple `type` properties

View File

@@ -12,7 +12,7 @@ tags:
## Goal
Add retrieval over canonical workspace memory without replacing the human-readable `project-knowledge/` vault.
Add retrieval over canonical workspace memory without replacing the human-readable profile project knowledge vault.
The local index is derived and disposable. If the index disagrees with Markdown, the Markdown wins.
@@ -29,7 +29,7 @@ scripts/aiw/indexer.py
It reads:
```text
project-knowledge/**/*.md
workspaces/<profile>/project-knowledge/**/*.md
```
and writes:
@@ -42,7 +42,7 @@ and writes:
It skips:
```text
project-knowledge/09-templates/
workspaces/<profile>/project-knowledge/09-templates/
```
so Obsidian templates do not appear as real memory.

View File

@@ -7,7 +7,7 @@ The AI Workspace should unify local service lifecycle without collapsing service
- Service manager: starts, stops, checks, and logs local services.
- Context MCP: exposes bounded read-only context to AI clients.
- Capture services: produce local evidence such as Mattermost mirror records or photo inbox files.
- Canonical memory remains under `project-knowledge/` and is updated by the agent using memory rules.
- Canonical memory remains under `workspaces/<profile>/project-knowledge/` and is updated by the agent using memory rules.
## Service Types

View File

@@ -30,6 +30,13 @@ python3 scripts/aiw/indexer.py build --profile fidelity
The `fidelity` profile is the first real project profile in this repo. New projects should follow the same shape but keep their own profile configuration and project memory isolated.
Create a new isolated profile with:
```bash
python3 scripts/aiw/profile.py create my-project --display-name "My Project"
python3 scripts/aiw/profile.py doctor --profile my-project
```
## Daily Use
1. Open the project knowledge vault in Obsidian or your Markdown editor.

View File

@@ -77,13 +77,23 @@ Source filters for profile-bounded reads. For example, a Mattermost profile can
## Adding A New Project
1. Copy `profiles/example/` to `profiles/<new-project>/`.
2. Create or point to a project knowledge vault.
3. Define services only for integrations the project actually uses.
4. Put connector secrets in ignored `.env` files.
1. Create the isolated profile and workspace:
```bash
python3 scripts/aiw/profile.py create my-project --display-name "My Project"
```
2. Define services only for integrations the project actually uses.
3. Put connector secrets in ignored `.env` files.
4. Validate the layout:
```bash
python3 scripts/aiw/profile.py doctor --profile my-project
```
5. Build the local index.
6. Connect AI clients through the MCP server.
## Migration Rule
Reusable code should accept a `--profile` argument and resolve paths through profile configuration. Avoid adding new hardcoded references to Fidelity, channel names, ticket prefixes, or company-specific folders in generic scripts.
Reusable code should accept a `--profile` argument and resolve paths through profile configuration. Avoid adding hardcoded references to Fidelity, channel names, ticket prefixes, or company-specific folders in generic scripts.

View File

@@ -38,9 +38,9 @@ Non-sensitive example profile showing how a new project can reuse the workspace
## 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.

View File

@@ -26,13 +26,7 @@ It keeps Fidelity-specific context, integrations, commands, and skills separate
- Current high-signal channel: `fidelity-preguntas`
- Focused Mattermost context for standups/latest project reads should come from configured profile/environment channels, not hardcoded connector defaults. For this profile, the useful context-channel set is currently `fidelity-preguntas`, `fidelity-standup`, `fidelity-code-review`, `fidelity-interface-meetings-on-calendar-outlook-team-etc`, and `dm-david--jeff`; keep that list in local `.env` as `AIW_MATTERMOST_CONTEXT_CHANNELS` or an equivalent profile setup when using the reusable Mattermost reader.
Compatibility environment variables:
- `FIDELITY_MATTERMOST_SYNC_CMD`
- `FIDELITY_MATTERMOST_SYNC_INTERVAL_MINUTES`
- `FIDELITY_SLACK_EXPORT_PATH`
Generic variables should be preferred for new setup:
Generic workspace variables:
- `AIW_PROJECT_KNOWLEDGE_DIR`
- `AIW_MATTERMOST_SYNC_CMD`

View File

@@ -8,7 +8,7 @@ Generate a standup update for the active project profile.
## Required refresh
- At the start of the day, fetch or read refreshed Mattermost evidence before drafting. Prefer the local proxy mirror through `scripts/mattermost-proxy/read-context.py` when it exists; legacy sync output is fallback evidence.
- At the start of the day, fetch or read refreshed Mattermost evidence before drafting. Prefer the local proxy mirror through `scripts/mattermost-proxy/read-context.py` when it exists; use configured sync output only when a fresh API pull is required.
- Fetch focused standup evidence with `python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`; this reads previous-workday and today records from date-bucketed mirror files and should filter through the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Do not read broad `latest.md` for standups unless the focused date-bucketed view is unavailable and you explicitly label the fallback as broad/noisy.
- If Mattermost refresh fails, say so internally and use only saved workspace memory with clear caution; do not invent fresher context.
- Do not skip communication refresh for standup just to reduce latency, because stale standups cost more time to correct later.

View File

@@ -62,10 +62,9 @@ bash scripts/mattermost/bootstrap.sh
The current Mattermost extractor is stdlib-only and does not require installing `requests`.
It also supports readable channel names in `.env`, not only channel IDs.
If you still want to override it with another script, expose that to OpenCode with:
If you want to override it with another script, expose that to OpenCode with:
- `AIW_MATTERMOST_SYNC_CMD`
- `FIDELITY_MATTERMOST_SYNC_CMD`
Example:
@@ -79,7 +78,7 @@ Expected behavior:
- avoid interactive prompts
- return a non-zero exit code on failure
OpenCode can then use that output to refresh the active profile inbox proactively. When the local Mattermost proxy mirror is running, commands should prefer `<profile inbox>/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py --profile <profile>` and use legacy sync output only as fallback evidence.
OpenCode can then use that output to refresh the active profile inbox proactively. When the local Mattermost proxy mirror is running, commands should prefer `<profile inbox>/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py --profile <profile>`.
Historical Slack exports can also be imported through:

View File

@@ -62,7 +62,14 @@ Current fields:
}
```
Use `scripts/aiw/profile.py` from new scripts instead of hardcoding root-level project memory or inbox paths.
Use `scripts/aiw/profile.py` from new scripts instead of hardcoding project memory or inbox paths.
Create and validate an isolated profile:
```bash
python3 scripts/aiw/profile.py create my-project --display-name "My Project"
python3 scripts/aiw/profile.py doctor --profile my-project
```
## Robustness features

View File

@@ -2,13 +2,14 @@
"""Profile path resolution for AI Workspace scripts.
Profiles own their configuration. Reusable scripts should call this module
instead of hardcoding root-level project paths.
instead of hardcoding project paths.
"""
from __future__ import annotations
import json
import argparse
import re
from pathlib import Path
from typing import Any
@@ -22,6 +23,23 @@ DEFAULT_WORKSPACE = {
"index_dir": ".aiw/indexes/{profile}",
}
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
KNOWLEDGE_DIRS = [
"00-start",
"01-current",
"02-work-items",
"03-context/process",
"03-context/systems",
"03-context/workstreams",
"04-people",
"05-decisions",
"06-daily",
"07-maps",
"08-bases",
"09-templates",
"attachments",
]
def workspace_config_path(profile: str, root: Path | None = None) -> Path:
base = root or ROOT
@@ -73,6 +91,74 @@ def relative_to_root(path: Path, root: Path | None = None) -> str:
return str(path)
def validate_profile_name(profile: str) -> None:
if not PROFILE_RE.match(profile):
raise SystemExit("profile must be lowercase letters, numbers, dashes, or underscores")
def write_file_once(path: Path, text: str) -> None:
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def create_profile(profile: str, display_name: str | None = None, root: Path | None = None) -> dict[str, Any]:
validate_profile_name(profile)
base = root or ROOT
display = display_name or profile.replace("-", " ").replace("_", " ").title()
profile_dir = base / "profiles" / profile
knowledge = knowledge_dir(profile, root=base)
inbox = inbox_dir(profile, root=base)
index = index_dir(profile, root=base)
if profile_dir.exists() or knowledge.exists() or inbox.exists():
raise SystemExit(f"profile already exists or has data directories: {profile}")
profile_dir.mkdir(parents=True)
write_file_once(profile_dir / "profile.md", f"# {display} Profile\n\n## Purpose\n\nDescribe how this project uses AI Workspace.\n\n## Project\n\n- Name: {display}\n- Workspace role: companion AI workspace\n\n## Communication Sources\n\n- Live communication: configure if needed\n- Historical archive: optional\n\n## Work System\n\n- Use the profile project knowledge vault for work items and current state.\n")
write_file_once(profile_dir / "workspace.json", json.dumps({
"profile": profile,
"display_name": display,
"description": f"AI Workspace profile for {display}.",
"knowledge_dir": relative_to_root(knowledge, root=base),
"inbox_dir": relative_to_root(inbox, root=base),
"index_dir": relative_to_root(index, root=base),
}, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
write_file_once(profile_dir / "services.json", json.dumps({"profile": profile, "description": f"Local services for {display}.", "services": {}}, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
write_file_once(profile_dir / "context-sources.json", json.dumps({"communication_sources": {}}, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
for folder in KNOWLEDGE_DIRS:
(knowledge / folder).mkdir(parents=True, exist_ok=True)
inbox.mkdir(parents=True, exist_ok=True)
write_file_once(knowledge / "00-start" / "start-here.md", f"# {display} Start Here\n\nUse this note as the entry point for the {display} project knowledge vault.\n")
write_file_once(knowledge / "01-current" / "current-work.md", "# Current Work\n\n## Focus\n\n- TBD\n")
write_file_once(knowledge / "01-current" / "work-items.md", "# Work Items\n\nNo active work items recorded yet.\n")
write_file_once(knowledge / "03-context" / "project.md", f"# {display} Project Context\n\nDurable project context goes here.\n")
write_file_once(knowledge / "04-people" / "index.md", "# People\n\nProject people and role mappings go here.\n")
write_file_once(knowledge / "07-maps" / "index.md", "# Maps\n\nNavigation maps go here.\n")
write_file_once(knowledge / "09-templates" / "work-item.md", "---\ntype: work-item\ntitle: {{title}}\nupdated: {{date}}\n---\n\n# {{title}}\n")
write_file_once(knowledge / "09-templates" / "daily.md", "---\ntype: daily\ndate: {{date}}\nupdated: {{date}}\n---\n\n# {{date}}\n")
write_file_once(inbox / "README.md", f"# {display} Inbox\n\nRaw evidence for this profile. Promote durable facts into the project knowledge vault.\n")
return {"profile": profile, "profile_dir": relative_to_root(profile_dir, root=base), "knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base)}
def doctor(profile: str, root: Path | None = None) -> dict[str, Any]:
base = root or ROOT
config_path = workspace_config_path(profile, root=base)
knowledge = knowledge_dir(profile, root=base)
inbox = inbox_dir(profile, root=base)
checks = {
"profile_config_exists": config_path.is_file(),
"knowledge_dir_exists": knowledge.is_dir(),
"inbox_dir_exists": inbox.is_dir(),
"start_here_exists": (knowledge / "00-start" / "start-here.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(),
}
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)}}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -84,6 +170,13 @@ def main() -> None:
config_parser = subparsers.add_parser("config", help="Print resolved workspace configuration as JSON.")
config_parser.add_argument("--profile", default="fidelity")
create_parser = subparsers.add_parser("create", help="Create a new isolated project profile.")
create_parser.add_argument("profile")
create_parser.add_argument("--display-name", default="")
doctor_parser = subparsers.add_parser("doctor", help="Validate profile workspace layout.")
doctor_parser.add_argument("--profile", default="fidelity")
args = parser.parse_args()
if args.command == "path":
if args.kind == "knowledge":
@@ -94,6 +187,15 @@ def main() -> None:
print(index_dir(args.profile))
return
if args.command == "create":
print(json.dumps(create_profile(args.profile, args.display_name or None), ensure_ascii=False, indent=2, sort_keys=True))
return
if args.command == "doctor":
payload = doctor(args.profile)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
raise SystemExit(0 if payload["ok"] else 1)
config = load_workspace_config(args.profile)
config["resolved"] = {
"knowledge_dir": str(knowledge_dir(args.profile)),

View File

@@ -31,6 +31,16 @@ STATE_DIR = RUNTIME_DIR / "state"
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_LOG_BACKUPS = 3
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)
@@ -126,14 +136,35 @@ def resolve_workspace_path(raw: str) -> 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:
return False
path = Path(command)
if path.is_absolute() or "/" in command:
resolved = resolve_workspace_path(command)
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:
@@ -273,9 +304,10 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
kind = ref.config.get("kind", "process")
command = ref.config.get("command") or []
env = service_env(profile)
if not 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]}")
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:
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
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()})
print(f"{ref.name}: launched (exit {result.returncode})")
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.parent.mkdir(parents=True, exist_ok=True)
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]:
errors = validate_manifest(manifest)
env = service_env(profile)
service_reports = []
for ref in service_items(manifest, include_disabled=True):
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 {}
checks = []
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 []:
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 []:
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 []:
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
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
service_reports.append(status)
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'}")
def shutil_which(command: str) -> str | None:
paths = os.environ.get("PATH", "").split(os.pathsep)
def shutil_which(command: str, env: dict[str, str] | None = None) -> str | None:
paths = (env or os.environ).get("PATH", "").split(os.pathsep)
for directory in paths:
candidate = Path(directory) / command
if candidate.exists() and os.access(candidate, os.X_OK):

View File

@@ -48,6 +48,25 @@ class ProfileTests(unittest.TestCase):
self.assertEqual(profile.relative_to_root(root / "a" / "b", root=root), "a/b")
self.assertEqual(profile.relative_to_root(Path("/external/path"), root=root), "/external/path")
def test_create_profile_writes_isolated_layout(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
result = profile.create_profile("demo-project", "Demo Project", root=root)
self.assertEqual(result["knowledge_dir"], "workspaces/demo-project/project-knowledge")
self.assertTrue((root / "profiles" / "demo-project" / "workspace.json").is_file())
self.assertTrue((root / "workspaces" / "demo-project" / "project-knowledge" / "00-start" / "start-here.md").is_file())
self.assertTrue((root / "workspaces" / "demo-project" / "inbox" / "README.md").is_file())
def test_doctor_reports_clean_layout(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile.create_profile("demo", root=root)
result = profile.doctor("demo", root=root)
self.assertTrue(result["ok"])
self.assertEqual(result["paths"]["knowledge_dir"], "workspaces/demo/project-knowledge")
if __name__ == "__main__":
unittest.main()

View File

@@ -149,7 +149,7 @@ def mode_latest() -> None:
records = read_jsonl(MIRROR_DIR / "latest.jsonl")
if print_jsonl(records):
return
print("No proxy mirror latest context available; falling back to legacy sync artifacts.")
print("No proxy mirror latest context available; checking configured sync artifacts.")
fallback()

View File

@@ -11,9 +11,9 @@ AIW_CHANNEL_PREFIX=fidelity
# MATTERMOST_TEAM_NAME=fidelity
# MATTERMOST_TEAM_ID=team_id_here
# Legacy Fidelity-specific options still supported by workspace plugins:
# FIDELITY_MATTERMOST_SYNC_CMD=bash scripts/mattermost/sync.sh
# FIDELITY_MATTERMOST_SYNC_INTERVAL_MINUTES=15
# FIDELITY_MANAGER_NAME=jeff
# AIW_MATTERMOST_SYNC_CMD=bash scripts/mattermost/sync.sh
# AIW_MATTERMOST_SYNC_INTERVAL_MINUTES=15
# AIW_MANAGER_NAME=jeff
# Legacy options still supported:
# CHANNEL_NAMES=fidelity-preguntas,otro-canal
# CHANNEL_IDS=canal_id_1,canal_id_2

View File

@@ -2,10 +2,7 @@
This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context.
The preferred live Mattermost evidence source is now the proxy mirror under
the active profile inbox, `<profile inbox>/mattermost-mirror/`, when it is running. This legacy extractor remains
the fallback and explicit refresh path for commands that need a fresh pull from
the Mattermost API.
The preferred live Mattermost evidence source is the proxy mirror under the active profile inbox, `<profile inbox>/mattermost-mirror/`. This extractor is an explicit API refresh path for commands that need a fresh pull from the Mattermost API.
## Files
@@ -63,7 +60,7 @@ Manual run:
bash scripts/mattermost/sync.sh
```
OpenCode can use this script directly. If `AIW_MATTERMOST_SYNC_CMD` is not set, the workspace plugins will fall back to `FIDELITY_MATTERMOST_SYNC_CMD`, then to this wrapper automatically.
OpenCode can use this script directly. If `AIW_MATTERMOST_SYNC_CMD` is not set, the workspace plugin uses this wrapper automatically.
Generic workspace variables are preferred for reusable projects:
@@ -72,7 +69,7 @@ Generic workspace variables are preferred for reusable projects:
- `AIW_MATTERMOST_SYNC_CMD`
- `AIW_MATTERMOST_SYNC_INTERVAL_MINUTES`
Profile-specific compatibility variables may exist for older project setups, but new reusable workflows should prefer the generic `AIW_*` variables.
Reusable workflows should prefer the generic `AIW_*` variables.
Previous workday mode for standups: