diff --git a/agents/codemap.yml b/agents/codemap.yml new file mode 100644 index 0000000..271c566 --- /dev/null +++ b/agents/codemap.yml @@ -0,0 +1,22 @@ +scope: module +parent: ../codemap.yml +sources_of_truth: + - path: product_manager.md + description: Bundled Product Manager Agent definition. + - path: workflow_runner.md + description: Bundled Workflow Runner Agent definition. + - path: business_analyst.md + description: Bundled Business Analyst Agent definition. + - path: technical_architect.md + description: Bundled Technical Architect Agent definition. + - path: tech_lead.md + description: Bundled Tech Lead Agent definition. + - path: developer.md + description: Bundled Developer Agent definition. + - path: qa_engineer.md + description: Bundled QA Engineer Agent definition. + - path: ui_ux_designer.md + description: Bundled UI/UX Designer Agent definition. +commands: + - npm test + - npm run build diff --git a/agents/product_manager.md b/agents/product_manager.md index b3c6212..defd919 100644 --- a/agents/product_manager.md +++ b/agents/product_manager.md @@ -6,6 +6,11 @@ tools: nomadworks_validate: true nomadworks_start_discussion: true nomadworks_stop_discussion: true + nomadworks_session_export: true + nomadworks_session_import: true + nomadworks_sync_status: true + nomadworks_sync_pull: true + nomadworks_sync_push: true nomadflow_run_workflow: true nomadflow_prompt_workflow: true --- diff --git a/agents/workflow_runner.md b/agents/workflow_runner.md index 019117d..6e99ae4 100644 --- a/agents/workflow_runner.md +++ b/agents/workflow_runner.md @@ -3,6 +3,9 @@ description: Delegated workflow orchestrator for PMA-started complex task lifecy mode: subagent tools: nomadworks_validate: true + nomadworks_session_export: true + nomadworks_session_import: true + nomadworks_sync_status: true --- You are the NomadWorks Workflow Runner. You execute one PMA-started workflow lifecycle for one task file. diff --git a/codemap.yml b/codemap.yml new file mode 100644 index 0000000..bdb4ca8 --- /dev/null +++ b/codemap.yml @@ -0,0 +1,29 @@ +scope: repo +modules: + - path: src + description: Plugin runtime source and validation logic. + - path: scripts + description: Build and release helper scripts. + - path: tests + description: Jest regression coverage. + - path: agents + description: Bundled NomadWorks agent prompt definitions. + - path: policies + description: Bundled shared policy documents. +entrypoints: + - path: package.json + description: Package metadata, scripts, and published entrypoint declaration. +sources_of_truth: + - path: package-lock.json + description: Locked npm dependency graph. + - path: README.md + description: Public package overview. + - path: Agents_Common.md + description: Shared prompt content included by bundled agents. +invariants: + - Root CodeMap describes only immediate repository children; subdirectory internals belong to local codemap.yml files. + - Runtime state under hidden tool-owned directories is not indexed here. +commands: + - npm test + - npm run build + - npm run release:check diff --git a/docs/core/technical_guidelines.md b/docs/core/technical_guidelines.md index 895115e..c5f63d3 100644 --- a/docs/core/technical_guidelines.md +++ b/docs/core/technical_guidelines.md @@ -3,12 +3,12 @@ This document defines the project's tech stack and architectural patterns. ## Tech Stack -- **Language:** [To be defined] -- **Runtime/Framework:** [To be defined] -- **Frontend (if applicable):** [To be defined] -- **State Management:** [To be defined] -- **Testing Framework:** [To be defined] -- **Database/Storage:** [To be defined] +- **Language:** JavaScript ES modules, with TypeScript types available for Node compatibility checks. +- **Runtime/Framework:** Node.js OpenCode plugin package using `@opencode-ai/plugin`. +- **Frontend (if applicable):** Not applicable; NomadWorks is a CLI/plugin and documentation package. +- **State Management:** File-backed repository configuration, workflow artifacts, and optional Git-managed PAI roots. +- **Testing Framework:** Jest via `node --experimental-vm-modules`. +- **Database/Storage:** Local filesystem plus Git repositories for durable project and PAI state. ## Architectural Patterns - **Feature-First:** Organize code into distinct features or modules. diff --git a/docs/guides/TOOLS.md b/docs/guides/TOOLS.md index 092bfb6..bf73e5e 100644 --- a/docs/guides/TOOLS.md +++ b/docs/guides/TOOLS.md @@ -82,6 +82,60 @@ This tool performs the full close flow synchronously: - archives the raw runtime transcript - returns the final closed result from the tool call itself +## `nomadworks_session_export` + +Exports selected OpenCode sessions with the native sanitized `opencode export --sanitize ` command into the workspace area of the Git-managed PAI root. If no `session_ids` are provided, it exports the current OpenCode session only when the runtime supplies a current session ID in tool context; otherwise it fails and asks for explicit session IDs. + +### Arguments + +- `session_ids`: optional OpenCode session IDs, separated by commas or whitespace. Uses the current session when empty. +- `repo_path`: optional PAI root path. Uses `pai.root`, `sync.repo_path`, plugin `pai_root`, or plugin `sync_repo_path` when empty. +- `opencode_command`: optional OpenCode executable path or command. Uses `pai.opencode_command` or `opencode` when empty. +- `raw_export`: optional boolean. Defaults to `false`; set to `true` only to explicitly opt in to raw `opencode export ` output. + +### Notes + +- Generated files live under `WORKSPACES//SESSIONS/` inside the PAI repository. The `` is derived from the repository's stable Git identity, preferring the normalized remote URL/full name; `pai.workspace.id` can override it when necessary. +- The configured PAI root must already be a Git repository with `.git` before export writes durable session state. +- To export outside a live OpenCode session context, pass explicit `session_ids`. +- Run `nomadworks_session_import` on another machine to import them with the native `opencode import ` command. + +## `nomadworks_session_import` + +Imports selected OpenCode sessions from native `opencode export` JSON files in the workspace area of the Git-managed PAI root. + +### Arguments + +- `session_ids`: optional session IDs to import. Imports all exported OpenCode sessions in the manifest when empty. +- `repo_path`: optional PAI root path. Uses `pai.root`, `sync.repo_path`, plugin `pai_root`, or plugin `sync_repo_path` when empty. +- `opencode_command`: optional OpenCode executable path or command. Uses `pai.opencode_command` or `opencode` when empty. + +### Notes + +- Uses the native `opencode import ` command in the current worktree. +- The configured PAI root must already be a Git repository with `.git` before import reads durable session state. + +## `nomadworks_sync_status` + +Shows sync repository status for global PAI and the current workspace. + +### Arguments + +- `repo_path`: optional sync Git repository path. Uses `pai.root`, `sync.repo_path`, plugin `pai_root`, or plugin `sync_repo_path`. + +## `nomadworks_sync_pull` + +Runs `git pull --ff-only` in the configured sync repository. The configured PAI root must already be a Git repository with `.git`. + +## `nomadworks_sync_push` + +Runs `git add .`, `git commit`, and `git push` in the configured sync repository. The configured PAI root must already be a Git repository with `.git`. If there are no changes to commit, it returns status `no_changes` with `push: null` and does not run `git push`. + +### Arguments + +- `repo_path`: optional sync Git repository path. +- `message`: optional commit message. Defaults to `sync nomadworks pai`. + ## `nomadflow_run_workflow` Starts a `workflow_runner` session for a complex task. diff --git a/docs/setup/CONFIGURATION.md b/docs/setup/CONFIGURATION.md index 88da84f..8ce194a 100644 --- a/docs/setup/CONFIGURATION.md +++ b/docs/setup/CONFIGURATION.md @@ -17,6 +17,7 @@ defaults: features: debug_dumps: true codemap_verification: true + # pai_context: false policies: extract_defaults: none @@ -115,3 +116,55 @@ Create `.nomadworks/agents/.md` to: ## Feature flags - `features.keep_builtin_agents`: when `true`, NomadWorks will not disable agents that OpenCode already registered, including built-in agents such as `build`, `plan`, `general`, and `explore`. NomadWorks will still set `product_manager` as the default agent. +- `features.pai_context`: when `true`, injects selected global and workspace PAI user files into configured agent prompts. + +## PAI plugin options + +NomadWorks can be configured globally in OpenCode with PAI root options: + +```json +{ + "plugin": [["@neuralnomads/nomadworks", { + "pai_root": "~/nomadworks-pai", + "sync_repo_path": "~/nomadworks-pai" + }]] +} +``` + +- `pai_root`: Git-managed PAI root shared across repositories. +- `sync_repo_path`: defaults Git operations to the same PAI root. + +These options may be supplied as tuple plugin options in OpenCode. Repository-local `pai.root` and `sync.repo_path` override global plugin defaults when present. + +## PAI context + +```yaml +features: + pai_context: true + +pai: + root: ../nomadworks-pai + opencode_command: opencode + workspace: + enabled: true + # Optional stable override when the Git remote cannot identify this repo uniquely. + # id: neuralnomads-nomadworks + context_files: + - MEMORY/PROJECT.md + - MEMORY/DECISIONS.md + - MEMORY/NOTES.md + context_files: + - USER/ABOUTME.md + - USER/TELOS.md + - USER/AISTEERINGRULES.md + apply_to_agents: + - product_manager + - business_analyst + - tech_lead +``` + +When enabled, NomadWorks appends selected global PAI files first, then selected workspace PAI files. Both live in the Git-managed PAI root, outside the project repository. Global PAI uses `USER/`, `MEMORY/`, and `LEARNINGS/`; workspace PAI uses `WORKSPACES//`. The workspace `` is derived from the repository's stable Git identity, preferring the normalized remote URL/full name rather than the local worktree folder name. Set `pai.workspace.id` only for edge cases where the Git identity is unavailable or must be overridden. Neither overrides repository truth, SCRs, task files, evidence, docs, or CodeMaps. + +Use `nomadworks_session_export` to export selected sessions using sanitized native `opencode export --sanitize ` JSON by default. Raw exports require the explicit `raw_export: true` tool argument and should only be used when the caller accepts the sensitivity risk. When no `session_ids` are provided, export only works if the OpenCode runtime supplies the current session ID in the tool context; otherwise the tool returns a failure asking for explicit session IDs. Use `nomadworks_session_import` on another machine after `nomadworks_sync_pull` to import those files with native `opencode import `. + +Use `nomadworks_sync_pull` and `nomadworks_sync_push` for Git. Git, not NomadWorks, handles text-file merges and conflicts in the PAI root. Mutating PAI/session tools fail fast unless the configured PAI root already contains `.git`. If there are no changes to commit, `nomadworks_sync_push` returns status `no_changes` and does not run `git push`. Global PAI lives under `USER/`, `MEMORY/`, and `LEARNINGS/`; repo-specific PAI lives under `WORKSPACES//`. diff --git a/policies/codemap.yml b/policies/codemap.yml new file mode 100644 index 0000000..891e6c6 --- /dev/null +++ b/policies/codemap.yml @@ -0,0 +1,23 @@ +scope: module +parent: ../codemap.yml +sources_of_truth: + - path: README.md + description: Policy bundle overview. + - path: definition-of-ready.md + description: Definition of Ready policy. + - path: definition-of-done.md + description: Definition of Done policy. + - path: development-guidelines.md + description: Development policy. + - path: documentation-guidelines.md + description: Documentation policy. + - path: git-commit-messaging.md + description: Commit message policy. + - path: product-guidelines.md + description: Product guidance policy. + - path: testing-guidelines.md + description: Testing policy. + - path: ui-ux-guidelines.md + description: UI/UX policy. +commands: + - npm test diff --git a/scripts/codemap.yml b/scripts/codemap.yml new file mode 100644 index 0000000..7903df9 --- /dev/null +++ b/scripts/codemap.yml @@ -0,0 +1,11 @@ +scope: module +parent: ../codemap.yml +entrypoints: + - path: build.js + description: Builds the distributable plugin files into dist/. +internals: + - path: resolve-release-version.js + description: Resolves release version metadata for packaging workflows. +commands: + - npm run build + - npm run release:check diff --git a/src/codemap.yml b/src/codemap.yml new file mode 100644 index 0000000..f1adf43 --- /dev/null +++ b/src/codemap.yml @@ -0,0 +1,14 @@ +scope: module +parent: ../codemap.yml +entrypoints: + - path: index.js + description: NomadWorks OpenCode plugin entrypoint and tool wiring. +internals: + - path: validate_logic.js + description: CodeMap and workflow artifact validation implementation. +invariants: + - PAI durable sync state must stay outside the project worktree. + - Mutating PAI/session tools require a Git-backed PAI root. +commands: + - npm test + - npm run build diff --git a/src/index.js b/src/index.js index 6b51122..fb09cdd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import os from "node:os"; import YAML from "yaml"; import ignore from "ignore"; @@ -16,6 +18,7 @@ const MINI_MODE_AGENTS = new Set(["product_manager", "business_analyst", "tech_l const DISCUSSION_BACKFILL_FETCH_LIMIT = 100; const NOMADWORKS_DIRNAME = ".nomadworks"; const LEGACY_NOMADWORKS_DIRNAME = ".codenomad"; +const SYNC_MANIFEST = "manifest.json"; const activeWorkflows = new Map(); // sessionId -> { pmaSessionId, taskPath, track } @@ -55,10 +58,491 @@ function repoAgentAdditionsDir(worktree) { return path.join(nomadworksDir(worktree), "agent-additions"); } +function expandHome(inputPath) { + if (typeof inputPath !== "string" || !inputPath.trim()) return inputPath; + const trimmed = inputPath.trim(); + if (trimmed === "~") return os.homedir(); + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return path.join(os.homedir(), trimmed.slice(2)); + return trimmed; +} + +function globalNomadworksDir(options = {}) { + const configuredRoot = options.global_root || options.nomadworks_root; + if (configuredRoot) return path.resolve(expandHome(configuredRoot)); + const base = process.platform === "win32" + ? (process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming")) + : path.join(os.homedir(), ".config"); + return path.join(base, "nomadworks"); +} + +function globalPaiDir(options = {}) { + return path.resolve(expandHome(options.pai_root || options.sync_repo_path || path.join(globalNomadworksDir(options), "pai"))); +} + +function globalPaiUserDir(options = {}) { + return path.join(globalPaiDir(options), "USER"); +} + +function globalPaiMemoryDir(options = {}) { + return path.join(globalPaiDir(options), "MEMORY"); +} + +function globalPaiLearningsDir(options = {}) { + return path.join(globalPaiDir(options), "LEARNINGS"); +} + +function workspaceSyncRoot(syncRoot, worktree, repoCfg = {}) { + return path.join(syncRoot, "WORKSPACES", memoryRepoId(worktree, repoCfg)); +} + +function workspacePaiDirFromRoot(paiRoot, worktree, repoCfg = {}) { + return path.join(paiRoot, "WORKSPACES", memoryRepoId(worktree, repoCfg)); +} + +function workspacePaiMemoryDirFromRoot(paiRoot, worktree, repoCfg = {}) { + return path.join(workspacePaiDirFromRoot(paiRoot, worktree, repoCfg), "MEMORY"); +} + +function workspacePaiSessionsDirFromRoot(paiRoot, worktree, repoCfg = {}) { + return path.join(workspacePaiDirFromRoot(paiRoot, worktree, repoCfg), "SESSIONS"); +} + function legacyRepoAgentsDir(worktree) { return path.join(legacyNomadworksDir(worktree), "nomadworks", "agents"); } +function sanitizeWorkspaceId(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\.git$/i, "") + .replace(/[^a-z0-9._/-]+/g, "-") + .replace(/[\\/]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 160) || "repository"; +} + +function gitProbe(worktree, args) { + const result = spawnSync("git", args, { cwd: worktree, encoding: "utf8", shell: false }); + if (result.error || result.status !== 0) return ""; + return String(result.stdout || "").trim(); +} + +function normalizeGitRemoteIdentity(remoteUrl) { + const trimmed = String(remoteUrl || "").trim(); + if (!trimmed) return ""; + const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/); + if (scpLike && !trimmed.includes("://")) return sanitizeWorkspaceId(`${scpLike[1]}/${scpLike[2]}`); + try { + const parsed = new URL(trimmed); + return sanitizeWorkspaceId(`${parsed.hostname}${parsed.pathname}`); + } catch { + return sanitizeWorkspaceId(trimmed); + } +} + +function packageRepositoryIdentity(worktree) { + const packagePath = path.join(worktree, "package.json"); + if (!fs.existsSync(packagePath)) return ""; + try { + const packageData = JSON.parse(fs.readFileSync(packagePath, "utf8")); + const repository = typeof packageData.repository === "string" + ? packageData.repository + : packageData.repository?.url; + return normalizeGitRemoteIdentity(repository); + } catch { + return ""; + } +} + +function memoryRepoId(worktree, repoCfg = {}) { + const configuredId = repoCfg.pai?.workspace?.id; + if (typeof configuredId === "string" && configuredId.trim()) return sanitizeWorkspaceId(configuredId); + + const originRemote = gitProbe(worktree, ["config", "--get", "remote.origin.url"]); + const remoteIdentity = normalizeGitRemoteIdentity(originRemote); + if (remoteIdentity) return remoteIdentity; + + const firstRemote = gitProbe(worktree, ["remote"]) + .split(/\r?\n/) + .map(remote => remote.trim()) + .filter(Boolean)[0]; + if (firstRemote) { + const firstRemoteUrl = gitProbe(worktree, ["config", "--get", `remote.${firstRemote}.url`]); + const firstRemoteIdentity = normalizeGitRemoteIdentity(firstRemoteUrl); + if (firstRemoteIdentity) return firstRemoteIdentity; + } + + const packageIdentity = packageRepositoryIdentity(worktree); + if (packageIdentity) return packageIdentity; + + const topLevel = gitProbe(worktree, ["rev-parse", "--show-toplevel"]); + return sanitizeWorkspaceId(topLevel ? path.basename(topLevel) : path.basename(worktree)); +} + +function resolveConfiguredPaiRoot(worktree, repoCfg, options = {}, args = {}) { + const configuredPath = typeof args.repo_path === "string" && args.repo_path.trim() + ? args.repo_path.trim() + : repoCfg.pai?.root || repoCfg.sync?.repo_path || options.pai_root || options.sync_repo_path; + if (!configuredPath) throw new Error("Configure pai.root, sync.repo_path, pai_root, or sync_repo_path before using PAI sync."); + const expandedPath = expandHome(configuredPath); + const resolvedPath = path.isAbsolute(expandedPath) + ? expandedPath + : path.resolve(worktree, expandedPath); + if (isPathInside(worktree, resolvedPath)) { + throw new Error(`PAI root must be outside the workspace: ${resolvedPath}`); + } + return resolvedPath; +} + +function resolveWorkspaceExchangeRoot(worktree, repoCfg, options = {}, args = {}) { + return workspaceSyncRoot(resolveConfiguredPaiRoot(worktree, repoCfg, options, args), worktree, repoCfg); +} + +function shouldScaffoldPaiOnLoad(repoCfg, options = {}) { + return Boolean(repoCfg.pai?.root) + || Boolean(repoCfg.sync?.repo_path) + || Boolean(options.pai_root) + || Boolean(options.sync_repo_path); +} + +function isPathInside(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +const SECRET_PATTERNS = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, + /\b(?:api[_-]?key|access[_-]?token|secret|password|credential)s?\b\s*[:=]\s*['\"]?[^\s'\"]{12,}/i, + /\b(?:ghp|gho|ghu|ghs|github_pat|sk-[a-z0-9])[a-z0-9_\-]{16,}\b/i +]; + +function readManifest(manifestPath, fallback) { + if (!fs.existsSync(manifestPath)) return fallback; + try { + return { ...fallback, ...JSON.parse(fs.readFileSync(manifestPath, "utf8")) }; + } catch { + return fallback; + } +} + +function parseSessionIds(value) { + if (Array.isArray(value)) return value.map(String).map(id => id.trim()).filter(Boolean); + if (typeof value !== "string") return []; + return value.split(/[\s,]+/).map(id => id.trim()).filter(Boolean); +} + +function sessionTranscriptRelativePath(sessionId) { + const safeId = sessionId.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 120) || "session"; + return path.join("SESSIONS", `${safeId}.json`); +} + +function opencodeCommand(repoCfg, args = {}) { + const configured = typeof args.opencode_command === "string" && args.opencode_command.trim() + ? args.opencode_command.trim() + : repoCfg.pai?.opencode_command; + return configured || "opencode"; +} + +function runOpenCodeCommand(command, commandArgs, worktree) { + const result = spawnSync(command, commandArgs, { + cwd: worktree, + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + shell: false + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "unknown error").trim(); + throw new Error(`${command} ${commandArgs.join(" ")} failed: ${detail}`); + } + return result.stdout || ""; +} + +function assertGitBackedPaiRoot(paiRoot) { + if (!fs.existsSync(path.join(paiRoot, ".git"))) throw new Error(`PAI root is not an existing Git repository: ${paiRoot}`); +} + +async function exportOpenCodeSessions(worktree, repoCfg, options = {}, args = {}) { + const sessionIds = parseSessionIds(args.session_ids); + if (sessionIds.length === 0 && args.current_session_id) sessionIds.push(args.current_session_id); + if (sessionIds.length === 0) throw new Error("Provide at least one session ID in session_ids, or call this tool from an OpenCode session context."); + + const paiRoot = resolveConfiguredPaiRoot(worktree, repoCfg, options, args); + assertGitBackedPaiRoot(paiRoot); + const targetRoot = workspaceSyncRoot(paiRoot, worktree, repoCfg); + if (!fs.existsSync(targetRoot)) fs.mkdirSync(targetRoot, { recursive: true }); + + const manifestPath = path.join(targetRoot, "manifest.json"); + const manifest = readManifest(manifestPath, { + version: 1, + repository: memoryRepoId(worktree, repoCfg), + repository_id: memoryRepoId(worktree, repoCfg), + exported_at: new Date().toISOString(), + files: [], + skipped_sensitive_files: [] + }); + + const command = opencodeCommand(repoCfg, args); + const rawExport = args.raw_export === true; + const exported = []; + const failed = []; + const manifestFiles = new Set(Array.isArray(manifest.files) ? manifest.files : []); + + for (const sessionId of sessionIds) { + try { + const relativePath = sessionTranscriptRelativePath(sessionId).replace(/\\/g, "/"); + const targetPath = path.join(targetRoot, relativePath); + const exportArgs = rawExport ? ["export", sessionId] : ["export", "--sanitize", sessionId]; + const content = runOpenCodeCommand(command, exportArgs, worktree); + if (SECRET_PATTERNS.some(pattern => pattern.test(content))) { + manifest.skipped_sensitive_files ??= []; + manifest.skipped_sensitive_files.push(relativePath); + failed.push({ session_id: sessionId, reason: "native export matched sensitive content patterns" }); + continue; + } + + const targetDir = path.dirname(targetPath); + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(targetPath, content, "utf8"); + manifestFiles.add(relativePath); + exported.push({ session_id: sessionId, file: relativePath, format: "opencode-export-json", sanitized: !rawExport }); + } catch (e) { + failed.push({ session_id: sessionId, reason: e.message }); + } + } + + manifest.exported_at = new Date().toISOString(); + manifest.files = [...manifestFiles].sort(); + manifest.opencode_sessions ??= []; + const priorSessions = new Map(manifest.opencode_sessions.map(entry => [entry.session_id, entry])); + for (const entry of exported) priorSessions.set(entry.session_id, { ...entry, exported_at: manifest.exported_at }); + manifest.opencode_sessions = [...priorSessions.values()].sort((a, b) => a.session_id.localeCompare(b.session_id)); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + + return { targetRoot, exported, failed, manifest }; +} + +function importOpenCodeSessions(worktree, repoCfg, options = {}, args = {}) { + const paiRoot = resolveConfiguredPaiRoot(worktree, repoCfg, options, args); + assertGitBackedPaiRoot(paiRoot); + const sourceRoot = workspaceSyncRoot(paiRoot, worktree, repoCfg); + const manifestPath = path.join(sourceRoot, "manifest.json"); + if (!fs.existsSync(manifestPath)) throw new Error(`PAI workspace manifest not found at ${manifestPath}`); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const manifestFiles = new Set(Array.isArray(manifest.files) ? manifest.files : []); + const requestedIds = parseSessionIds(args.session_ids); + const requestedSet = new Set(requestedIds); + const sessions = Array.isArray(manifest.opencode_sessions) ? manifest.opencode_sessions : []; + const selectedSessions = requestedSet.size > 0 + ? sessions.filter(session => requestedSet.has(session.session_id)) + : sessions; + if (selectedSessions.length === 0) throw new Error("No matching OpenCode session exports found in manifest."); + + const command = opencodeCommand(repoCfg, args); + const imported = []; + const failed = []; + + for (const session of selectedSessions) { + try { + if (session.format !== "opencode-export-json") { + failed.push({ session_id: session.session_id, reason: "unsupported session export format" }); + continue; + } + const sessionFile = String(session.file || "").replace(/\\/g, "/"); + const normalizedFile = path.posix.normalize(sessionFile); + if (path.isAbsolute(sessionFile) || sessionFile.split("/").includes("..") || !/^SESSIONS\/[^/]+\.json$/.test(normalizedFile)) { + failed.push({ session_id: session.session_id, reason: "invalid session export path" }); + continue; + } + if (!manifestFiles.has(normalizedFile)) { + failed.push({ session_id: session.session_id, reason: "session export not listed in manifest files" }); + continue; + } + const sourcePath = path.join(sourceRoot, normalizedFile); + if (!isPathInside(sourceRoot, sourcePath) || !fs.existsSync(sourcePath)) { + failed.push({ session_id: session.session_id, reason: "export file not found" }); + continue; + } + runOpenCodeCommand(command, ["import", sourcePath], worktree); + imported.push({ session_id: session.session_id, file: normalizedFile }); + } catch (e) { + failed.push({ session_id: session.session_id, reason: e.message }); + } + } + + return { sourceRoot, imported, failed }; +} + +function syncStatus(worktree, repoCfg, options, args = {}) { + const root = resolveConfiguredPaiRoot(worktree, repoCfg, options, args); + const workspaceManifestPath = path.join(workspaceSyncRoot(root, worktree, repoCfg), SYNC_MANIFEST); + const readManifest = (filePath) => { + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return { error: "manifest is not valid JSON" }; + } + }; + return { + pai_root: root, + is_git_repository: fs.existsSync(path.join(root, ".git")), + git_status: fs.existsSync(path.join(root, ".git")) ? runGitStatus(root) : null, + workspace_root: workspaceSyncRoot(root, worktree, repoCfg), + workspace_manifest: readManifest(workspaceManifestPath) + }; +} + +function runGitStatus(syncRoot) { + const status = runGitSyncCommand(syncRoot, ["status", "--short", "--branch"]); + const lines = status.stdout.split(/\r?\n/).filter(Boolean); + const branchLine = lines[0] || ""; + const divergence = branchLine.match(/\[(.*?)\]/)?.[1] || ""; + return { + ...status, + branch: branchLine.replace(/^##\s*/, "").replace(/\s*\[.*\]$/, ""), + dirty: lines.slice(1).length > 0, + ahead: Number(divergence.match(/ahead\s+(\d+)/)?.[1] || 0), + behind: Number(divergence.match(/behind\s+(\d+)/)?.[1] || 0), + has_upstream: branchLine.includes("...") + }; +} + +function runGitSyncCommand(syncRoot, args, options = {}) { + if (!fs.existsSync(path.join(syncRoot, ".git"))) throw new Error(`Sync root is not an existing Git repository: ${syncRoot}`); + const result = spawnSync("git", args, { cwd: syncRoot, encoding: "utf8", shell: false }); + if (result.error) throw result.error; + const allowedStatuses = new Set([0, ...(options.allowStatuses || [])]); + if (!allowedStatuses.has(result.status)) { + const detail = (result.stderr || result.stdout || "unknown git error").trim(); + throw new Error(`git ${args.join(" ")} failed: ${detail}`); + } + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function resolvePaiContextFiles(worktree, repoCfg, options = {}) { + if (repoCfg.features?.pai_context !== true) return []; + const configuredFiles = Array.isArray(repoCfg.pai?.context_files) ? repoCfg.pai.context_files : []; + const workspaceFiles = Array.isArray(repoCfg.pai?.workspace?.context_files) + ? repoCfg.pai.workspace.context_files + : ["MEMORY/PROJECT.md", "MEMORY/DECISIONS.md", "MEMORY/NOTES.md"]; + const includeGlobal = repoCfg.pai?.global?.enabled !== false && options.pai_global !== false; + const includeWorkspace = repoCfg.pai?.workspace?.enabled !== false; + let paiRoot; + try { + paiRoot = resolveConfiguredPaiRoot(worktree, repoCfg, options, {}); + } catch { + return []; + } + const files = []; + + if (includeGlobal) { + files.push(...configuredFiles + .filter(file => typeof file === "string" && file.trim()) + .map(file => file.trim()) + .map(relativeFile => { + const normalized = relativeFile.replace(/^[\\/]+/, ""); + const absolutePath = path.join(paiRoot, normalized); + return { relativePath: path.join("PAI", normalized).replace(/\\/g, "/"), absolutePath, root: paiRoot }; + })); + } + + if (includeWorkspace) { + files.push(...workspaceFiles + .filter(file => typeof file === "string" && file.trim()) + .map(file => file.trim()) + .map(relativeFile => { + const normalized = relativeFile.replace(/^[\\/]+/, ""); + const workspaceRoot = workspacePaiDirFromRoot(paiRoot, worktree, repoCfg); + const absolutePath = path.join(workspaceRoot, normalized); + return { relativePath: path.join("WORKSPACES", memoryRepoId(worktree, repoCfg), normalized).replace(/\\/g, "/"), absolutePath, root: workspaceRoot }; + })); + } + + return files.filter(file => isPathInside(file.root, file.absolutePath) && fs.existsSync(file.absolutePath) && fs.statSync(file.absolutePath).isFile()); +} + +function buildPaiContextFragment(agentId, worktree, repoCfg, options = {}) { + const applyToAgents = Array.isArray(repoCfg.pai?.apply_to_agents) ? repoCfg.pai.apply_to_agents : []; + if (!applyToAgents.includes(agentId)) return ""; + const files = resolvePaiContextFiles(worktree, repoCfg, options); + if (files.length === 0) return ""; + + const sections = []; + for (const file of files) { + const content = fs.readFileSync(file.absolutePath, "utf8").trim(); + if (!content) continue; + sections.push(`## ${file.relativePath}\n\n${content}`); + } + if (sections.length === 0) return ""; + + return [ + "# Optional PAI User Context", + "", + "Use this context as user/team steering input. It does not override repository truth, SCRs, tasks, evidence, docs, or CodeMaps.", + "", + ...sections + ].join("\n"); +} + +function scaffoldGlobalPai(options = {}) { + ensureReadmeFile(globalPaiDir(options), [ + "# NomadWorks Global PAI", + "", + "Personal AI Infrastructure context shared across repositories.", + "", + "- `USER/` stores identity, TELOS, and steering context.", + "- `MEMORY/` stores durable cross-project memory.", + "- `LEARNINGS/` stores reusable lessons and improvement loops.", + "" + ].join("\n")); + ensureReadmeFile(globalPaiUserDir(options), [ + "# Global PAI User Context", + "", + "Place ABOUTME.md, TELOS.md, AISTEERINGRULES.md, and other personal context here.", + "" + ].join("\n")); + ensureReadmeFile(globalPaiMemoryDir(options), [ + "# Global PAI Memory", + "", + "Durable personal memory shared across NomadWorks repositories.", + "" + ].join("\n")); + ensureReadmeFile(globalPaiLearningsDir(options), [ + "# Global PAI Learnings", + "", + "Reusable lessons, preferences, and improvement notes learned across sessions.", + "" + ].join("\n")); +} + +function scaffoldWorkspacePai(paiRoot, worktree, repoCfg = {}) { + const workspaceRoot = workspacePaiDirFromRoot(paiRoot, worktree, repoCfg); + ensureReadmeFile(workspaceRoot, [ + `# Workspace PAI: ${memoryRepoId(worktree, repoCfg)}`, + "", + "Project-specific AI memory stored outside the project repository.", + "", + "Repository truth still lives in the project repo; this folder is auxiliary AI memory.", + "" + ].join("\n")); + ensureReadmeFile(workspacePaiMemoryDirFromRoot(paiRoot, worktree, repoCfg), [ + "# Workspace Memory", + "", + "Durable project-specific learnings, decisions, and context for NomadWorks agents.", + "" + ].join("\n")); + ensureReadmeFile(workspacePaiSessionsDirFromRoot(paiRoot, worktree, repoCfg), [ + "# OpenCode Sessions", + "", + "Native `opencode export` JSON files for sessions related to this workspace.", + "" + ].join("\n")); +} + function runtimeDiscussionRegistryPath(worktree) { return path.join(nomadworksDir(worktree), "runtime", "discussions.json"); } @@ -775,12 +1259,16 @@ function getModePromptFragment(agentId, operatingTeamMode, worktree) { return readResolvedFile(fragmentPath, worktree); } -export default async function NomadWorksPlugin(input) { +export default async function NomadWorksPlugin(input, options = {}) { + const pluginOptions = { + ...(options || {}), + ...(input.config || {}), + ...(input.options || {}) + }; const worktree = path.resolve(input.worktree || process.cwd()); const debugDir = generatedAgentsDir(worktree); const configPath = resolveConfigPath(worktree); const discussionRegistry = loadDiscussionRegistry(worktree); - // Load project-specific configuration let repoCfg = { agents: {}, defaults: {}, features: {} }; if (fs.existsSync(configPath)) { @@ -791,6 +1279,16 @@ export default async function NomadWorksPlugin(input) { } } repoCfg = applyTeamConfigRules(repoCfg); + if (shouldScaffoldPaiOnLoad(repoCfg, pluginOptions)) { + try { + const paiRoot = resolveConfiguredPaiRoot(worktree, repoCfg, pluginOptions, {}); + assertGitBackedPaiRoot(paiRoot); + scaffoldGlobalPai({ ...pluginOptions, pai_root: paiRoot }); + scaffoldWorkspacePai(paiRoot, worktree, repoCfg); + } catch (e) { + console.error(`[NomadWorks] PAI scaffolding skipped for ${worktree}:`, e); + } + } scaffoldNomadworksReadmes(worktree); syncGeneratedPolicies(worktree, repoCfg); const operatingTeamMode = getOperatingTeamMode(repoCfg); @@ -862,12 +1360,11 @@ export default async function NomadWorksPlugin(input) { if (requestedTeamMode !== "mini" && requestedTeamMode !== "full") { return "Error: team_mode must be either 'mini' or 'full'."; } - const cfgDir = nomadworksDir(context.worktree); if (!fs.existsSync(cfgDir)) fs.mkdirSync(cfgDir, { recursive: true }); // Discover all agent IDs to enable them explicitly - const agentIds = fs.existsSync(BUNDLE_AGENTS_DIR) + const agentIds = fs.existsSync(BUNDLE_AGENTS_DIR) ? fs.readdirSync(BUNDLE_AGENTS_DIR).filter(f => f.endsWith(".md")).map(f => f.replace(".md", "")) : []; @@ -879,7 +1376,7 @@ export default async function NomadWorksPlugin(input) { let nomadworksConfig = fs.readFileSync(nomadworksTmplPath, "utf8"); nomadworksConfig = nomadworksConfig.replace("{{teamMode}}", requestedTeamMode); - + // Append dynamically discovered agents to the template let agentsSection = ""; for (const id of agentIds) { @@ -914,7 +1411,7 @@ export default async function NomadWorksPlugin(input) { const donePath = path.join(tasksDir, "done.md"); const scrsCurrentPath = path.join(scrsDir, "current.md"); const scrsDonePath = path.join(scrsDir, "done.md"); - + if (!fs.existsSync(currentPath)) { fs.writeFileSync(currentPath, "# Current Tasks (Backlog)\n\n## 💬 Active Discussions\n- (None)\n\n## 🚀 Active\n- (None)\n\n## 📋 Todo\n- (None)\n\n## 🛑 Blocked\n- (None)\n", "utf8"); } @@ -1129,6 +1626,110 @@ export default async function NomadWorksPlugin(input) { return `FAIL: Discussion summarization failed.\nID: ${existing.id}\nTitle: ${existing.title}\nTranscript: ${existing.transcriptPath}\nFinal Summary Target: ${existing.summaryPath}\nReason: ${err.message}`; } } + }), + nomadworks_session_export: tool({ + description: "Export selected OpenCode sessions with the native opencode export command into the workspace PAI sessions directory", + args: { + session_ids: tool.schema.string().describe("Optional OpenCode session IDs, separated by commas or whitespace. Uses the current session when empty.").optional(), + repo_path: tool.schema.string().describe("Optional PAI root path. Uses pai.root, sync.repo_path, plugin pai_root, or plugin sync_repo_path when empty.").optional(), + opencode_command: tool.schema.string().describe("Optional OpenCode executable path or command. Defaults to pai.opencode_command or 'opencode'.").optional(), + raw_export: tool.schema.boolean().describe("Explicit opt-in to run raw 'opencode export ' instead of the default sanitized export.").optional() + }, + async execute(args, context) { + try { + const currentSessionId = context.sessionId || context.sessionID; + const exported = await exportOpenCodeSessions(context.worktree, repoCfg, pluginOptions, { ...args, current_session_id: currentSessionId }); + return JSON.stringify({ + exported_to: exported.targetRoot, + exported_sessions: exported.exported, + failed_sessions: exported.failed, + next_step: "Commit and push the PAI repository with Git from the exported_to path or its parent repository. Import later with nomadworks_session_import." + }, null, 2); + } catch (e) { + return `FAIL: ${e.message}`; + } + } + }), + nomadworks_session_import: tool({ + description: "Import selected OpenCode sessions from native opencode export JSON files in the workspace PAI sessions directory", + args: { + session_ids: tool.schema.string().describe("Optional session IDs to import. Imports all exported OpenCode sessions in the manifest when empty.").optional(), + repo_path: tool.schema.string().describe("Optional PAI root path. Uses pai.root, sync.repo_path, plugin pai_root, or plugin sync_repo_path when empty.").optional(), + opencode_command: tool.schema.string().describe("Optional OpenCode executable path or command. Defaults to pai.opencode_command or 'opencode'.").optional() + }, + async execute(args, context) { + try { + const imported = importOpenCodeSessions(context.worktree, repoCfg, pluginOptions, args); + return JSON.stringify({ + imported_from: imported.sourceRoot, + imported_sessions: imported.imported, + failed_sessions: imported.failed + }, null, 2); + } catch (e) { + return `FAIL: ${e.message}`; + } + } + }), + nomadworks_sync_status: tool({ + description: "Show global PAI and workspace sync status", + args: { + repo_path: tool.schema.string().describe("Optional sync Git repository path. Uses pai.root, sync.repo_path, plugin pai_root, or plugin sync_repo_path.").optional() + }, + async execute(args, context) { + try { + return JSON.stringify(syncStatus(context.worktree, repoCfg, pluginOptions, args), null, 2); + } catch (e) { + return `FAIL: ${e.message}`; + } + } + }), + nomadworks_sync_pull: tool({ + description: "Run git pull in the configured sync repository", + args: { + repo_path: tool.schema.string().describe("Optional sync Git repository path. Uses pai.root, sync.repo_path, plugin pai_root, or plugin sync_repo_path.").optional() + }, + async execute(args, context) { + try { + const root = resolveConfiguredPaiRoot(context.worktree, repoCfg, pluginOptions, args); + return JSON.stringify({ sync_root: root, pull: runGitSyncCommand(root, ["pull", "--ff-only"]) }, null, 2); + } catch (e) { + return `FAIL: ${e.message}`; + } + } + }), + nomadworks_sync_push: tool({ + description: "Commit and push the configured sync repository", + args: { + repo_path: tool.schema.string().describe("Optional sync Git repository path. Uses pai.root, sync.repo_path, plugin pai_root, or plugin sync_repo_path.").optional(), + message: tool.schema.string().describe("Optional commit message. Defaults to 'sync nomadworks pai'.").optional() + }, + async execute(args, context) { + try { + const root = resolveConfiguredPaiRoot(context.worktree, repoCfg, pluginOptions, args); + const message = typeof args.message === "string" && args.message.trim() ? args.message.trim() : "sync nomadworks pai"; + const add = runGitSyncCommand(root, ["add", "."]); + const commit = runGitSyncCommand(root, ["commit", "-m", message], { allowStatuses: [1] }); + const commitOutput = `${commit.stdout}\n${commit.stderr}`.toLowerCase(); + if (commit.status !== 0 && (commitOutput.includes("nothing to commit") || commitOutput.includes("no changes added to commit"))) { + return JSON.stringify({ + sync_root: root, + add, + commit, + push: null, + status: "no_changes", + message: "No PAI changes to commit." + }, null, 2); + } + if (commit.status !== 0) { + const detail = (commit.stderr || commit.stdout || "unknown git error").trim(); + return `FAIL: git commit -m ${message} failed: ${detail}`; + } + const push = runGitSyncCommand(root, ["push"]); + return JSON.stringify({ sync_root: root, add, commit, push }, null, 2); + } catch (e) { + return `FAIL: ${e.message}`; + } + } }), nomadflow_run_workflow: tool({ description: "Start a workflow_runner session for a complex task", @@ -1339,6 +1940,11 @@ export default async function NomadWorksPlugin(input) { finalPrompt = `${finalPrompt}\n\n# Repository-Specific ${id} Additions\n\n${additionFragment}`; } + const paiContextFragment = buildPaiContextFragment(id, worktree, repoCfg, pluginOptions); + if (paiContextFragment) { + finalPrompt = `${finalPrompt}\n\n${paiContextFragment}`; + } + const provider = agentOverride.provider || data.provider || repoCfg.defaults?.provider; const model = agentOverride.model || data.model || repoCfg.defaults?.model; diff --git a/templates/nomadworks.yaml.template b/templates/nomadworks.yaml.template index c2f6a27..4d2b8c1 100644 --- a/templates/nomadworks.yaml.template +++ b/templates/nomadworks.yaml.template @@ -13,6 +13,28 @@ features: # debug_logs: false # Enable detailed console logging for the plugin # keep_builtin_agents: false # If true, do not disable agents OpenCode already registered, including built-ins codemap_verification: true + # pai_context: false # If true, injects selected Git-managed PAI root context into configured agents + +sync: + # repo_path: ../nomadworks-pai # Optional Git-managed PAI repository + +pai: + # root: ../nomadworks-pai # Optional Git-managed PAI root; defaults to sync.repo_path or global NomadWorks config + # opencode_command: opencode # Optional executable path or command for native OpenCode session export/import + workspace: + enabled: true + context_files: + - MEMORY/PROJECT.md + - MEMORY/DECISIONS.md + - MEMORY/NOTES.md + context_files: + - USER/ABOUTME.md + - USER/TELOS.md + - USER/AISTEERINGRULES.md + apply_to_agents: + - product_manager + - business_analyst + - tech_lead policies: extract_defaults: none # Set to 'all' to write bundled policy defaults to .nomadworks/generated/policies/ diff --git a/tests/codemap.yml b/tests/codemap.yml new file mode 100644 index 0000000..216e589 --- /dev/null +++ b/tests/codemap.yml @@ -0,0 +1,11 @@ +scope: module +parent: ../codemap.yml +entrypoints: + - path: plugin.test.js + description: Regression coverage for plugin PAI behavior and tool wiring. + - path: validate.test.js + description: Regression coverage for CodeMap validation behavior. + - path: pai-session-export.test.js + description: Regression coverage for sanitized and raw session export commands. +commands: + - npm test diff --git a/tests/pai-session-export.test.js b/tests/pai-session-export.test.js new file mode 100644 index 0000000..e55b14e --- /dev/null +++ b/tests/pai-session-export.test.js @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { jest } from "@jest/globals"; + +const spawnSyncMock = jest.fn(); + +jest.unstable_mockModule("node:child_process", () => ({ + spawnSync: spawnSyncMock +})); + +const { default: NomadWorksPlugin } = await import("../src/index.js"); + +function createTestEnv(configText) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-plugin-test-")); + fs.mkdirSync(path.join(root, ".nomadworks"), { recursive: true }); + fs.writeFileSync(path.join(root, ".nomadworks", "nomadworks.yaml"), configText, "utf8"); + return root; +} + +function createPaiRoot() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-pai-test-")); + fs.mkdirSync(path.join(root, ".git")); + return root; +} + +describe("NomadWorks session export command behavior", () => { + beforeEach(() => { + spawnSyncMock.mockReset(); + spawnSyncMock.mockImplementation((command, args) => { + if (command === "opencode" && args[0] === "export") { + return { status: 0, stdout: JSON.stringify({ command, args }), stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }); + }); + + test("session export uses native OpenCode sanitization by default", async () => { + const paiRoot = createPaiRoot(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + " workspace:", + " id: export-test", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + const result = JSON.parse(await plugin.tool.nomadworks_session_export.execute({ session_ids: "abc" }, { worktree })); + + expect(spawnSyncMock).toHaveBeenCalledWith("opencode", ["export", "--sanitize", "abc"], expect.any(Object)); + expect(result.exported_sessions).toEqual([{ session_id: "abc", file: "SESSIONS/abc.json", format: "opencode-export-json", sanitized: true }]); + }); + + test("session export only uses raw OpenCode export with explicit raw_export opt-in", async () => { + const paiRoot = createPaiRoot(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + " workspace:", + " id: export-test", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + const result = JSON.parse(await plugin.tool.nomadworks_session_export.execute({ session_ids: "abc", raw_export: true }, { worktree })); + + expect(spawnSyncMock).toHaveBeenCalledWith("opencode", ["export", "abc"], expect.any(Object)); + expect(result.exported_sessions).toEqual([{ session_id: "abc", file: "SESSIONS/abc.json", format: "opencode-export-json", sanitized: false }]); + }); +}); diff --git a/tests/plugin.test.js b/tests/plugin.test.js new file mode 100644 index 0000000..e376d64 --- /dev/null +++ b/tests/plugin.test.js @@ -0,0 +1,282 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { jest } from "@jest/globals"; + +import NomadWorksPlugin from "../src/index.js"; + +function createTestEnv(configText) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-plugin-test-")); + fs.mkdirSync(path.join(root, ".nomadworks"), { recursive: true }); + fs.writeFileSync(path.join(root, ".nomadworks", "nomadworks.yaml"), configText, "utf8"); + return root; +} + +function createGitRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-pai-test-")); + const result = spawnSync("git", ["init"], { cwd: root, encoding: "utf8", shell: false }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout || "git init failed"); + return root; +} + +function createGitWorktree(configText, remoteUrl) { + const root = createTestEnv(configText); + const init = spawnSync("git", ["init"], { cwd: root, encoding: "utf8", shell: false }); + if (init.status !== 0) throw new Error(init.stderr || init.stdout || "git init failed"); + if (remoteUrl) { + const remote = spawnSync("git", ["remote", "add", "origin", remoteUrl], { cwd: root, encoding: "utf8", shell: false }); + if (remote.status !== 0) throw new Error(remote.stderr || remote.stdout || "git remote add failed"); + } + return root; +} + +describe("NomadWorks plugin PAI behavior", () => { + test("plugin load does not scaffold PAI sync folders without PAI or memory configuration", async () => { + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + " pai_context: false", + "" + ].join("\n")); + + await NomadWorksPlugin({ worktree, options: {} }); + + expect(fs.existsSync(path.join(worktree, ".nomadworks", "pai"))).toBe(false); + expect(fs.existsSync(path.join(worktree, ".nomadworks", "memory"))).toBe(false); + expect(fs.existsSync(path.join(worktree, ".nomadworks", "memory", "sync"))).toBe(false); + }); + + test("PAI context without configured root does not create local sync fallback", async () => { + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + " pai_context: true", + "pai:", + " apply_to_agents:", + " - product_manager", + " context_files:", + " - USER/ABOUTME.md", + "" + ].join("\n")); + + await NomadWorksPlugin({ worktree, options: {} }); + + expect(fs.existsSync(path.join(worktree, ".nomadworks", "memory", "sync"))).toBe(false); + }); + + test("sync push requires an explicit configured Git-backed PAI root", async () => { + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + await expect(plugin.tool.nomadworks_sync_push.execute({}, { worktree })).resolves.toMatch(/^FAIL: Configure pai\.root/); + }); + + test("sync pull requires an explicit configured Git-backed PAI root", async () => { + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + await expect(plugin.tool.nomadworks_sync_pull.execute({}, { worktree })).resolves.toMatch(/^FAIL: Configure pai\.root/); + }); + + test("sync status includes real Git status for configured PAI repo", async () => { + const paiRoot = createGitRepo(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + const result = JSON.parse(await plugin.tool.nomadworks_sync_status.execute({}, { worktree })); + + expect(result.is_git_repository).toBe(true); + expect(result.git_status.status).toBe(0); + expect(result.git_status.stdout).toContain("##"); + }); + + test("global PAI options from second plugin argument are honored when input options are empty", async () => { + const paiRoot = createGitRepo(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "" + ].join("\n")); + + const plugin = await NomadWorksPlugin({ worktree, options: {} }, { pai_root: paiRoot }); + const result = JSON.parse(await plugin.tool.nomadworks_sync_status.execute({}, { worktree })); + + expect(result.pai_root).toBe(paiRoot); + expect(result.is_git_repository).toBe(true); + }); + + test("workspace PAI identity is stable across worktree paths for the same Git remote", async () => { + const paiRoot = createGitRepo(); + const remoteUrl = "https://github.com/NeuralNomadsAI/NomadWorks.git"; + const config = [ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n"); + const firstWorktree = createGitWorktree(config, remoteUrl); + const secondWorktree = createGitWorktree(config, remoteUrl); + + const firstPlugin = await NomadWorksPlugin({ worktree: firstWorktree, options: {} }); + const secondPlugin = await NomadWorksPlugin({ worktree: secondWorktree, options: {} }); + const firstStatus = JSON.parse(await firstPlugin.tool.nomadworks_sync_status.execute({}, { worktree: firstWorktree })); + const secondStatus = JSON.parse(await secondPlugin.tool.nomadworks_sync_status.execute({}, { worktree: secondWorktree })); + + expect(firstStatus.workspace_root).toBe(secondStatus.workspace_root); + expect(firstStatus.workspace_root).toContain(path.join("WORKSPACES", "github.com-neuralnomadsai-nomadworks")); + }); + + test("workspace PAI identity honors pai.workspace.id override", async () => { + const paiRoot = createGitRepo(); + const worktree = createGitWorktree([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + " workspace:", + " id: Team Override Repo", + "" + ].join("\n"), "https://github.com/NeuralNomadsAI/NomadWorks.git"); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + const result = JSON.parse(await plugin.tool.nomadworks_sync_status.execute({}, { worktree })); + + expect(result.workspace_root).toContain(path.join("WORKSPACES", "team-override-repo")); + }); + + test("sync push reports failed Git commands as FAIL", async () => { + const paiRoot = createGitRepo(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + await expect(plugin.tool.nomadworks_sync_push.execute({}, { worktree })).resolves.toMatch(/^FAIL: git (commit|push)/); + }); + + test("sync push reports no changes as a no-op instead of failure", async () => { + const paiRoot = createGitRepo(); + spawnSync("git", ["config", "user.email", "test@example.com"], { cwd: paiRoot, encoding: "utf8", shell: false }); + spawnSync("git", ["config", "user.name", "NomadWorks Test"], { cwd: paiRoot, encoding: "utf8", shell: false }); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + spawnSync("git", ["add", "."], { cwd: paiRoot, encoding: "utf8", shell: false }); + spawnSync("git", ["commit", "-m", "seed pai"], { cwd: paiRoot, encoding: "utf8", shell: false }); + + const secondResult = JSON.parse(await plugin.tool.nomadworks_sync_push.execute({}, { worktree })); + + expect(secondResult.status).toBe("no_changes"); + expect(secondResult.push).toBeNull(); + }); + + test("session export requires an explicit configured PAI root", async () => { + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + await expect(plugin.tool.nomadworks_session_export.execute({ session_ids: "abc" }, { worktree })).resolves.toMatch(/^FAIL: Configure pai\.root/); + }); + + test("session export fails before writing when configured PAI root is not a Git repository", async () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + const paiRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-pai-test-")); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + consoleError.mockRestore(); + + await expect(plugin.tool.nomadworks_session_export.execute({ session_ids: "abc" }, { worktree })).resolves.toMatch(/^FAIL: PAI root is not an existing Git repository/); + expect(fs.existsSync(path.join(paiRoot, "WORKSPACES"))).toBe(false); + }); + + test("session import fails fast when configured PAI root is not a Git repository", async () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + const paiRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-pai-test-")); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + consoleError.mockRestore(); + + await expect(plugin.tool.nomadworks_session_import.execute({}, { worktree })).resolves.toMatch(/^FAIL: PAI root is not an existing Git repository/); + }); + + test("session import rejects manifest entries outside SESSIONS json exports", async () => { + const paiRoot = createGitRepo(); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + ` root: ${JSON.stringify(paiRoot)}`, + "" + ].join("\n")); + const workspaceRoot = path.join(paiRoot, "WORKSPACES", path.basename(worktree).toLowerCase().replace(/[^a-z0-9._-]+/g, "-")); + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, "README.md"), "not a session", "utf8"); + fs.writeFileSync(path.join(workspaceRoot, "manifest.json"), JSON.stringify({ + version: 1, + files: ["README.md"], + opencode_sessions: [{ session_id: "abc", file: "README.md", format: "opencode-export-json" }] + }), "utf8"); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + + const result = JSON.parse(await plugin.tool.nomadworks_session_import.execute({}, { worktree })); + + expect(result.imported_sessions).toEqual([]); + expect(result.failed_sessions).toEqual([{ session_id: "abc", reason: "invalid session export path" }]); + }); + + test("configured PAI root inside the workspace is rejected", async () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + const worktree = createTestEnv([ + "features:", + " debug_dumps: false", + "pai:", + " root: .nomadworks/pai", + "" + ].join("\n")); + const plugin = await NomadWorksPlugin({ worktree, options: {} }); + consoleError.mockRestore(); + + await expect(plugin.tool.nomadworks_sync_status.execute({}, { worktree })).resolves.toMatch(/^FAIL: PAI root must be outside the workspace/); + expect(fs.existsSync(path.join(worktree, ".nomadworks", "pai"))).toBe(false); + }); +});