diff --git a/.agents/skills/company-creator/SKILL.md b/.agents/skills/company-creator/SKILL.md index b94b0e6..9c0690d 100644 --- a/.agents/skills/company-creator/SKILL.md +++ b/.agents/skills/company-creator/SKILL.md @@ -194,6 +194,7 @@ The `.taskcore.yaml` file is the Taskcore vendor extension. It configures adapte **Do not specify an adapter unless the repo or user context warrants it.** If you don't know what adapter the user wants, omit the adapter block entirely — Taskcore will use its default. Specifying an unknown adapter type causes an import error. Taskcore's supported adapter types (these are the ONLY valid values): + - `claude_local` — Claude Code CLI - `codex_local` — Codex CLI - `opencode_local` — OpenCode CLI @@ -203,6 +204,7 @@ Taskcore's supported adapter types (these are the ONLY valid values): - `openclaw_gateway` — OpenClaw gateway Only set an adapter when: + - The repo or its skills clearly target a specific runtime (e.g. gstack is built for Claude Code, so `claude_local` is appropriate) - The user explicitly requests a specific adapter - The agent's role requires a specific runtime capability @@ -210,11 +212,13 @@ Only set an adapter when: ### Env Inputs Rules **Do not add boilerplate env variables.** Only add env inputs that the agent actually needs based on its skills or role: + - `GH_TOKEN` for agents that push code, create PRs, or interact with GitHub - API keys only when a skill explicitly requires them - Never set `ANTHROPIC_API_KEY` as a default empty env variable — the runtime handles this Example with adapter (only when warranted): + ```yaml schema: taskcore/v1 agents: @@ -231,6 +235,7 @@ agents: ``` Example — only agents with actual overrides appear: + ```yaml schema: taskcore/v1 agents: diff --git a/.agents/skills/company-creator/references/from-repo-guide.md b/.agents/skills/company-creator/references/from-repo-guide.md index b945869..6422b69 100644 --- a/.agents/skills/company-creator/references/from-repo-guide.md +++ b/.agents/skills/company-creator/references/from-repo-guide.md @@ -30,11 +30,13 @@ metadata: ``` To get the commit SHA: + ```bash git ls-remote https://github.com/owner/repo HEAD ``` Only vendor (copy) skills when: + - The user explicitly asks to copy them - The skill is very small and tightly coupled to the company - The source repo is private or may become unavailable @@ -42,6 +44,7 @@ Only vendor (copy) skills when: ## Handling Existing Agent Configurations If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, etc.): + - Use them as inspiration for AGENTS.md instructions - Don't copy them verbatim - adapt them to the Agent Companies format - Preserve the intent and key instructions @@ -49,31 +52,37 @@ If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, e ## Repo-Only Skills (No Agents) When a repo contains only skills and no agents: + - Create agents that would naturally use those skills - The agents should be minimal - just enough to give the skills a runtime context - A single agent may use multiple skills from the repo - Name agents based on the domain the skills cover Example: A repo with `code-review`, `testing`, and `deployment` skills might become: + - A "Lead Engineer" agent with all three skills - Or separate "Reviewer", "QA Engineer", and "DevOps" agents if the skills are distinct enough ## Common Repo Patterns ### Developer Tools / CLI repos + - Create agents for the tool's primary use cases - Reference any existing skills - Add a project maintainer or lead agent ### Library / Framework repos + - Create agents for development, testing, documentation - Skills from the repo become agent capabilities ### Full Application repos + - Map to departments: engineering, product, QA - Create a lean team structure appropriate to the project size ### Skills Collection repos (e.g. skills.sh repos) + - Each skill or skill group gets an agent - Create a lightweight company or team wrapper - Keep the agent count proportional to the skill diversity diff --git a/.agents/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md index dcd6456..7643103 100644 --- a/.agents/skills/create-agent-adapter/SKILL.md +++ b/.agents/skills/create-agent-adapter/SKILL.md @@ -37,11 +37,11 @@ packages/adapters// Three separate registries consume adapter modules: -| Registry | Location | Interface | -|----------|----------|-----------| -| Server | `server/src/adapters/registry.ts` | `ServerAdapterModule` | -| UI | `ui/src/adapters/registry.ts` | `UIAdapterModule` | -| CLI | `cli/src/adapters/registry.ts` | `CLIAdapterModule` | +| Registry | Location | Interface | +| -------- | --------------------------------- | --------------------- | +| Server | `server/src/adapters/registry.ts` | `ServerAdapterModule` | +| UI | `ui/src/adapters/registry.ts` | `UIAdapterModule` | +| CLI | `cli/src/adapters/registry.ts` | `CLIAdapterModule` | --- @@ -55,9 +55,9 @@ All adapter interfaces live in `packages/adapter-utils/src/types.ts`. Import fro // The execute function signature — every adapter must implement this interface AdapterExecutionContext { runId: string; - agent: AdapterAgent; // { id, companyId, name, adapterType, adapterConfig } - runtime: AdapterRuntime; // { sessionId, sessionParams, sessionDisplayId, taskKey } - config: Record; // The agent's adapterConfig blob + agent: AdapterAgent; // { id, companyId, name, adapterType, adapterConfig } + runtime: AdapterRuntime; // { sessionId, sessionParams, sessionDisplayId, taskKey } + config: Record; // The agent's adapterConfig blob context: Record; // Runtime context (taskId, wakeReason, approvalId, etc.) onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; @@ -69,21 +69,23 @@ interface AdapterExecutionResult { signal: string | null; timedOut: boolean; errorMessage?: string | null; - usage?: UsageSummary; // { inputTokens, outputTokens, cachedInputTokens? } - sessionId?: string | null; // Legacy — prefer sessionParams - sessionParams?: Record | null; // Opaque session state persisted between runs + usage?: UsageSummary; // { inputTokens, outputTokens, cachedInputTokens? } + sessionId?: string | null; // Legacy — prefer sessionParams + sessionParams?: Record | null; // Opaque session state persisted between runs sessionDisplayId?: string | null; - provider?: string | null; // "anthropic", "openai", etc. + provider?: string | null; // "anthropic", "openai", etc. model?: string | null; costUsd?: number | null; resultJson?: Record | null; - summary?: string | null; // Human-readable summary of what the agent did - clearSession?: boolean; // true = tell Paperclip to forget the stored session + summary?: string | null; // Human-readable summary of what the agent did + clearSession?: boolean; // true = tell Paperclip to forget the stored session } interface AdapterSessionCodec { deserialize(raw: unknown): Record | null; - serialize(params: Record | null): Record | null; + serialize( + params: Record | null, + ): Record | null; getDisplayId?(params: Record | null): string | null; } ``` @@ -95,7 +97,9 @@ interface AdapterSessionCodec { interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; - testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; + testEnvironment( + ctx: AdapterEnvironmentTestContext, + ): Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: { id: string; label: string }[]; @@ -211,7 +215,7 @@ packages/adapters// This file is imported by **all three** consumers (server, UI, CLI). Keep it dependency-free (no Node APIs, no React). ```ts -export const type = "my_agent"; // snake_case, globally unique +export const type = "my_agent"; // snake_case, globally unique export const label = "My Agent (local)"; export const models = [ @@ -225,6 +229,7 @@ export const agentConfigurationDoc = `# my_agent agent configuration ``` **Required exports:** + - `type` — the adapter type key, stored in `agents.adapter_type` - `label` — human-readable name for the UI - `models` — available model options for the agent creation form @@ -277,19 +282,19 @@ This is the most important file. It receives an `AdapterExecutionContext` and mu **Environment variables the server always injects:** -| Variable | Source | -|----------|--------| -| `PAPERCLIP_AGENT_ID` | `agent.id` | -| `PAPERCLIP_COMPANY_ID` | `agent.companyId` | -| `PAPERCLIP_API_URL` | Server's own URL | -| `PAPERCLIP_RUN_ID` | Current run id | -| `PAPERCLIP_TASK_ID` | `context.taskId` or `context.issueId` | -| `PAPERCLIP_WAKE_REASON` | `context.wakeReason` | -| `PAPERCLIP_WAKE_COMMENT_ID` | `context.wakeCommentId` or `context.commentId` | -| `PAPERCLIP_APPROVAL_ID` | `context.approvalId` | -| `PAPERCLIP_APPROVAL_STATUS` | `context.approvalStatus` | -| `PAPERCLIP_LINKED_ISSUE_IDS` | `context.issueIds` (comma-separated) | -| `PAPERCLIP_API_KEY` | `authToken` (if no explicit key in config) | +| Variable | Source | +| ---------------------------- | ---------------------------------------------- | +| `PAPERCLIP_AGENT_ID` | `agent.id` | +| `PAPERCLIP_COMPANY_ID` | `agent.companyId` | +| `PAPERCLIP_API_URL` | Server's own URL | +| `PAPERCLIP_RUN_ID` | Current run id | +| `PAPERCLIP_TASK_ID` | `context.taskId` or `context.issueId` | +| `PAPERCLIP_WAKE_REASON` | `context.wakeReason` | +| `PAPERCLIP_WAKE_COMMENT_ID` | `context.wakeCommentId` or `context.commentId` | +| `PAPERCLIP_APPROVAL_ID` | `context.approvalId` | +| `PAPERCLIP_APPROVAL_STATUS` | `context.approvalStatus` | +| `PAPERCLIP_LINKED_ISSUE_IDS` | `context.issueIds` (comma-separated) | +| `PAPERCLIP_API_KEY` | `authToken` (if no explicit key in config) | #### `server/parse.ts` — Output Parser @@ -303,6 +308,7 @@ Parse the agent's stdout format into structured data. Must handle: - **Unknown session detection** — export an `isUnknownSessionError()` function for retry logic **Treat agent output as untrusted.** The stdout you're parsing comes from an LLM-driven process that may have executed arbitrary tool calls, fetched external content, or been influenced by prompt injection in the files it read. Parse defensively: + - Never `eval()` or dynamically execute anything from output - Use safe extraction helpers (`asString`, `asNumber`, `parseJson`) — they return fallbacks on unexpected types - Validate session IDs and other structured data before passing them through @@ -317,9 +323,15 @@ export { parseMyAgentOutput, isMyAgentUnknownSessionError } from "./parse.js"; // Session codec — required for session persistence export const sessionCodec: AdapterSessionCodec = { - deserialize(raw) { /* raw DB JSON -> typed params or null */ }, - serialize(params) { /* typed params -> JSON for DB storage */ }, - getDisplayId(params) { /* -> human-readable session id string */ }, + deserialize(raw) { + /* raw DB JSON -> typed params or null */ + }, + serialize(params) { + /* typed params -> JSON for DB storage */ + }, + getDisplayId(params) { + /* -> human-readable session id string */ + }, }; ``` @@ -355,7 +367,10 @@ Converts individual stdout lines into `TranscriptEntry[]` for the run detail vie - `stdout` — fallback for unparseable lines ```ts -export function parseMyAgentStdoutLine(line: string, ts: string): TranscriptEntry[] { +export function parseMyAgentStdoutLine( + line: string, + ts: string, +): TranscriptEntry[] { // Parse JSON line, map to appropriate TranscriptEntry kind(s) // Return [{ kind: "stdout", ts, text: line }] as fallback } @@ -366,7 +381,9 @@ export function parseMyAgentStdoutLine(line: string, ts: string): TranscriptEntr Converts the UI form's `CreateConfigValues` into the `adapterConfig` JSON blob stored on the agent. ```ts -export function buildMyAgentConfig(v: CreateConfigValues): Record { +export function buildMyAgentConfig( + v: CreateConfigValues, +): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; @@ -383,6 +400,7 @@ export function buildMyAgentConfig(v: CreateConfigValues): Record/config-fields.tsx` with a React component implementing `AdapterConfigFieldsProps`. This renders adapter-specific form fields in the agent creation/edit form. Use the shared primitives from `ui/src/components/agent-config-primitives`: + - `Field` — labeled form field wrapper - `ToggleField` — boolean toggle with label and hint - `DraftInput` — text input with draft/commit behavior @@ -483,6 +501,7 @@ Sessions allow agents to maintain conversation context across runs. The system i **Design for long runs from the start.** Treat session reuse as the default primitive, not an optimization to add later. An agent working on an issue may be woken dozens of times — for the initial assignment, approval callbacks, re-assignments, manual nudges. Each wake should resume the existing conversation so the agent retains full context about what it has already done, what files it has read, and what decisions it has made. Starting fresh each time wastes tokens on re-reading the same files and risks contradictory decisions. **Key concepts:** + - `sessionParams` is an opaque `Record` stored in the DB per task - The adapter's `sessionCodec.serialize()` converts execution result data to storable params - `sessionCodec.deserialize()` converts stored params back for the next run @@ -497,13 +516,19 @@ If the agent runtime supports any form of context compaction or conversation com ```ts const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || + path.resolve(runtimeSessionCwd) === path.resolve(cwd)); const sessionId = canResumeSession ? runtimeSessionId : null; // ... run attempt ... // If resume failed with unknown session, retry fresh -if (sessionId && !proc.timedOut && exitCode !== 0 && isUnknownSessionError(output)) { +if ( + sessionId && + !proc.timedOut && + exitCode !== 0 && + isUnknownSessionError(output) +) { const retry = await runAttempt(null); return toResult(retry, { clearSessionOnMissingSession: true }); } @@ -515,48 +540,53 @@ if (sessionId && !proc.timedOut && exitCode !== 0 && isUnknownSessionError(outpu Import from `@paperclipai/adapter-utils/server-utils`: -| Helper | Purpose | -|--------|---------| -| `asString(val, fallback)` | Safe string extraction | -| `asNumber(val, fallback)` | Safe number extraction | -| `asBoolean(val, fallback)` | Safe boolean extraction | -| `asStringArray(val)` | Safe string array extraction | -| `parseObject(val)` | Safe `Record` extraction | -| `parseJson(str)` | Safe JSON.parse returning `Record` or null | -| `renderTemplate(tmpl, data)` | `{{path.to.value}}` template rendering | -| `buildPaperclipEnv(agent)` | Standard `PAPERCLIP_*` env vars | -| `redactEnvForLogs(env)` | Redact sensitive keys for onMeta | -| `ensureAbsoluteDirectory(cwd)` | Validate cwd exists and is absolute | -| `ensureCommandResolvable(cmd, cwd, env)` | Validate command is in PATH | -| `ensurePathInEnv(env)` | Ensure PATH exists in env | -| `runChildProcess(runId, cmd, args, opts)` | Spawn with timeout, logging, capture | +| Helper | Purpose | +| ----------------------------------------- | ------------------------------------------ | +| `asString(val, fallback)` | Safe string extraction | +| `asNumber(val, fallback)` | Safe number extraction | +| `asBoolean(val, fallback)` | Safe boolean extraction | +| `asStringArray(val)` | Safe string array extraction | +| `parseObject(val)` | Safe `Record` extraction | +| `parseJson(str)` | Safe JSON.parse returning `Record` or null | +| `renderTemplate(tmpl, data)` | `{{path.to.value}}` template rendering | +| `buildPaperclipEnv(agent)` | Standard `PAPERCLIP_*` env vars | +| `redactEnvForLogs(env)` | Redact sensitive keys for onMeta | +| `ensureAbsoluteDirectory(cwd)` | Validate cwd exists and is absolute | +| `ensureCommandResolvable(cmd, cwd, env)` | Validate command is in PATH | +| `ensurePathInEnv(env)` | Ensure PATH exists in env | +| `runChildProcess(runId, cmd, args, opts)` | Spawn with timeout, logging, capture | --- ## 7. Conventions and Patterns ### Naming + - Adapter type: `snake_case` (e.g. `claude_local`, `codex_local`) - Package name: `@paperclipai/adapter-` - Package directory: `packages/adapters//` ### Config Parsing + - Never trust `config` values directly — always use `asString`, `asNumber`, etc. - Provide sensible defaults for every optional field - Document all fields in `agentConfigurationDoc` ### Prompt Templates + - Support `promptTemplate` for every run - Use `renderTemplate()` with the standard variable set - Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."` ### Error Handling + - Differentiate timeout vs process error vs parse failure - Always populate `errorMessage` on failure - Include raw stdout/stderr in `resultJson` when parsing fails - Handle the agent CLI not being installed (command not found) ### Logging + - Call `onLog("stdout", ...)` and `onLog("stderr", ...)` for all process output — this feeds the real-time run viewer - Call `onMeta(...)` before spawning to record invocation details - Use `redactEnvForLogs()` when including env in meta @@ -583,7 +613,9 @@ async function buildSkillsDir(): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); const target = path.join(tmp, ".claude", "skills"); await fs.mkdir(target, { recursive: true }); - const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true }); + const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { + withFileTypes: true, + }); for (const entry of entries) { if (entry.isDirectory()) { await fs.symlink( @@ -614,7 +646,7 @@ async function ensureCodexSkillsInjected(onLog) { for (const entry of entries) { const target = path.join(skillsHome, entry.name); const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; // Don't overwrite user's own skills + if (existing) continue; // Don't overwrite user's own skills await fs.symlink(source, target); } } @@ -671,18 +703,18 @@ If your agent runtime supports network access controls (sandboxing, allowlists), The UI run viewer displays these entry kinds: -| Kind | Fields | Usage | -|------|--------|-------| -| `init` | `model`, `sessionId` | Agent initialization | -| `assistant` | `text` | Agent text response | -| `thinking` | `text` | Agent reasoning/thinking | -| `user` | `text` | User message | -| `tool_call` | `name`, `input` | Tool invocation | -| `tool_result` | `toolUseId`, `content`, `isError` | Tool result | -| `result` | `text`, `inputTokens`, `outputTokens`, `cachedTokens`, `costUsd`, `subtype`, `isError`, `errors` | Final result with usage | -| `stderr` | `text` | Stderr output | -| `system` | `text` | System messages | -| `stdout` | `text` | Raw stdout fallback | +| Kind | Fields | Usage | +| ------------- | ------------------------------------------------------------------------------------------------ | ------------------------ | +| `init` | `model`, `sessionId` | Agent initialization | +| `assistant` | `text` | Agent text response | +| `thinking` | `text` | Agent reasoning/thinking | +| `user` | `text` | User message | +| `tool_call` | `name`, `input` | Tool invocation | +| `tool_result` | `toolUseId`, `content`, `isError` | Tool result | +| `result` | `text`, `inputTokens`, `outputTokens`, `cachedTokens`, `costUsd`, `subtype`, `isError`, `errors` | Final result with usage | +| `stderr` | `text` | Stderr output | +| `system` | `text` | System messages | +| `stdout` | `text` | Raw stdout fallback | --- diff --git a/.agents/skills/deal-with-security-advisory/SKILL.md b/.agents/skills/deal-with-security-advisory/SKILL.md index 0ba1223..e7eb16d 100644 --- a/.agents/skills/deal-with-security-advisory/SKILL.md +++ b/.agents/skills/deal-with-security-advisory/SKILL.md @@ -11,18 +11,18 @@ description: > ## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades. -*** +--- ## Context A security vulnerability has been reported via GitHub Security Advisory: -* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7) -* **Reporter:** {{reporterHandle}} -* **Severity:** {{severity}} -* **Notes:** {{notes}} +- **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7) +- **Reporter:** {{reporterHandle}} +- **Severity:** {{severity}} +- **Notes:** {{notes}} -*** +--- ## Step 0: Fetch the Advisory Details @@ -71,26 +71,26 @@ git checkout -b security-fix **TIPS:** -* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this -* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix` -* All work stays in the private fork until publication -* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally +- Do not commit `pnpm-lock.yaml` — the repo has actions to manage this +- Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix` +- All work stays in the private fork until publication +- CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally ## Step 3: Develop and Validate the Fix Write the patch. Same content standards as any PR: -* It must functionally work — **run tests locally** since CI won't run on the private fork -* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch -* Ensure backwards compatibility for the database, or be explicit about what breaks -* Make sure any UI components still look correct if the fix touches them -* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why +- It must functionally work — **run tests locally** since CI won't run on the private fork +- Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch +- Ensure backwards compatibility for the database, or be explicit about what breaks +- Make sure any UI components still look correct if the fix touches them +- The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why **Specific to security fixes:** -* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it -* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem? -* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows +- Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it +- Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem? +- Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows Push your fix to the private fork: @@ -175,9 +175,9 @@ EOF Publishing the advisory simultaneously: -* Makes the GHSA public -* Merges the temporary private fork into your repo -* Triggers the CVE assignment (if requested in step 5) +- Makes the GHSA public +- Merges the temporary private fork into your repo +- Triggers the CVE assignment (if requested in step 5) ### 6c. Cut a release immediately after merge @@ -224,7 +224,7 @@ If the CVE hasn't been assigned yet, that's normal — it can take a few hours. Tell the human operator what you did by posting a comment to this task, including: -* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}` -* The release URL -* Whether the CVE has been assigned yet -* All URLs to any pull requests or branches +- The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}` +- The release URL +- Whether the CVE has been assigned yet +- All URLs to any pull requests or branches diff --git a/.agents/skills/doc-maintenance/SKILL.md b/.agents/skills/doc-maintenance/SKILL.md index a597e90..d417eb6 100644 --- a/.agents/skills/doc-maintenance/SKILL.md +++ b/.agents/skills/doc-maintenance/SKILL.md @@ -21,11 +21,11 @@ Detect documentation drift and fix it via PR — no rewrites, no churn. ## Target Documents -| Document | Path | What matters | -|----------|------|-------------| -| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table | -| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy | -| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy | +| Document | Path | What matters | +| -------- | ---------------- | --------------------------------------------------------------------------- | +| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table | +| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy | +| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy | Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files, release notes. These are dev-facing or ephemeral — lower risk of user-facing @@ -169,15 +169,15 @@ were needed, commit the cursor update to the current branch. ## Change Classification Rules -| Signal | Category | Doc update needed? | -|--------|----------|-------------------| -| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing | -| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes | -| New top-level directory or config file | Structural | Maybe | -| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) | -| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No | -| `docs:` | Doc change | No (already handled) | -| Dependency bumps only | Maintenance | No | +| Signal | Category | Doc update needed? | +| ------------------------------------------------- | ----------- | ------------------------------------------------- | +| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing | +| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes | +| New top-level directory or config file | Structural | Maybe | +| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) | +| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No | +| `docs:` | Doc change | No (already handled) | +| Dependency bumps only | Maintenance | No | ## Patch Style Guide diff --git a/.agents/skills/doc-maintenance/references/audit-checklist.md b/.agents/skills/doc-maintenance/references/audit-checklist.md index 9c13a43..539b88d 100644 --- a/.agents/skills/doc-maintenance/references/audit-checklist.md +++ b/.agents/skills/doc-maintenance/references/audit-checklist.md @@ -6,80 +6,97 @@ against the change summary from git history. ## README.md ### Features table + - [ ] Each feature card reflects a shipped capability - [ ] No feature cards for things that don't exist yet - [ ] No major shipped features missing from the table ### Roadmap + - [ ] Nothing listed as "planned" or "coming soon" that already shipped - [ ] No removed/cancelled items still listed - [ ] Items reflect current priorities (cross-check with recent PRs) ### Quickstart + - [ ] `npx paperclipai onboard` command is correct - [ ] Manual install steps are accurate (clone URL, commands) - [ ] Prerequisites (Node version, pnpm version) are current - [ ] Server URL and port are correct ### "What is Paperclip" section + - [ ] High-level description is accurate - [ ] Step table (Define goal / Hire team / Approve and run) is correct ### "Works with" table + - [ ] All supported adapters/runtimes are listed - [ ] No removed adapters still listed - [ ] Logos and labels match current adapter names ### "Paperclip is right for you if" + - [ ] Use cases are still accurate - [ ] No claims about capabilities that don't exist ### "Why Paperclip is special" + - [ ] Technical claims are accurate (atomic execution, governance, etc.) - [ ] No features listed that were removed or significantly changed ### FAQ + - [ ] Answers are still correct - [ ] No references to removed features or outdated behavior ### Development section + - [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.) - [ ] Link to DEVELOPING.md is correct ## doc/SPEC.md ### Company Model + - [ ] Fields match current schema - [ ] Governance model description is accurate ### Agent Model + - [ ] Adapter types match what's actually supported - [ ] Agent configuration description is accurate - [ ] No features described as "not supported" or "not V1" that shipped ### Task Model + - [ ] Task hierarchy description is accurate - [ ] Status values match current implementation ### Extensions / Plugins + - [ ] If plugins are shipped, no "not in V1" or "future" language - [ ] Plugin model description matches implementation ### Open Questions + - [ ] Resolved questions removed or updated - [ ] No "TBD" items that have been decided ## doc/PRODUCT.md ### Core Concepts + - [ ] Company, Employees, Task Management descriptions accurate - [ ] Agent Execution modes described correctly - [ ] No missing major concepts ### Principles + - [ ] Principles haven't been contradicted by shipped features - [ ] No principles referencing removed capabilities ### User Flow + - [ ] Dream scenario still reflects actual onboarding - [ ] Steps are achievable with current features diff --git a/.agents/skills/doc-maintenance/references/section-map.md b/.agents/skills/doc-maintenance/references/section-map.md index 4ec64f8..179a0e8 100644 --- a/.agents/skills/doc-maintenance/references/section-map.md +++ b/.agents/skills/doc-maintenance/references/section-map.md @@ -3,20 +3,20 @@ Maps feature areas to specific document sections so the skill knows where to look when a feature ships or changes. -| Feature Area | README Section | SPEC Section | PRODUCT Section | -|-------------|---------------|-------------|----------------| -| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts | -| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution | -| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles | -| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) | -| Task Management | Features table | Task Model | Task Management | -| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents | -| Multi-Company | Features table, FAQ | Company Model | Company | -| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution | -| CLI Commands | Development section | — | — | -| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow | -| Skills / Skill Injection | "Why special" | — | — | -| Company Templates | "Why special", Roadmap (ClipMart) | — | — | -| Mobile / UI | Features table | — | — | -| Project Archiving | — | — | — | -| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution | +| Feature Area | README Section | SPEC Section | PRODUCT Section | +| ------------------------ | --------------------------------- | -------------------------------------- | ----------------------------------- | +| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts | +| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution | +| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles | +| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) | +| Task Management | Features table | Task Model | Task Management | +| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents | +| Multi-Company | Features table, FAQ | Company Model | Company | +| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution | +| CLI Commands | Development section | — | — | +| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow | +| Skills / Skill Injection | "Why special" | — | — | +| Company Templates | "Why special", Roadmap (ClipMart) | — | — | +| Mobile / UI | Features table | — | — | +| Project Archiving | — | — | — | +| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution | diff --git a/.agents/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md index 5064b67..a9fec85 100644 --- a/.agents/skills/pr-report/SKILL.md +++ b/.agents/skills/pr-report/SKILL.md @@ -57,7 +57,7 @@ Gather: - relevant repo docs, specs, and invariants - contributor intent if it is documented in PR text or design docs -Start by answering: what is this change *trying* to become? +Start by answering: what is this change _trying_ to become? ### 2. Build a mental model of the system diff --git a/.agents/skills/prcheckloop/SKILL.md b/.agents/skills/prcheckloop/SKILL.md index 70d9e19..f71b00b 100644 --- a/.agents/skills/prcheckloop/SKILL.md +++ b/.agents/skills/prcheckloop/SKILL.md @@ -128,13 +128,13 @@ gh run view --log-failed For each failing check, classify it: -| Failure type | Action | -|---|---| -| Code/test regression | Reproduce locally, fix, and verify | -| Lint/type/build mismatch | Run the matching local command from the workflow and fix it | -| Flake or transient infra issue | Rerun once if evidence supports flakiness | -| External service/status app failure | Escalate with the details URL and owner guess | -| Missing secret/permission/branch protection issue | Escalate immediately | +| Failure type | Action | +| ------------------------------------------------- | ----------------------------------------------------------- | +| Code/test regression | Reproduce locally, fix, and verify | +| Lint/type/build mismatch | Run the matching local command from the workflow and fix it | +| Flake or transient infra issue | Rerun once if evidence supports flakiness | +| External service/status app failure | Escalate with the details URL and owner guess | +| Missing secret/permission/branch protection issue | Escalate immediately | Only rerun a failed job once without code changes. Do not loop on reruns. diff --git a/.claude/skills/design-guide/SKILL.md b/.claude/skills/design-guide/SKILL.md index cde10f7..cad844c 100644 --- a/.claude/skills/design-guide/SKILL.md +++ b/.claude/skills/design-guide/SKILL.md @@ -51,19 +51,19 @@ All tokens defined as CSS variables in `ui/src/index.css`. Both light and dark t Use semantic token names, never raw color values: -| Token | Usage | -|-------|-------| -| `--background` / `--foreground` | Page background and primary text | -| `--card` / `--card-foreground` | Card surfaces | -| `--primary` / `--primary-foreground` | Primary actions, emphasis | -| `--secondary` / `--secondary-foreground` | Secondary surfaces | -| `--muted` / `--muted-foreground` | Subdued text, labels | -| `--accent` / `--accent-foreground` | Hover states, active nav items | -| `--destructive` | Destructive actions | -| `--border` | All borders | -| `--ring` | Focus rings | -| `--sidebar-*` | Sidebar-specific variants | -| `--chart-1` through `--chart-5` | Data visualization | +| Token | Usage | +| ---------------------------------------- | -------------------------------- | +| `--background` / `--foreground` | Page background and primary text | +| `--card` / `--card-foreground` | Card surfaces | +| `--primary` / `--primary-foreground` | Primary actions, emphasis | +| `--secondary` / `--secondary-foreground` | Secondary surfaces | +| `--muted` / `--muted-foreground` | Subdued text, labels | +| `--accent` / `--accent-foreground` | Hover states, active nav items | +| `--destructive` | Destructive actions | +| `--border` | All borders | +| `--ring` | Focus rings | +| `--sidebar-*` | Sidebar-specific variants | +| `--chart-1` through `--chart-5` | Data visualization | ### Radius @@ -85,18 +85,18 @@ Minimal shadows: `shadow-xs` (outline buttons), `shadow-sm` (cards). No heavy sh Use these exact patterns — do not invent new ones: -| Pattern | Classes | Usage | -|---------|---------|-------| -| Page title | `text-xl font-bold` | Top of pages | -| Section title | `text-lg font-semibold` | Major sections | +| Pattern | Classes | Usage | +| --------------- | --------------------------------------------------------------------- | ---------------------------------------- | +| Page title | `text-xl font-bold` | Top of pages | +| Section title | `text-lg font-semibold` | Major sections | | Section heading | `text-sm font-semibold text-muted-foreground uppercase tracking-wide` | Section headers in design guide, sidebar | -| Card title | `text-sm font-medium` or `text-sm font-semibold` | Card headers, list item titles | -| Body | `text-sm` | Default body text | -| Muted | `text-sm text-muted-foreground` | Descriptions, secondary text | -| Tiny label | `text-xs text-muted-foreground` | Metadata, timestamps, property labels | -| Mono identifier | `text-xs font-mono text-muted-foreground` | Issue keys (PAP-001), CSS vars | -| Large stat | `text-2xl font-bold` | Dashboard metric values | -| Code/log | `font-mono text-xs` | Log output, code snippets | +| Card title | `text-sm font-medium` or `text-sm font-semibold` | Card headers, list item titles | +| Body | `text-sm` | Default body text | +| Muted | `text-sm text-muted-foreground` | Descriptions, secondary text | +| Tiny label | `text-xs text-muted-foreground` | Metadata, timestamps, property labels | +| Mono identifier | `text-xs font-mono text-muted-foreground` | Issue keys (PAP-001), CSS vars | +| Large stat | `text-2xl font-bold` | Dashboard metric values | +| Code/log | `font-mono text-xs` | Log output, code snippets | --- @@ -106,17 +106,17 @@ Use these exact patterns — do not invent new ones: Defined in `StatusBadge.tsx` and `StatusIcon.tsx`: -| Status | Color | Entity types | -|--------|-------|-------------| +| Status | Color | Entity types | +| ------------------------------------------------------ | ------------ | -------------------------------- | | active, achieved, completed, succeeded, approved, done | Green shades | Agents, goals, issues, approvals | -| running | Cyan | Agents | -| paused | Orange | Agents | -| idle, pending | Yellow | Agents, approvals | -| failed, error, rejected, blocked | Red shades | Runs, agents, approvals, issues | -| archived, planned, backlog, cancelled | Neutral gray | Various | -| todo | Blue | Issues | -| in_progress | Indigo | Issues | -| in_review | Violet | Issues | +| running | Cyan | Agents | +| paused | Orange | Agents | +| idle, pending | Yellow | Agents, approvals | +| failed, error, rejected, blocked | Red shades | Runs, agents, approvals, issues | +| archived, planned, backlog, cancelled | Neutral gray | Various | +| todo | Blue | Issues | +| in_progress | Indigo | Issues | +| in_review | Violet | Issues | ### Priority Icons @@ -141,11 +141,13 @@ Three tiers: ### When to Create a New Component Create a reusable component when: + - The same visual pattern appears in 2+ places - The pattern has interactive behavior (status changing, inline editing) - The pattern encodes domain logic (status colors, priority icons) Do NOT create a component for: + - One-off layouts specific to a single page - Simple className combinations (use Tailwind directly) - Thin wrappers that add no semantic value @@ -162,7 +164,12 @@ The standard list item for issues and similar entities: ```tsx } + leading={ + <> + + + + } identifier="PAP-001" title="Implement authentication flow" subtitle="Assigned to Agent Alpha" @@ -208,7 +215,12 @@ Dashboard metrics in a responsive grid: ```tsx
- + ...
``` @@ -219,7 +231,10 @@ Color by threshold: green (<60%), yellow (60-85%), red (>85%): ```tsx
-
+
``` @@ -300,12 +315,8 @@ This is the living showcase of every component and pattern in the app. It is the ```tsx
- - {/* Show all variants */} - - - {/* Show all sizes */} - + {/* Show all variants */} + {/* Show all sizes */} {/* Show interactive/disabled states */} @@ -319,6 +330,7 @@ This is the living showcase of every component and pattern in the app. It is the **See [references/component-index.md](references/component-index.md) for the full component inventory.** When you create a new reusable component: + 1. Add it to the component index reference file 2. Add it to the /design-guide page 3. Follow existing naming and file conventions diff --git a/.claude/skills/design-guide/references/component-index.md b/.claude/skills/design-guide/references/component-index.md index 1673752..3fc8d46 100644 --- a/.claude/skills/design-guide/references/component-index.md +++ b/.claude/skills/design-guide/references/component-index.md @@ -22,29 +22,29 @@ Location: `ui/src/components/ui/` These are shadcn/ui base components. Do not modify directly — extend via composition. -| Component | File | Key Props | Notes | -|-----------|------|-----------|-------| -| Button | `button.tsx` | `variant` (default, secondary, outline, ghost, destructive, link), `size` (xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg) | Primary interactive element. Uses CVA. | -| Card | `card.tsx` | CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter | Compound component. `py-6` default padding. | -| Input | `input.tsx` | `disabled` | Standard text input. | -| Badge | `badge.tsx` | `variant` (default, secondary, outline, destructive, ghost) | Generic label/tag. For status, use StatusBadge instead. | -| Label | `label.tsx` | — | Form label, wraps Radix Label. | -| Select | `select.tsx` | Trigger, Content, Item, etc. | Radix-based dropdown select. | -| Separator | `separator.tsx` | `orientation` (horizontal, vertical) | Divider line. | -| Checkbox | `checkbox.tsx` | `checked`, `onCheckedChange` | Radix checkbox with indicator. | -| Textarea | `textarea.tsx` | Standard textarea props | Multi-line input. | -| Avatar | `avatar.tsx` | `size` (sm, default, lg). Includes AvatarGroup, AvatarGroupCount | Image or fallback initials. | -| Breadcrumb | `breadcrumb.tsx` | BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage | Navigation breadcrumbs. | -| Command | `command.tsx` | CommandInput, CommandList, CommandGroup, CommandItem | Command palette / search. Based on cmdk. | -| Dialog | `dialog.tsx` | DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter | Modal overlay. | -| DropdownMenu | `dropdown-menu.tsx` | Trigger, Content, Item, Separator, etc. | Context/action menus. | -| Popover | `popover.tsx` | PopoverTrigger, PopoverContent | Floating content panel. | -| Tabs | `tabs.tsx` | `variant` (pill, line). TabsList, TabsTrigger, TabsContent | Tabbed navigation. Pill = default, line = underline style. | -| Tooltip | `tooltip.tsx` | TooltipTrigger, TooltipContent | Hover tooltips. App is wrapped in TooltipProvider. | -| ScrollArea | `scroll-area.tsx` | — | Custom scrollable container. | -| Collapsible | `collapsible.tsx` | CollapsibleTrigger, CollapsibleContent | Expand/collapse sections. | -| Skeleton | `skeleton.tsx` | className for sizing | Loading placeholder with shimmer. | -| Sheet | `sheet.tsx` | SheetTrigger, SheetContent, SheetHeader, etc. | Side panel overlay. | +| Component | File | Key Props | Notes | +| ------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Button | `button.tsx` | `variant` (default, secondary, outline, ghost, destructive, link), `size` (xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg) | Primary interactive element. Uses CVA. | +| Card | `card.tsx` | CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter | Compound component. `py-6` default padding. | +| Input | `input.tsx` | `disabled` | Standard text input. | +| Badge | `badge.tsx` | `variant` (default, secondary, outline, destructive, ghost) | Generic label/tag. For status, use StatusBadge instead. | +| Label | `label.tsx` | — | Form label, wraps Radix Label. | +| Select | `select.tsx` | Trigger, Content, Item, etc. | Radix-based dropdown select. | +| Separator | `separator.tsx` | `orientation` (horizontal, vertical) | Divider line. | +| Checkbox | `checkbox.tsx` | `checked`, `onCheckedChange` | Radix checkbox with indicator. | +| Textarea | `textarea.tsx` | Standard textarea props | Multi-line input. | +| Avatar | `avatar.tsx` | `size` (sm, default, lg). Includes AvatarGroup, AvatarGroupCount | Image or fallback initials. | +| Breadcrumb | `breadcrumb.tsx` | BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage | Navigation breadcrumbs. | +| Command | `command.tsx` | CommandInput, CommandList, CommandGroup, CommandItem | Command palette / search. Based on cmdk. | +| Dialog | `dialog.tsx` | DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter | Modal overlay. | +| DropdownMenu | `dropdown-menu.tsx` | Trigger, Content, Item, Separator, etc. | Context/action menus. | +| Popover | `popover.tsx` | PopoverTrigger, PopoverContent | Floating content panel. | +| Tabs | `tabs.tsx` | `variant` (pill, line). TabsList, TabsTrigger, TabsContent | Tabbed navigation. Pill = default, line = underline style. | +| Tooltip | `tooltip.tsx` | TooltipTrigger, TooltipContent | Hover tooltips. App is wrapped in TooltipProvider. | +| ScrollArea | `scroll-area.tsx` | — | Custom scrollable container. | +| Collapsible | `collapsible.tsx` | CollapsibleTrigger, CollapsibleContent | Expand/collapse sections. | +| Skeleton | `skeleton.tsx` | className for sizing | Loading placeholder with shimmer. | +| Sheet | `sheet.tsx` | SheetTrigger, SheetContent, SheetHeader, etc. | Side panel overlay. | --- @@ -96,7 +96,12 @@ Supports: critical, high, medium, low. Use alongside StatusIcon in entity row le ```tsx } + leading={ + <> + + + + } identifier="PAP-003" title="Write API documentation" trailing={} @@ -113,7 +118,12 @@ Wrap multiple EntityRows in a `border border-border rounded-md` container. **Usage:** Dashboard stat card with icon, large value, label, and optional description. ```tsx - + ``` Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`. @@ -125,7 +135,12 @@ Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`. **Usage:** Empty list placeholder with icon, message, and optional CTA button. ```tsx - + ``` ### FilterBar @@ -136,7 +151,11 @@ Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`. **Usage:** Filter chip display with remove buttons and clear all. ```tsx - setFilters([])} /> + setFilters([])} +/> ``` ### Identity @@ -160,7 +179,12 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag **Usage:** Click-to-edit text. Renders as display text, clicking enters edit mode. Enter saves, Escape cancels. ```tsx - + ``` ### PageSkeleton @@ -258,12 +282,12 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag These render inside the PropertiesPanel for different entity types: -| Component | File | Entity | -|-----------|------|--------| -| IssueProperties | `IssueProperties.tsx` | Issues | -| AgentProperties | `AgentProperties.tsx` | Agents | +| Component | File | Entity | +| ----------------- | ----------------------- | -------- | +| IssueProperties | `IssueProperties.tsx` | Issues | +| AgentProperties | `AgentProperties.tsx` | Agents | | ProjectProperties | `ProjectProperties.tsx` | Projects | -| GoalProperties | `GoalProperties.tsx` | Goals | +| GoalProperties | `GoalProperties.tsx` | Goals | All follow the property row pattern: `text-xs text-muted-foreground` label on left, value on right, `py-1.5` spacing. @@ -293,19 +317,19 @@ All follow the property row pattern: `text-xs text-muted-foreground` label on le ```tsx import { cn } from "@/lib/utils"; -
+
; ``` ### Formatting Utilities **File:** `ui/src/lib/utils.ts` -| Function | Usage | -|----------|-------| -| `formatCents(cents)` | Money display: `$12.34` | -| `formatDate(date)` | Date display: `Jan 15, 2025` | -| `relativeTime(date)` | Relative time: `2m ago`, `Jan 15` | -| `formatTokens(count)` | Token counts: `1.2M`, `500k` | +| Function | Usage | +| --------------------- | --------------------------------- | +| `formatCents(cents)` | Money display: `$12.34` | +| `formatDate(date)` | Date display: `Jan 15, 2025` | +| `relativeTime(date)` | Relative time: `2m ago`, `Jan 15` | +| `formatTokens(count)` | Token counts: `1.2M`, `500k` | ### useKeyboardShortcuts diff --git a/AGENTS.md b/AGENTS.md index d41d5f4..3d8bba6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,16 +62,18 @@ pnpm dev ## 5. Core Engineering Rules 1. Keep changes company-scoped. -Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services. + Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services. 2. Keep contracts synchronized. -If you change schema/API behavior, update all impacted layers: + If you change schema/API behavior, update all impacted layers: + - `packages/db` schema and exports - `packages/shared` types/constants/validators - `server` routes/services - `ui` API clients and pages 3. Preserve control-plane invariants. + - Single-assignee task model - Atomic issue checkout semantics - Approval gates for governed actions @@ -79,10 +81,10 @@ If you change schema/API behavior, update all impacted layers: - Activity logging for mutating actions 4. Do not replace strategic docs wholesale unless asked. -Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned. + Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned. 5. Keep repo plan docs dated and centralized. -When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Taskcore issue planning: if a Taskcore issue asks for a plan, update the issue `plan` document per the `taskcore` skill instead of creating a repo markdown file. + When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Taskcore issue planning: if a Taskcore issue asks for a plan, update the issue `plan` document per the `taskcore` skill instead of creating a repo markdown file. ## 6. Database Change Workflow @@ -103,6 +105,7 @@ pnpm -r typecheck ``` Notes: + - `packages/db/drizzle.config.ts` reads compiled schema from `dist/schema/*.js` - `pnpm db:generate` compiles `packages/db` first diff --git a/README.md b/README.md index 8566569..e5a0216 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ -

- Taskcore — runs your business -

- +# TaskCore +

Quickstart · Docs · @@ -315,10 +313,6 @@ MIT © 2026 KhulnaSoft, Ltd --- -

- -

-

Open source under MIT. Built for people who want to run companies, not babysit agents.

diff --git a/adapter-plugin.md b/adapter-plugin.md index 45bd072..702baee 100644 --- a/adapter-plugin.md +++ b/adapter-plugin.md @@ -1,34 +1,32 @@ - Created branch: feat/external-adapter-phase1 - I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front. + I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front. - What I changed - - 1. Server adapter registry is now mutable - Files: + What I changed + 1. Server adapter registry is now mutable + Files: - server/src/adapters/registry.ts - server/src/adapters/index.ts - Added: + Added: - registerServerAdapter(adapter) - unregisterServerAdapter(type) - requireServerAdapter(type) - Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup. + Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup. - Why this is merge-friendly: + Why this is merge-friendly: - existing built-in adapter definitions stay where they already are - existing lookup helpers still exist - no big architectural rewrite yet - - 1. Runtime adapter validation moved to server routes - File: + 1. Runtime adapter validation moved to server routes + File: - server/src/routes/agents.ts - Added: + Added: - assertKnownAdapterType(...) - Used it in: + Used it in: - /companies/:companyId/adapters/:type/models - /companies/:companyId/adapters/:type/detect-model - /companies/:companyId/adapters/:type/test-environment @@ -36,108 +34,104 @@ - POST /companies/:companyId/agent-hires - PATCH /agents/:id when adapterType is touched - Why: + Why: - shared schemas can now allow external adapter strings - server becomes the real source of truth for “is this adapter actually registered?” - - 1. Shared adapterType validation is now open-ended for inputs - Files: + 1. Shared adapterType validation is now open-ended for inputs + Files: - packages/shared/src/adapter-type.ts - packages/shared/src/validators/agent.ts - packages/shared/src/validators/access.ts - packages/shared/src/index.ts - Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to: + Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to: - agentAdapterTypeSchema - optionalAgentAdapterTypeSchema - These accept any non-empty string. + These accept any non-empty string. - Important: + Important: - I did not remove AGENT_ADAPTER_TYPES from constants - I did not change the built-in type unions yet - this keeps the change minimal and limits blast radius - So: + So: - input payloads can carry external adapter types - actual acceptance still depends on server registry validation - - 1. UI adapter registry is now mutable too - Files: + 1. UI adapter registry is now mutable too + Files: - ui/src/adapters/registry.ts - ui/src/adapters/index.ts - Added: + Added: - registerUIAdapter(adapter) - unregisterUIAdapter(type) - findUIAdapter(type) - Built-ins are still registered from the same file, same pattern as server. - - 1. Began switching UI adapter lists toward the registry - Files: + Built-ins are still registered from the same file, same pattern as server. + 1. Began switching UI adapter lists toward the registry + Files: - ui/src/adapters/metadata.ts - ui/src/components/AgentConfigForm.tsx - ui/src/pages/NewAgent.tsx - Changed: + Changed: - AgentConfigForm adapter list now comes from registered UI adapters via metadata helper - NewAgent supported adapter set now derives from listUIAdapters() - Why: + Why: - phase 1 should start reducing hardcoded UI lists - but without rewriting every page at once - - 1. Tests added first, then implementation - New tests: - - server/src/__tests__/adapter-registry.test.ts - - server/src/__tests__/agent-adapter-validation-routes.test.ts + 1. Tests added first, then implementation + New tests: + - server/src/**tests**/adapter-registry.test.ts + - server/src/**tests**/agent-adapter-validation-routes.test.ts - ui/src/adapters/registry.test.ts - packages/shared/src/adapter-types.test.ts - Small test mock fix: - - server/src/__tests__/agent-skills-routes.test.ts + Small test mock fix: + - server/src/**tests**/agent-skills-routes.test.ts - Current status + Current status - The branch is created and the phase-1 code is started. + The branch is created and the phase-1 code is started. - I also followed TDD in spirit: + I also followed TDD in spirit: - wrote failing tests first for missing register/unregister APIs - then implemented the registry changes - What is not finished yet + What is not finished yet - I have not finished the green/stabilization pass yet. + I have not finished the green/stabilization pass yet. - I still need to: + I still need to: - run the targeted tests again after the implementation changes - fix any TS / mocking fallout - decide how far to take phase-1 UI wiring in this branch before stopping - Also, I intentionally did not yet touch the bigger hardcoded surfaces like: + Also, I intentionally did not yet touch the bigger hardcoded surfaces like: - NewAgentDialog - OnboardingWizard - InviteLanding - duplicated adapterLabels maps - plugin manifest / plugin loader adapter contributions - That is deliberate. - For merge safety, I started with the smallest useful seam: + That is deliberate. + For merge safety, I started with the smallest useful seam: - dynamic server registry - dynamic UI registry - runtime validation based on registry - first couple of UI consumers - One note - There was already an unrelated dirty change in: + One note + There was already an unrelated dirty change in: - server/scripts/dev-watch.ts - I left that alone and did not make phase-1 changes there. + I left that alone and did not make phase-1 changes there. - Recommended next move - I should keep going on this same branch and do the stabilization pass: - 1. rerun server/ui/shared targeted tests - 2. fix failures - 3. run typechecks - 4. then extend the same registry-driven approach to the next safest UI surfaces + Recommended next move + I should keep going on this same branch and do the stabilization pass: + 1. rerun server/ui/shared targeted tests + 2. fix failures + 3. run typechecks + 4. then extend the same registry-driven approach to the next safest UI surfaces diff --git a/cli/README.md b/cli/README.md index b01d63b..8e37f32 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,4 @@ -

- Taskcore — runs your business -

+# TaskCore

Quickstart · @@ -129,13 +127,13 @@ Monitor and manage your autonomous businesses from anywhere. ## Problems Taskcore solves -| Without Taskcore | With Taskcore | -| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | -| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | +| Without Taskcore | With Taskcore | +| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | +| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | | ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Taskcore gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. | -| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | -| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | +| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | +| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | | ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Taskcore. Your coding agent works on it until it's done. Management reviews their work. |
@@ -148,7 +146,7 @@ Taskcore handles the hard orchestration details correctly. | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | | **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. | | **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. | -| **Runtime skill injection.** | Agents can learn Taskcore workflows and project context at runtime, without retraining. | +| **Runtime skill injection.** | Agents can learn Taskcore workflows and project context at runtime, without retraining. | | **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. | | **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. | | **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. | @@ -158,10 +156,10 @@ Taskcore handles the hard orchestration details correctly. ## What Taskcore is not -| | | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Not a chatbot.** | Agents have jobs, not chat windows. | -| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | +| | | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **Not a chatbot.** | Agents have jobs, not chat windows. | +| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | | **Not a workflow builder.** | No drag-and-drop pipelines. Taskcore models companies — with org charts, goals, budgets, and governance. | | **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Taskcore manages the organization they work in. | | **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Taskcore. If you have twenty — you definitely do. | @@ -290,11 +288,6 @@ MIT © 2026 KhulnaSoft, Ltd
--- - -

- -

-

Open source under MIT. Built for people who want to run companies, not babysit agents.

diff --git a/cli/package.json b/cli/package.json index 2b9d802..70f1feb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -59,4 +59,4 @@ "tsx": "^4.19.2", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 49c5774..77a8f90 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -45,7 +45,9 @@ describe("agent jwt env helpers", () => { it("loads secret from .env next to explicit config path", () => { const configPath = tempConfigPath(); const envPath = resolveAgentJwtEnvFile(configPath); - fs.writeFileSync(envPath, "TASKCORE_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 }); + fs.writeFileSync(envPath, "TASKCORE_AGENT_JWT_SECRET=test-secret\n", { + mode: 0o600, + }); const loaded = readAgentJwtSecretFromEnv(configPath); expect(loaded).toBe("test-secret"); @@ -55,7 +57,9 @@ describe("agent jwt env helpers", () => { it("doctor check passes when secret exists in adjacent .env", () => { const configPath = tempConfigPath(); const envPath = resolveAgentJwtEnvFile(configPath); - fs.writeFileSync(envPath, "TASKCORE_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 }); + fs.writeFileSync(envPath, "TASKCORE_AGENT_JWT_SECRET=check-secret\n", { + mode: 0o600, + }); const result = agentJwtSecretCheck(configPath); expect(result.status).toBe("pass"); @@ -74,6 +78,8 @@ describe("agent jwt env helpers", () => { const contents = fs.readFileSync(envPath, "utf-8"); expect(contents).toContain('TASKCORE_WORKTREE_COLOR="#439edb"'); - expect(readTaskcoreEnvEntries(envPath).TASKCORE_WORKTREE_COLOR).toBe("#439edb"); + expect(readTaskcoreEnvEntries(envPath).TASKCORE_WORKTREE_COLOR).toBe( + "#439edb", + ); }); }); diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 8024449..883f7b2 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -6,7 +6,9 @@ import type { TaskcoreConfig } from "../config/schema.js"; import { addAllowedHostname } from "../commands/allowed-hostname.js"; function createTempConfigPath() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-allowed-hostname-")); + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-allowed-hostname-"), + ); return path.join(dir, "config.json"); } @@ -71,10 +73,14 @@ describe("allowed-hostname command", () => { const configPath = createTempConfigPath(); writeBaseConfig(configPath); - await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath }); + await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { + config: configPath, + }); await addAllowedHostname("dotta-macbook-pro", { config: configPath }); - const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as TaskcoreConfig; + const raw = JSON.parse( + fs.readFileSync(configPath, "utf-8"), + ) as TaskcoreConfig; expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]); }); }); diff --git a/cli/src/__tests__/auth-command-registration.test.ts b/cli/src/__tests__/auth-command-registration.test.ts index a93d8fa..a5461aa 100644 --- a/cli/src/__tests__/auth-command-registration.test.ts +++ b/cli/src/__tests__/auth-command-registration.test.ts @@ -11,6 +11,8 @@ describe("registerClientAuthCommands", () => { const login = auth.commands.find((command) => command.name() === "login"); expect(login).toBeDefined(); - expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1); + expect( + login?.options.filter((option) => option.long === "--company-id"), + ).toHaveLength(1); }); }); diff --git a/cli/src/__tests__/board-auth.test.ts b/cli/src/__tests__/board-auth.test.ts index 000504f..d10a04a 100644 --- a/cli/src/__tests__/board-auth.test.ts +++ b/cli/src/__tests__/board-auth.test.ts @@ -32,7 +32,9 @@ describe("board auth store", () => { storePath: authPath, }); - expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({ + expect( + getStoredBoardCredential("http://localhost:3100", authPath), + ).toMatchObject({ apiBase: "http://localhost:3100", token: "token-123", userId: "user-1", @@ -47,7 +49,11 @@ describe("board auth store", () => { storePath: authPath, }); - expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true); - expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull(); + expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe( + true, + ); + expect( + getStoredBoardCredential("http://localhost:3100", authPath), + ).toBeNull(); }); }); diff --git a/cli/src/__tests__/common.test.ts b/cli/src/__tests__/common.test.ts index 11f4f54..33aad41 100644 --- a/cli/src/__tests__/common.test.ts +++ b/cli/src/__tests__/common.test.ts @@ -43,7 +43,10 @@ describe("resolveCommandContext", () => { ); process.env.AGENT_KEY = "key-from-env"; - const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true }); + const resolved = resolveCommandContext( + { context: contextPath }, + { requireCompany: true }, + ); expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999"); expect(resolved.companyId).toBe("company-profile"); expect(resolved.api.apiKey).toBe("key-from-env"); @@ -92,7 +95,10 @@ describe("resolveCommandContext", () => { ); expect(() => - resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }), + resolveCommandContext( + { context: contextPath, apiBase: "http://localhost:3100" }, + { requireCompany: true }, + ), ).toThrow(/Company ID is required/); }); }); diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts index 7bbd508..c9c1172 100644 --- a/cli/src/__tests__/company-delete.test.ts +++ b/cli/src/__tests__/company-delete.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { Company } from "@taskcore/shared"; -import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js"; +import { + assertDeleteConfirmation, + resolveCompanyForDeletion, +} from "../commands/client/company.js"; function makeCompany(overrides: Partial): Company { return { @@ -43,7 +46,11 @@ describe("resolveCompanyForDeletion", () => { ]; it("resolves by ID in auto mode", () => { - const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto"); + const result = resolveCompanyForDeletion( + companies, + "22222222-2222-2222-2222-222222222222", + "auto", + ); expect(result.issuePrefix).toBe("PAP"); }); @@ -53,16 +60,25 @@ describe("resolveCompanyForDeletion", () => { }); it("throws when selector is not found", () => { - expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/); + expect(() => + resolveCompanyForDeletion(companies, "MISSING", "auto"), + ).toThrow(/No company found/); }); it("respects explicit id mode", () => { - expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/); + expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow( + /No company found by ID/, + ); }); it("respects explicit prefix mode", () => { - expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix")) - .toThrow(/No company found by shortname/); + expect(() => + resolveCompanyForDeletion( + companies, + "22222222-2222-2222-2222-222222222222", + "prefix", + ), + ).toThrow(/No company found by shortname/); }); }); @@ -73,11 +89,15 @@ describe("assertDeleteConfirmation", () => { }); it("requires --yes", () => { - expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/); + expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow( + /requires --yes/, + ); }); it("accepts matching prefix confirmation", () => { - expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow(); + expect(() => + assertDeleteConfirmation(company, { yes: true, confirm: "pap" }), + ).not.toThrow(); }); it("accepts matching id confirmation", () => { @@ -85,11 +105,13 @@ describe("assertDeleteConfirmation", () => { assertDeleteConfirmation(company, { yes: true, confirm: "22222222-2222-2222-2222-222222222222", - })).not.toThrow(); + }), + ).not.toThrow(); }); it("rejects mismatched confirmation", () => { - expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" })) - .toThrow(/does not match target company/); + expect(() => + assertDeleteConfirmation(company, { yes: true, confirm: "nope" }), + ).toThrow(/does not match target company/); }); }); diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 5b93f10..5f836c5 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -1,5 +1,12 @@ import { execFile, spawn } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -36,7 +43,9 @@ async function getAvailablePort(): Promise { } const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported + ? describe + : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( @@ -44,7 +53,12 @@ if (!embeddedPostgresSupport.supported) { ); } -function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { +function writeTestConfig( + configPath: string, + tempRoot: string, + port: number, + connectionString: string, +) { const config = { $meta: { version: 1, @@ -104,7 +118,11 @@ function writeTestConfig(configPath: string, tempRoot: string, port: number, con writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } -function createServerEnv(configPath: string, port: number, connectionString: string) { +function createServerEnv( + configPath: string, + port: number, + connectionString: string, +) { const env = { ...process.env }; for (const key of Object.keys(env)) { if (key.startsWith("TASKCORE_")) { @@ -148,7 +166,11 @@ function createCliEnv() { return env; } -function collectTextFiles(root: string, current: string, files: Record) { +function collectTextFiles( + root: string, + current: string, + files: Record, +) { for (const entry of readdirSync(current, { withFileTypes: true })) { const absolutePath = path.join(current, entry.name); if (entry.isDirectory()) { @@ -174,20 +196,39 @@ async function stopServerProcess(child: ServerProcess | null) { }); } -async function api(baseUrl: string, pathname: string, init?: RequestInit): Promise { +async function api( + baseUrl: string, + pathname: string, + init?: RequestInit, +): Promise { const res = await fetch(`${baseUrl}${pathname}`, init); const text = await res.text(); if (!res.ok) { throw new Error(`Request failed ${res.status} ${pathname}: ${text}`); } - return text ? JSON.parse(text) as T : (null as T); + return text ? (JSON.parse(text) as T) : (null as T); } -async function runCliJson(args: string[], opts: { apiBase: string; configPath: string }) { - const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +async function runCliJson( + args: string[], + opts: { apiBase: string; configPath: string }, +) { + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", + ); const result = await execFileAsync( "pnpm", - ["--silent", "taskcore", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"], + [ + "--silent", + "taskcore", + ...args, + "--api-base", + opts.apiBase, + "--config", + opts.configPath, + "--json", + ], { cwd: repoRoot, env: createCliEnv(), @@ -197,7 +238,9 @@ async function runCliJson(args: string[], opts: { apiBase: string; configPath const stdout = result.stdout.trim(); const jsonStart = stdout.search(/[\[{]/); if (jsonStart === -1) { - throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + throw new Error( + `CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); } return JSON.parse(stdout.slice(jsonStart)) as T; } @@ -236,30 +279,33 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { let exportDir = ""; let apiBase = ""; let serverProcess: ServerProcess | null = null; - let tempDb: Awaited> | null = null; + let tempDb: Awaited< + ReturnType + > | null = null; beforeAll(async () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "taskcore-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); - tempDb = await startEmbeddedPostgresTestDatabase("taskcore-company-cli-db-"); + tempDb = await startEmbeddedPostgresTestDatabase( + "taskcore-company-cli-db-", + ); const port = await getAvailablePort(); writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; - const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); - const output = { stdout: [] as string[], stderr: [] as string[] }; - const child = spawn( - "pnpm", - ["taskcore", "run", "--config", configPath], - { - cwd: repoRoot, - env: createServerEnv(configPath, port, tempDb.connectionString), - stdio: ["ignore", "pipe", "pipe"], - }, + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", ); + const output = { stdout: [] as string[], stderr: [] as string[] }; + const child = spawn("pnpm", ["taskcore", "run", "--config", configPath], { + cwd: repoRoot, + env: createServerEnv(configPath, port, tempDb.connectionString), + stdio: ["ignore", "pipe", "pipe"], + }); serverProcess = child; child.stdout?.on("data", (chunk) => { output.stdout.push(String(chunk)); @@ -282,7 +328,11 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { it("exports a company package and imports it into new and existing companies", async () => { expect(serverProcess).not.toBeNull(); - const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", { + const sourceCompany = await api<{ + id: string; + name: string; + issuePrefix: string; + }>(apiBase, "/api/companies", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }), @@ -320,21 +370,21 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`; - const sourceIssue = await api<{ id: string; title: string; identifier: string }>( - apiBase, - `/api/companies/${sourceCompany.id}/issues`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - title: "Validate company import/export", - description: largeIssueDescription, - status: "todo", - projectId: sourceProject.id, - assigneeAgentId: sourceAgent.id, - }), - }, - ); + const sourceIssue = await api<{ + id: string; + title: string; + identifier: string; + }>(apiBase, `/api/companies/${sourceCompany.id}/issues`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Validate company import/export", + description: largeIssueDescription, + status: "todo", + projectId: sourceProject.id, + assigneeAgentId: sourceAgent.id, + }), + }); const exportResult = await runCliJson<{ ok: boolean; @@ -355,8 +405,12 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { expect(exportResult.ok).toBe(true); expect(exportResult.filesWritten).toBeGreaterThan(0); - expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name); - expect(readFileSync(path.join(exportDir, ".taskcore.yaml"), "utf8")).toContain('schema: "taskcore/v1"'); + expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain( + sourceCompany.name, + ); + expect( + readFileSync(path.join(exportDir, ".taskcore.yaml"), "utf8"), + ).toContain('schema: "taskcore/v1"'); const importedNew = await runCliJson<{ company: { id: string; name: string; action: string }; @@ -389,14 +443,19 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { apiBase, `/api/companies/${importedNew.company.id}/projects`, ); - const importedIssues = await api>( - apiBase, - `/api/companies/${importedNew.company.id}/issues`, - ); + const importedIssues = await api< + Array<{ id: string; title: string; identifier: string }> + >(apiBase, `/api/companies/${importedNew.company.id}/issues`); - expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name); - expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name); - expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title); + expect(importedAgents.map((agent) => agent.name)).toContain( + sourceAgent.name, + ); + expect(importedProjects.map((project) => project.name)).toContain( + sourceProject.name, + ); + expect(importedIssues.map((issue) => issue.title)).toContain( + sourceIssue.title, + ); const previewExisting = await runCliJson<{ errors: string[]; @@ -426,9 +485,17 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { expect(previewExisting.errors).toEqual([]); expect(previewExisting.plan.companyAction).toBe("none"); - expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true); - expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true); - expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true); + expect( + previewExisting.plan.agentPlans.some((plan) => plan.action === "create"), + ).toBe(true); + expect( + previewExisting.plan.projectPlans.some( + (plan) => plan.action === "create", + ), + ).toBe(true); + expect( + previewExisting.plan.issuePlans.some((plan) => plan.action === "create"), + ).toBe(true); const importedExisting = await runCliJson<{ company: { id: string; action: string }; @@ -452,30 +519,35 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { ); expect(importedExisting.company.action).toBe("unchanged"); - expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); + expect( + importedExisting.agents.some((agent) => agent.action === "created"), + ).toBe(true); const twiceImportedAgents = await api>( apiBase, `/api/companies/${importedNew.company.id}/agents`, ); - const twiceImportedProjects = await api>( - apiBase, - `/api/companies/${importedNew.company.id}/projects`, - ); - const twiceImportedIssues = await api>( - apiBase, - `/api/companies/${importedNew.company.id}/issues`, - ); + const twiceImportedProjects = await api< + Array<{ id: string; name: string }> + >(apiBase, `/api/companies/${importedNew.company.id}/projects`); + const twiceImportedIssues = await api< + Array<{ id: string; title: string; identifier: string }> + >(apiBase, `/api/companies/${importedNew.company.id}/issues`); expect(twiceImportedAgents).toHaveLength(2); - expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2); + expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe( + 2, + ); expect(twiceImportedProjects).toHaveLength(2); expect(twiceImportedIssues).toHaveLength(2); const zipPath = path.join(tempRoot, "exported-company.zip"); const portableFiles: Record = {}; collectTextFiles(exportDir, exportDir, portableFiles); - writeFileSync(zipPath, createStoredZipArchive(portableFiles, "taskcore-demo")); + writeFileSync( + zipPath, + createStoredZipArchive(portableFiles, "taskcore-demo"), + ); const importedFromZip = await runCliJson<{ company: { id: string; name: string; action: string }; @@ -497,6 +569,8 @@ describeEmbeddedPostgres("taskcore company import/export e2e", () => { ); expect(importedFromZip.company.action).toBe("created"); - expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true); + expect( + importedFromZip.agents.some((agent) => agent.action === "created"), + ).toBe(true); }, 60_000); }); diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts index 1e12cd9..b19deb4 100644 --- a/cli/src/__tests__/company-import-url.test.ts +++ b/cli/src/__tests__/company-import-url.test.ts @@ -56,7 +56,9 @@ describe("normalizeGithubImportSource", () => { }); it("applies --ref to shorthand imports", () => { - expect(normalizeGithubImportSource("taskcore/companies/gstack", "feature/demo")).toBe( + expect( + normalizeGithubImportSource("taskcore/companies/gstack", "feature/demo"), + ).toBe( "https://github.com/khulnasoft/companies?ref=feature%2Fdemo&path=gstack", ); }); diff --git a/cli/src/__tests__/company-import-zip.test.ts b/cli/src/__tests__/company-import-zip.test.ts index 9a66f1b..c1c7052 100644 --- a/cli/src/__tests__/company-import-zip.test.ts +++ b/cli/src/__tests__/company-import-zip.test.ts @@ -15,7 +15,9 @@ afterEach(async () => { describe("resolveInlineSourceFromPath", () => { it("imports portable files from a zip archive instead of scanning the parent directory", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "taskcore-company-import-zip-")); + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "taskcore-company-import-zip-"), + ); tempDirs.push(tempDir); const archivePath = path.join(tempDir, "taskcore-demo.zip"); diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index a136120..50df16f 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -55,7 +55,7 @@ describe("resolveCompanyImportApiPath", () => { dryRun: true, targetMode: "existing_company", companyId: " ", - }) + }), ).toThrow(/require a companyId/i); }); }); @@ -87,7 +87,7 @@ describe("resolveCompanyImportApplyConfirmationMode", () => { yes: false, interactive: false, json: false, - }) + }), ).toThrow(/non-interactive terminal requires --yes/i); }); @@ -97,16 +97,16 @@ describe("resolveCompanyImportApplyConfirmationMode", () => { yes: false, interactive: false, json: true, - }) + }), ).toThrow(/with --json requires --yes/i); }); }); describe("buildCompanyDashboardUrl", () => { it("preserves the configured base path when building a dashboard URL", () => { - expect(buildCompanyDashboardUrl("https://taskcore.example/app/", "PAP")).toBe( - "https://taskcore.example/app/PAP/dashboard", - ); + expect( + buildCompanyDashboardUrl("https://taskcore.example/app/", "PAP"), + ).toBe("https://taskcore.example/app/PAP/dashboard"); }); }); @@ -123,23 +123,84 @@ describe("renderCompanyImportPreview", () => { targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", - selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"], + selectedAgentSlugs: [ + "ceo", + "cto", + "eng-1", + "eng-2", + "eng-3", + "eng-4", + "eng-5", + ], plan: { companyAction: "update", agentPlans: [ - { slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null }, - { slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" }, - { slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" }, - { slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null }, - { slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null }, - { slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null }, - { slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null }, + { + slug: "ceo", + action: "create", + plannedName: "CEO", + existingAgentId: null, + reason: null, + }, + { + slug: "cto", + action: "update", + plannedName: "CTO", + existingAgentId: "agent-2", + reason: "replace strategy", + }, + { + slug: "eng-1", + action: "skip", + plannedName: "Engineer 1", + existingAgentId: "agent-3", + reason: "skip strategy", + }, + { + slug: "eng-2", + action: "create", + plannedName: "Engineer 2", + existingAgentId: null, + reason: null, + }, + { + slug: "eng-3", + action: "create", + plannedName: "Engineer 3", + existingAgentId: null, + reason: null, + }, + { + slug: "eng-4", + action: "create", + plannedName: "Engineer 4", + existingAgentId: null, + reason: null, + }, + { + slug: "eng-5", + action: "create", + plannedName: "Engineer 5", + existingAgentId: null, + reason: null, + }, ], projectPlans: [ - { slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null }, + { + slug: "alpha", + action: "create", + plannedName: "Alpha", + existingProjectId: null, + reason: null, + }, ], issuePlans: [ - { slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null }, + { + slug: "kickoff", + action: "create", + plannedTitle: "Kickoff", + reason: null, + }, ], }, manifest: { @@ -307,14 +368,50 @@ describe("renderCompanyImportResult", () => { action: "updated", }, agents: [ - { slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null }, - { slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" }, - { slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" }, + { + slug: "ceo", + id: "agent-1", + action: "created", + name: "CEO", + reason: null, + }, + { + slug: "cto", + id: "agent-2", + action: "updated", + name: "CTO", + reason: "replace strategy", + }, + { + slug: "ops", + id: null, + action: "skipped", + name: "Ops", + reason: "skip strategy", + }, ], projects: [ - { slug: "app", id: "project-1", action: "created", name: "App", reason: null }, - { slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" }, - { slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" }, + { + slug: "app", + id: "project-1", + action: "created", + name: "App", + reason: null, + }, + { + slug: "ops", + id: "project-2", + action: "updated", + name: "Operations", + reason: "replace strategy", + }, + { + slug: "archive", + id: null, + action: "skipped", + name: "Archive", + reason: "skip strategy", + }, ], envInputs: [], warnings: ["Review API keys"], @@ -328,8 +425,12 @@ describe("renderCompanyImportResult", () => { expect(rendered).toContain("Company"); expect(rendered).toContain("https://taskcore.example/PAP/dashboard"); - expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)"); - expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)"); + expect(rendered).toContain( + "3 agents total (1 created, 1 updated, 1 skipped)", + ); + expect(rendered).toContain( + "3 projects total (1 created, 1 updated, 1 skipped)", + ); expect(rendered).toContain("Agent results"); expect(rendered).toContain("Project results"); expect(rendered).toContain("Using claude-local adapter"); @@ -505,8 +606,12 @@ describe("import selection catalog", () => { expect(selectedFiles).toContain(".taskcore.yaml"); expect(selectedFiles).toContain("projects/alpha/PROJECT.md"); expect(selectedFiles).toContain("projects/alpha/notes.md"); - expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md"); - expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md"); + expect(selectedFiles).not.toContain( + "projects/alpha/issues/kickoff/TASK.md", + ); + expect(selectedFiles).not.toContain( + "projects/alpha/issues/kickoff/details.md", + ); }); }); diff --git a/cli/src/__tests__/data-dir.test.ts b/cli/src/__tests__/data-dir.test.ts index 6134287..4349195 100644 --- a/cli/src/__tests__/data-dir.test.ts +++ b/cli/src/__tests__/data-dir.test.ts @@ -19,11 +19,14 @@ describe("applyDataDirOverride", () => { }); it("sets TASKCORE_HOME and isolated default config/context paths", () => { - const home = applyDataDirOverride({ - dataDir: "~/taskcore-data", - config: undefined, - context: undefined, - }, { hasConfigOption: true, hasContextOption: true }); + const home = applyDataDirOverride( + { + dataDir: "~/taskcore-data", + config: undefined, + context: undefined, + }, + { hasConfigOption: true, hasContextOption: true }, + ); const expectedHome = path.resolve(os.homedir(), "taskcore-data"); expect(home).toBe(expectedHome); @@ -31,17 +34,22 @@ describe("applyDataDirOverride", () => { expect(process.env.TASKCORE_CONFIG).toBe( path.resolve(expectedHome, "instances", "default", "config.json"), ); - expect(process.env.TASKCORE_CONTEXT).toBe(path.resolve(expectedHome, "context.json")); + expect(process.env.TASKCORE_CONTEXT).toBe( + path.resolve(expectedHome, "context.json"), + ); expect(process.env.TASKCORE_INSTANCE_ID).toBe("default"); }); it("uses the provided instance id when deriving default config path", () => { - const home = applyDataDirOverride({ - dataDir: "/tmp/taskcore-alt", - instance: "dev_1", - config: undefined, - context: undefined, - }, { hasConfigOption: true, hasContextOption: true }); + const home = applyDataDirOverride( + { + dataDir: "/tmp/taskcore-alt", + instance: "dev_1", + config: undefined, + context: undefined, + }, + { hasConfigOption: true, hasContextOption: true }, + ); expect(home).toBe(path.resolve("/tmp/taskcore-alt")); expect(process.env.TASKCORE_INSTANCE_ID).toBe("dev_1"); @@ -54,11 +62,14 @@ describe("applyDataDirOverride", () => { process.env.TASKCORE_CONFIG = "/env/config.json"; process.env.TASKCORE_CONTEXT = "/env/context.json"; - applyDataDirOverride({ - dataDir: "/tmp/taskcore-alt", - config: "/flag/config.json", - context: "/flag/context.json", - }, { hasConfigOption: true, hasContextOption: true }); + applyDataDirOverride( + { + dataDir: "/tmp/taskcore-alt", + config: "/flag/config.json", + context: "/flag/context.json", + }, + { hasConfigOption: true, hasContextOption: true }, + ); expect(process.env.TASKCORE_CONFIG).toBe("/env/config.json"); expect(process.env.TASKCORE_CONTEXT).toBe("/env/context.json"); diff --git a/cli/src/__tests__/feedback.test.ts b/cli/src/__tests__/feedback.test.ts index 2ac08d7..9e589ff 100644 --- a/cli/src/__tests__/feedback.test.ts +++ b/cli/src/__tests__/feedback.test.ts @@ -67,10 +67,19 @@ describe("registerFeedbackCommands", () => { expect(() => registerFeedbackCommands(program)).not.toThrow(); - const feedback = program.commands.find((command) => command.name() === "feedback"); + const feedback = program.commands.find( + (command) => command.name() === "feedback", + ); expect(feedback).toBeDefined(); - expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]); - expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1); + expect(feedback?.commands.map((command) => command.name())).toEqual([ + "report", + "export", + ]); + expect( + feedback?.commands[0]?.options.filter( + (option) => option.long === "--company-id", + ), + ).toHaveLength(1); }); }); @@ -128,7 +137,9 @@ describe("renderFeedbackReport", () => { describe("writeFeedbackExportBundle", () => { it("writes votes, traces, a manifest, and a zip archive", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "taskcore-feedback-export-")); + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "taskcore-feedback-export-"), + ); const outputDir = path.join(tempDir, "feedback-export"); const traces = [ makeTrace(), @@ -158,7 +169,9 @@ describe("writeFeedbackExportBundle", () => { expect(exported.manifest.summary.total).toBe(2); expect(exported.manifest.summary.withReason).toBe(1); - const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as { + const manifest = JSON.parse( + await readFile(path.join(outputDir, "index.json"), "utf8"), + ) as { files: { votes: string[]; traces: string[]; zip: string }; }; expect(manifest.files.votes).toHaveLength(2); diff --git a/cli/src/__tests__/helpers/zip.ts b/cli/src/__tests__/helpers/zip.ts index ef79b5b..a074a0c 100644 --- a/cli/src/__tests__/helpers/zip.ts +++ b/cli/src/__tests__/helpers/zip.ts @@ -21,14 +21,19 @@ function crc32(bytes: Uint8Array) { return (crc ^ 0xffffffff) >>> 0; } -export function createStoredZipArchive(files: Record, rootPath: string) { +export function createStoredZipArchive( + files: Record, + rootPath: string, +) { const encoder = new TextEncoder(); const localChunks: Uint8Array[] = []; const centralChunks: Uint8Array[] = []; let localOffset = 0; let entryCount = 0; - for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { + for (const [relativePath, content] of Object.entries(files).sort( + ([left], [right]) => left.localeCompare(right), + )) { const fileName = encoder.encode(`${rootPath}/${relativePath}`); const body = encoder.encode(content); const checksum = crc32(body); @@ -63,9 +68,14 @@ export function createStoredZipArchive(files: Record, rootPath: entryCount += 1; } - const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const centralDirectoryLength = centralChunks.reduce( + (sum, chunk) => sum + chunk.length, + 0, + ); const archive = new Uint8Array( - localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22, + localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + + centralDirectoryLength + + 22, ); let offset = 0; for (const chunk of localChunks) { diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts index 8c36328..e9aa36c 100644 --- a/cli/src/__tests__/home-paths.test.ts +++ b/cli/src/__tests__/home-paths.test.ts @@ -22,7 +22,15 @@ describe("home path resolution", () => { const paths = describeLocalInstancePaths(); expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".taskcore")); expect(paths.instanceId).toBe("default"); - expect(paths.configPath).toBe(path.resolve(os.homedir(), ".taskcore", "instances", "default", "config.json")); + expect(paths.configPath).toBe( + path.resolve( + os.homedir(), + ".taskcore", + "instances", + "default", + "config.json", + ), + ); }); it("supports TASKCORE_HOME and explicit instance ids", () => { @@ -34,7 +42,9 @@ describe("home path resolution", () => { }); it("rejects invalid instance ids", () => { - expect(() => resolveTaskcoreInstanceId("bad/id")).toThrow(/Invalid instance id/); + expect(() => resolveTaskcoreInstanceId("bad/id")).toThrow( + /Invalid instance id/, + ); }); it("expands ~ prefixes", () => { diff --git a/cli/src/__tests__/http.test.ts b/cli/src/__tests__/http.test.ts index 4309e2d..b4cced6 100644 --- a/cli/src/__tests__/http.test.ts +++ b/cli/src/__tests__/http.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiConnectionError, ApiRequestError, TaskcoreApiClient } from "../client/http.js"; +import { + ApiConnectionError, + ApiRequestError, + TaskcoreApiClient, +} from "../client/http.js"; describe("TaskcoreApiClient", () => { afterEach(() => { @@ -7,9 +11,11 @@ describe("TaskcoreApiClient", () => { }); it("adds authorization and run-id headers", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { status: 200 }), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); vi.stubGlobal("fetch", fetchMock); const client = new TaskcoreApiClient({ @@ -31,9 +37,11 @@ describe("TaskcoreApiClient", () => { }); it("returns null on ignoreNotFound", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "Not found" }), { status: 404 }), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "Not found" }), { status: 404 }), + ); vi.stubGlobal("fetch", fetchMock); const client = new TaskcoreApiClient({ apiBase: "http://localhost:3100" }); @@ -42,17 +50,24 @@ describe("TaskcoreApiClient", () => { }); it("throws ApiRequestError with details", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }), - { status: 409 }, - ), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ + error: "Issue checkout conflict", + details: { issueId: "1" }, + }), + { status: 409 }, + ), + ); vi.stubGlobal("fetch", fetchMock); const client = new TaskcoreApiClient({ apiBase: "http://localhost:3100" }); - await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({ + await expect( + client.post("/api/issues/1/checkout", {}), + ).rejects.toMatchObject({ status: 409, message: "Issue checkout conflict", details: { issueId: "1" }, @@ -65,28 +80,38 @@ describe("TaskcoreApiClient", () => { const client = new TaskcoreApiClient({ apiBase: "http://localhost:3100" }); - await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError); - await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({ + await expect( + client.post("/api/companies/import/preview", {}), + ).rejects.toBeInstanceOf(ApiConnectionError); + await expect( + client.post("/api/companies/import/preview", {}), + ).rejects.toMatchObject({ url: "http://localhost:3100/api/companies/import/preview", method: "POST", causeMessage: "fetch failed", } satisfies Partial); - await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( - /Could not reach the Taskcore API\./, - ); - await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( - /curl http:\/\/localhost:3100\/api\/health/, - ); - await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( - /pnpm dev|pnpm taskcore run/, - ); + await expect( + client.post("/api/companies/import/preview", {}), + ).rejects.toThrow(/Could not reach the Taskcore API\./); + await expect( + client.post("/api/companies/import/preview", {}), + ).rejects.toThrow(/curl http:\/\/localhost:3100\/api\/health/); + await expect( + client.post("/api/companies/import/preview", {}), + ).rejects.toThrow(/pnpm dev|pnpm taskcore run/); }); it("retries once after interactive auth recovery", async () => { const fetchMock = vi .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Board access required" }), { + status: 403, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); vi.stubGlobal("fetch", fetchMock); const recoverAuth = vi.fn().mockResolvedValue("board-token-123"); @@ -95,12 +120,17 @@ describe("TaskcoreApiClient", () => { recoverAuth, }); - const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" }); + const result = await client.post<{ ok: boolean }>("/api/test", { + hello: "world", + }); expect(result).toEqual({ ok: true }); expect(recoverAuth).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledTimes(2); - const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record; + const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record< + string, + string + >; expect(retryHeaders.authorization).toBe("Bearer board-token-123"); }); }); diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts index 48554b7..76591ba 100644 --- a/cli/src/__tests__/network-bind.test.ts +++ b/cli/src/__tests__/network-bind.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveRuntimeBind, validateConfiguredBindMode } from "@taskcore/shared"; +import { + resolveRuntimeBind, + validateConfiguredBindMode, +} from "@taskcore/shared"; import { buildPresetServerConfig } from "../config/server-bind.js"; describe("network bind helpers", () => { @@ -31,7 +34,9 @@ describe("network bind helpers", () => { host: "127.0.0.1", }); - expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom"); + expect(resolved.errors).toContain( + "server.customBindHost is required when server.bind=custom", + ); }); it("stores the detected tailscale address for tailnet presets", () => { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index dfd7be0..dd08844 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -69,13 +69,17 @@ function createExistingConfigFixture() { }; fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); return { configPath, configText: fs.readFileSync(configPath, "utf8") }; } function createFreshConfigPath() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-onboard-fresh-")); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-onboard-fresh-"), + ); return path.join(root, ".taskcore", "config.json"); } @@ -96,19 +100,31 @@ describe("onboard", () => { await onboard({ config: fixture.configPath }); - expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe( + fixture.configText, + ); expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); - expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + expect( + fs.existsSync(path.join(path.dirname(fixture.configPath), ".env")), + ).toBe(true); }); it("preserves an existing config when rerun with --yes", async () => { const fixture = createExistingConfigFixture(); - await onboard({ config: fixture.configPath, yes: true, invokedByRun: true }); + await onboard({ + config: fixture.configPath, + yes: true, + invokedByRun: true, + }); - expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe( + fixture.configText, + ); expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); - expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + expect( + fs.existsSync(path.join(path.dirname(fixture.configPath), ".env")), + ).toBe(true); }); it("keeps --yes onboarding on local trusted loopback defaults", async () => { @@ -118,7 +134,9 @@ describe("onboard", () => { await onboard({ config: configPath, yes: true, invokedByRun: true }); - const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as TaskcoreConfig; + const raw = JSON.parse( + fs.readFileSync(configPath, "utf8"), + ) as TaskcoreConfig; expect(raw.server.deploymentMode).toBe("local_trusted"); expect(raw.server.exposure).toBe("private"); expect(raw.server.bind).toBe("loopback"); @@ -129,9 +147,16 @@ describe("onboard", () => { const configPath = createFreshConfigPath(); process.env.TASKCORE_TAILNET_BIND_HOST = "100.64.0.8"; - await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + await onboard({ + config: configPath, + yes: true, + invokedByRun: true, + bind: "tailnet", + }); - const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as TaskcoreConfig; + const raw = JSON.parse( + fs.readFileSync(configPath, "utf8"), + ) as TaskcoreConfig; expect(raw.server.deploymentMode).toBe("authenticated"); expect(raw.server.exposure).toBe("private"); expect(raw.server.bind).toBe("tailnet"); @@ -142,9 +167,16 @@ describe("onboard", () => { const configPath = createFreshConfigPath(); delete process.env.TASKCORE_TAILNET_BIND_HOST; - await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + await onboard({ + config: configPath, + yes: true, + invokedByRun: true, + bind: "tailnet", + }); - const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as TaskcoreConfig; + const raw = JSON.parse( + fs.readFileSync(configPath, "utf8"), + ) as TaskcoreConfig; expect(raw.server.deploymentMode).toBe("authenticated"); expect(raw.server.exposure).toBe("private"); expect(raw.server.bind).toBe("tailnet"); @@ -157,7 +189,9 @@ describe("onboard", () => { await onboard({ config: configPath, yes: true, invokedByRun: true }); - const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as TaskcoreConfig; + const raw = JSON.parse( + fs.readFileSync(configPath, "utf8"), + ) as TaskcoreConfig; expect(raw.server.deploymentMode).toBe("local_trusted"); expect(raw.server.exposure).toBe("private"); expect(raw.server.bind).toBe("loopback"); diff --git a/cli/src/__tests__/routines.test.ts b/cli/src/__tests__/routines.test.ts index e785ddf..56e3164 100644 --- a/cli/src/__tests__/routines.test.ts +++ b/cli/src/__tests__/routines.test.ts @@ -4,13 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { - agents, - companies, - createDb, - projects, - routines, -} from "@taskcore/db"; +import { agents, companies, createDb, projects, routines } from "@taskcore/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -18,7 +12,9 @@ import { import { disableAllRoutinesInConfig } from "../commands/routines.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported + ? describe + : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( @@ -26,7 +22,11 @@ if (!embeddedPostgresSupport.supported) { ); } -function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) { +function writeTestConfig( + configPath: string, + tempRoot: string, + connectionString: string, +) { const config = { $meta: { version: 1, @@ -88,14 +88,20 @@ function writeTestConfig(configPath: string, tempRoot: string, connectionString: describeEmbeddedPostgres("disableAllRoutinesInConfig", () => { let db!: ReturnType; - let tempDb: Awaited> | null = null; + let tempDb: Awaited< + ReturnType + > | null = null; let tempRoot = ""; let configPath = ""; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("taskcore-routines-cli-db-"); + tempDb = await startEmbeddedPostgresTestDatabase( + "taskcore-routines-cli-db-", + ); db = createDb(tempDb.connectionString); - tempRoot = mkdtempSync(path.join(os.tmpdir(), "taskcore-routines-cli-config-")); + tempRoot = mkdtempSync( + path.join(os.tmpdir(), "taskcore-routines-cli-config-"), + ); configPath = path.join(tempRoot, "config.json"); writeTestConfig(configPath, tempRoot, tempDb.connectionString); }, 20_000); @@ -232,7 +238,9 @@ describeEmbeddedPostgres("disableAllRoutinesInConfig", () => { }) .from(routines) .where(eq(routines.companyId, companyId)); - const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status])); + const statusById = new Map( + companyRoutines.map((routine) => [routine.id, routine.status]), + ); expect(statusById.get(activeRoutineId)).toBe("paused"); expect(statusById.get(pausedRoutineId)).toBe("paused"); diff --git a/cli/src/__tests__/telemetry.test.ts b/cli/src/__tests__/telemetry.test.ts index 5875c65..90864bc 100644 --- a/cli/src/__tests__/telemetry.test.ts +++ b/cli/src/__tests__/telemetry.test.ts @@ -4,67 +4,80 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const ORIGINAL_ENV = { ...process.env }; -const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"]; +const CI_ENV_VARS = [ + "CI", + "CONTINUOUS_INTEGRATION", + "BUILD_NUMBER", + "GITHUB_ACTIONS", + "GITLAB_CI", +]; function makeConfigPath(root: string, enabled: boolean): string { const configPath = path.join(root, ".taskcore", "config.json"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify({ - $meta: { - version: 1, - updatedAt: "2026-03-31T00:00:00.000Z", - source: "configure", - }, - database: { - mode: "embedded-postgres", - embeddedPostgresDataDir: path.join(root, "runtime", "db"), - embeddedPostgresPort: 54329, - backup: { - enabled: true, - intervalMinutes: 60, - retentionDays: 30, - dir: path.join(root, "runtime", "backups"), + fs.writeFileSync( + configPath, + JSON.stringify( + { + $meta: { + version: 1, + updatedAt: "2026-03-31T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(root, "runtime", "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(root, "runtime", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(root, "runtime", "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + telemetry: { + enabled, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(root, "runtime", "storage"), + }, + s3: { + bucket: "taskcore", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(root, "runtime", "secrets", "master.key"), + }, + }, }, - }, - logging: { - mode: "file", - logDir: path.join(root, "runtime", "logs"), - }, - server: { - deploymentMode: "local_trusted", - exposure: "private", - host: "127.0.0.1", - port: 3100, - allowedHostnames: [], - serveUi: true, - }, - auth: { - baseUrlMode: "auto", - disableSignUp: false, - }, - telemetry: { - enabled, - }, - storage: { - provider: "local_disk", - localDisk: { - baseDir: path.join(root, "runtime", "storage"), - }, - s3: { - bucket: "taskcore", - region: "us-east-1", - prefix: "", - forcePathStyle: false, - }, - }, - secrets: { - provider: "local_encrypted", - strictMode: false, - localEncrypted: { - keyFilePath: path.join(root, "runtime", "secrets", "master.key"), - }, - }, - }, null, 2)); + null, + 2, + ), + ); return configPath; } @@ -74,7 +87,10 @@ describe("cli telemetry", () => { for (const key of CI_ENV_VARS) { delete process.env[key]; } - vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true }))); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true })), + ); }); afterEach(() => { @@ -84,7 +100,9 @@ describe("cli telemetry", () => { }); it("respects telemetry.enabled=false from the config file", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-cli-telemetry-")); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-cli-telemetry-"), + ); const configPath = makeConfigPath(root, false); process.env.TASKCORE_HOME = path.join(root, "home"); process.env.TASKCORE_INSTANCE_ID = "telemetry-test"; @@ -93,17 +111,37 @@ describe("cli telemetry", () => { const client = initTelemetryFromConfigFile(configPath); expect(client).toBeNull(); - expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false); + expect( + fs.existsSync( + path.join( + root, + "home", + "instances", + "telemetry-test", + "telemetry", + "state.json", + ), + ), + ).toBe(false); }); it("creates telemetry state only after the first event is tracked", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-cli-telemetry-")); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-cli-telemetry-"), + ); process.env.TASKCORE_HOME = path.join(root, "home"); process.env.TASKCORE_INSTANCE_ID = "telemetry-test"; const { initTelemetry, flushTelemetry } = await import("../telemetry.js"); const client = initTelemetry({ enabled: true }); - const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"); + const statePath = path.join( + root, + "home", + "instances", + "telemetry-test", + "telemetry", + "state.json", + ); expect(client).not.toBeNull(); expect(fs.existsSync(statePath)).toBe(false); diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts index de14549..4580c34 100644 --- a/cli/src/__tests__/worktree-merge-history.test.ts +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js"; +import { + buildWorktreeMergePlan, + parseWorktreeMergeScopes, +} from "../commands/worktree-merge-history-lib.js"; function makeIssue(overrides: Record = {}) { return { @@ -168,7 +171,11 @@ describe("worktree merge history planner", () => { }); it("dedupes nested worktree issues by preserved source uuid", () => { - const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" }); + const sharedIssue = makeIssue({ + id: "issue-a", + identifier: "PAP-10", + title: "Shared", + }); const branchOneIssue = makeIssue({ id: "issue-b", identifier: "PAP-22", @@ -199,8 +206,16 @@ describe("worktree merge history planner", () => { }); expect(plan.counts.issuesToInsert).toBe(1); - expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]); - expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({ + expect( + plan.issuePlans + .filter((item) => item.action === "insert") + .map((item) => item.source.id), + ).toEqual(["issue-c"]); + expect( + plan.issuePlans.find( + (item) => item.source.id === "issue-c" && item.action === "insert", + ), + ).toMatchObject({ previewIdentifier: "PAP-501", }); }); @@ -266,7 +281,13 @@ describe("worktree merge history planner", () => { sourceComments: [], targetComments: [], targetAgents: [], - targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any, + targetProjects: [ + { + id: "target-project-1", + name: "Mapped project", + status: "in_progress", + }, + ] as any, targetProjectWorkspaces: [], targetGoals: [{ id: "goal-1" }] as any, projectIdOverrides: { @@ -343,8 +364,14 @@ describe("worktree merge history planner", () => { identifier: "PAP-11", createdAt: new Date("2026-03-20T01:00:00.000Z"), }); - const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" }); - const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" }); + const existingComment = makeComment({ + id: "comment-existing", + issueId: "issue-a", + }); + const sharedIssueComment = makeComment({ + id: "comment-shared", + issueId: "issue-a", + }); const newIssueComment = makeComment({ id: "comment-new-issue", issueId: "issue-b", @@ -370,10 +397,11 @@ describe("worktree merge history planner", () => { expect(plan.counts.commentsToInsert).toBe(2); expect(plan.counts.commentsExisting).toBe(1); - expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([ - "comment-shared", - "comment-new-issue", - ]); + expect( + plan.commentPlans + .filter((item) => item.action === "insert") + .map((item) => item.source.id), + ).toEqual(["comment-shared", "comment-new-issue"]); expect(plan.adjustments.clear_author_agent).toBe(1); }); @@ -397,7 +425,10 @@ describe("worktree merge history planner", () => { documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"), linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"), }); - const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" }); + const sourceRevisionOne = makeDocumentRevision({ + documentId: "document-a", + id: "revision-1", + }); const sourceRevisionTwo = makeDocumentRevision({ documentId: "document-a", id: "revision-branch-2", @@ -405,7 +436,10 @@ describe("worktree merge history planner", () => { body: "# Branch plan", createdAt: new Date("2026-03-20T02:00:00.000Z"), }); - const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" }); + const targetRevisionOne = makeDocumentRevision({ + documentId: "document-a", + id: "revision-1", + }); const targetRevisionTwo = makeDocumentRevision({ documentId: "document-a", id: "revision-main-2", diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index eee4bf7..69eee64 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -47,7 +47,9 @@ import { const ORIGINAL_CWD = process.cwd(); const ORIGINAL_ENV = { ...process.env }; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported + ? describe + : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( @@ -128,7 +130,9 @@ function buildSourceConfig(): TaskcoreConfig { describe("worktree helpers", () => { it("sanitizes instance ids", () => { - expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); + expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe( + "feature-worktree-support", + ); expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); }); @@ -151,7 +155,14 @@ describe("worktree helpers", () => { targetPath: "/tmp/feature-branch", branchExists: false, }), - ).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]); + ).toEqual([ + "worktree", + "add", + "-b", + "feature-branch", + "/tmp/feature-branch", + "HEAD", + ]); expect( resolveGitWorktreeAddArgs({ @@ -170,7 +181,14 @@ describe("worktree helpers", () => { branchExists: false, startPoint: "public-gh/master", }), - ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); + ).toEqual([ + "worktree", + "add", + "-b", + "my-worktree", + "/tmp/my-worktree", + "public-gh/master", + ]); }); it("uses start point even when a local branch with the same name exists", () => { @@ -181,12 +199,23 @@ describe("worktree helpers", () => { branchExists: true, startPoint: "origin/main", }), - ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); + ).toEqual([ + "worktree", + "add", + "-b", + "my-worktree", + "/tmp/my-worktree", + "origin/main", + ]); }); it("rewrites loopback auth URLs to the new port only", () => { - expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); - expect(rewriteLocalUrlPort("https://taskcore.example", 3110)).toBe("https://taskcore.example"); + expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe( + "http://127.0.0.1:3110/", + ); + expect(rewriteLocalUrlPort("https://taskcore.example", 3110)).toBe( + "https://taskcore.example", + ); }); it("builds isolated config and env paths for a worktree", () => { @@ -204,13 +233,24 @@ describe("worktree helpers", () => { }); expect(config.database.embeddedPostgresDataDir).toBe( - path.resolve("/tmp/taskcore-worktrees", "instances", "feature-worktree-support", "db"), + path.resolve( + "/tmp/taskcore-worktrees", + "instances", + "feature-worktree-support", + "db", + ), ); expect(config.database.embeddedPostgresPort).toBe(54339); expect(config.server.port).toBe(3110); expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); expect(config.storage.localDisk.baseDir).toBe( - path.resolve("/tmp/taskcore-worktrees", "instances", "feature-worktree-support", "data", "storage"), + path.resolve( + "/tmp/taskcore-worktrees", + "instances", + "feature-worktree-support", + "data", + "storage", + ), ); const env = buildWorktreeEnvEntries(paths, { @@ -222,7 +262,9 @@ describe("worktree helpers", () => { expect(env.TASKCORE_IN_WORKTREE).toBe("true"); expect(env.TASKCORE_WORKTREE_NAME).toBe("feature-worktree-support"); expect(env.TASKCORE_WORKTREE_COLOR).toBe("#3abf7a"); - expect(formatShellExports(env)).toContain("export TASKCORE_INSTANCE_ID='feature-worktree-support'"); + expect(formatShellExports(env)).toContain( + "export TASKCORE_INSTANCE_ID='feature-worktree-support'", + ); }); it("falls back across storage roots before skipping a missing attachment object", async () => { @@ -253,7 +295,11 @@ describe("worktree helpers", () => { getObject: vi.fn().mockRejectedValue(missingErr), }, { - getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })), + getObject: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("missing"), { status: 404 }), + ), }, ], "company-1", @@ -274,22 +320,37 @@ describe("worktree helpers", () => { expect(minimal.excludedTables).toContain("heartbeat_run_events"); expect(minimal.excludedTables).toContain("workspace_runtime_services"); expect(minimal.excludedTables).toContain("agent_task_sessions"); - expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); + expect(minimal.nullifyColumns.issues).toEqual([ + "checkout_run_id", + "execution_run_id", + ]); expect(full.excludedTables).toEqual([]); expect(full.nullifyColumns).toEqual({}); }); it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-secrets-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-secrets-"), + ); const originalInlineMasterKey = process.env.TASKCORE_SECRETS_MASTER_KEY; const originalKeyFile = process.env.TASKCORE_SECRETS_MASTER_KEY_FILE; try { delete process.env.TASKCORE_SECRETS_MASTER_KEY; delete process.env.TASKCORE_SECRETS_MASTER_KEY_FILE; const sourceConfigPath = path.join(tempRoot, "source", "config.json"); - const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); - const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + const sourceKeyPath = path.join( + tempRoot, + "source", + "secrets", + "master.key", + ); + const targetKeyPath = path.join( + tempRoot, + "target", + "secrets", + "master.key", + ); fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); @@ -320,10 +381,17 @@ describe("worktree helpers", () => { }); it("writes the source inline secrets master key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-secrets-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-secrets-"), + ); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); - const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + const targetKeyPath = path.join( + tempRoot, + "target", + "secrets", + "master.key", + ); copySeededSecretsKey({ sourceConfigPath, @@ -334,14 +402,18 @@ describe("worktree helpers", () => { targetKeyFilePath: targetKeyPath, }); - expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe( + "inline-source-master-key", + ); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); it("persists the current agent jwt secret into the worktree env file", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-jwt-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-jwt-"), + ); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); const originalJwtSecret = process.env.TASKCORE_AGENT_JWT_SECRET; @@ -359,7 +431,9 @@ describe("worktree helpers", () => { const envPath = path.join(repoRoot, ".taskcore", ".env"); const envContents = fs.readFileSync(envPath, "utf8"); - expect(envContents).toContain("TASKCORE_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain( + "TASKCORE_AGENT_JWT_SECRET=worktree-shared-secret", + ); expect(envContents).toContain("TASKCORE_WORKTREE_NAME=repo"); expect(envContents).toMatch(/TASKCORE_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); } finally { @@ -374,10 +448,16 @@ describe("worktree helpers", () => { }); it("avoids ports already claimed by sibling worktree instance configs", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-claimed-ports-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-claimed-ports-"), + ); const repoRoot = path.join(tempRoot, "repo"); const homeDir = path.join(tempRoot, ".taskcore-worktrees"); - const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); + const siblingInstanceRoot = path.join( + homeDir, + "instances", + "existing-worktree", + ); const originalCwd = process.cwd(); try { @@ -427,7 +507,11 @@ describe("worktree helpers", () => { provider: "local_encrypted", strictMode: false, localEncrypted: { - keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"), + keyFilePath: path.join( + siblingInstanceRoot, + "secrets", + "master.key", + ), }, }, }, @@ -443,7 +527,12 @@ describe("worktree helpers", () => { home: homeDir, }); - const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".taskcore", "config.json"), "utf8")); + const config = JSON.parse( + fs.readFileSync( + path.join(repoRoot, ".taskcore", "config.json"), + "utf8", + ), + ); expect(config.server.port).toBeGreaterThan(3101); expect(config.database.embeddedPostgresPort).not.toBe(54330); expect(config.database.embeddedPostgresPort).not.toBe(config.server.port); @@ -455,7 +544,9 @@ describe("worktree helpers", () => { }); it("defaults the seed source config to the current repo-local Taskcore config", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-source-config-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-source-config-"), + ); const repoRoot = path.join(tempRoot, "repo"); const localConfigPath = path.join(repoRoot, ".taskcore", "config.json"); const originalCwd = process.cwd(); @@ -463,11 +554,17 @@ describe("worktree helpers", () => { try { fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); - fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync( + localConfigPath, + JSON.stringify(buildSourceConfig()), + "utf8", + ); delete process.env.TASKCORE_CONFIG; process.chdir(repoRoot); - expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); + expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe( + fs.realpathSync(localConfigPath), + ); } finally { process.chdir(originalCwd); if (originalTaskcoreConfig === undefined) { @@ -480,7 +577,9 @@ describe("worktree helpers", () => { }); it("preserves the source config path across worktree:make cwd changes", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-source-override-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-source-override-"), + ); const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "target"); const originalCwd = process.cwd(); @@ -489,13 +588,17 @@ describe("worktree helpers", () => { try { fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); fs.mkdirSync(targetRoot, { recursive: true }); - fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync( + sourceConfigPath, + JSON.stringify(buildSourceConfig()), + "utf8", + ); delete process.env.TASKCORE_CONFIG; process.chdir(targetRoot); - expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( - path.resolve(sourceConfigPath), - ); + expect( + resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath }), + ).toBe(path.resolve(sourceConfigPath)); } finally { process.chdir(originalCwd); if (originalTaskcoreConfig === undefined) { @@ -514,16 +617,20 @@ describe("worktree helpers", () => { }); it("rejects mixed reseed source selectors", () => { - expect(() => resolveWorktreeReseedSource({ - from: "current", - fromInstance: "default", - })).toThrow( + expect(() => + resolveWorktreeReseedSource({ + from: "current", + fromInstance: "default", + }), + ).toThrow( "Use either --from or --from-config/--from-data-dir/--from-instance, not both.", ); }); it("derives worktree reseed target paths from the adjacent env file", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-reseed-target-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-reseed-target-"), + ); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".taskcore", "config.json"); const envPath = path.join(worktreeRoot, ".taskcore", ".env"); @@ -555,27 +662,36 @@ describe("worktree helpers", () => { }); it("rejects reseed targets without worktree env metadata", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-reseed-target-missing-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-reseed-target-missing-"), + ); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".taskcore", "config.json"); try { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8"); - fs.writeFileSync(path.join(worktreeRoot, ".taskcore", ".env"), "", "utf8"); + fs.writeFileSync( + path.join(worktreeRoot, ".taskcore", ".env"), + "", + "utf8", + ); expect(() => resolveWorktreeReseedTargetPaths({ configPath, rootPath: worktreeRoot, - })).toThrow("does not look like a worktree-local Taskcore instance"); + }), + ).toThrow("does not look like a worktree-local Taskcore instance"); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); it("reseed preserves the current worktree ports, instance id, and branding", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-reseed-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-reseed-"), + ); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".taskcore-worktrees"); @@ -596,7 +712,9 @@ describe("worktree helpers", () => { try { fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); - fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { + recursive: true, + }); fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(sourceRoot, { recursive: true }); @@ -612,8 +730,16 @@ describe("worktree helpers", () => { serverPort: 3200, databasePort: 54400, }); - fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); - fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync( + currentPaths.configPath, + JSON.stringify(currentConfig, null, 2), + "utf8", + ); + fs.writeFileSync( + sourcePaths.configPath, + JSON.stringify(sourceConfig, null, 2), + "utf8", + ); fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); fs.writeFileSync( currentPaths.envPath, @@ -621,7 +747,7 @@ describe("worktree helpers", () => { `TASKCORE_HOME=${homeDir}`, `TASKCORE_INSTANCE_ID=${currentInstanceId}`, "TASKCORE_WORKTREE_NAME=existing-name", - "TASKCORE_WORKTREE_COLOR=\"#112233\"", + 'TASKCORE_WORKTREE_COLOR="#112233"', ].join("\n"), "utf8", ); @@ -634,15 +760,21 @@ describe("worktree helpers", () => { yes: true, }); - const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const rewrittenConfig = JSON.parse( + fs.readFileSync(currentPaths.configPath, "utf8"), + ); const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8"); expect(rewrittenConfig.server.port).toBe(3114); expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341); - expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir); - expect(rewrittenEnv).toContain(`TASKCORE_INSTANCE_ID=${currentInstanceId}`); + expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe( + currentPaths.embeddedPostgresDataDir, + ); + expect(rewrittenEnv).toContain( + `TASKCORE_INSTANCE_ID=${currentInstanceId}`, + ); expect(rewrittenEnv).toContain("TASKCORE_WORKTREE_NAME=existing-name"); - expect(rewrittenEnv).toContain("TASKCORE_WORKTREE_COLOR=\"#112233\""); + expect(rewrittenEnv).toContain('TASKCORE_WORKTREE_COLOR="#112233"'); } finally { process.chdir(originalCwd); if (originalTaskcoreConfig === undefined) { @@ -655,7 +787,9 @@ describe("worktree helpers", () => { }, 20_000); it("restores the current worktree config and instance data if reseed fails", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-reseed-rollback-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-reseed-rollback-"), + ); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".taskcore-worktrees"); @@ -677,7 +811,9 @@ describe("worktree helpers", () => { fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); fs.mkdirSync(currentPaths.instanceRoot, { recursive: true }); - fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { + recursive: true, + }); fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(sourceRoot, { recursive: true }); @@ -702,27 +838,54 @@ describe("worktree helpers", () => { }, } as TaskcoreConfig; - fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); - fs.writeFileSync(currentPaths.envPath, `TASKCORE_HOME=${homeDir}\nTASKCORE_INSTANCE_ID=${currentInstanceId}\n`, "utf8"); - fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8"); - fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync( + currentPaths.configPath, + JSON.stringify(currentConfig, null, 2), + "utf8", + ); + fs.writeFileSync( + currentPaths.envPath, + `TASKCORE_HOME=${homeDir}\nTASKCORE_INSTANCE_ID=${currentInstanceId}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(currentPaths.instanceRoot, "marker.txt"), + "keep me", + "utf8", + ); + fs.writeFileSync( + sourcePaths.configPath, + JSON.stringify(sourceConfig, null, 2), + "utf8", + ); fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); delete process.env.TASKCORE_CONFIG; process.chdir(repoRoot); - await expect(worktreeReseedCommand({ - fromConfig: sourcePaths.configPath, - yes: true, - })).rejects.toThrow("Source instance uses postgres mode but has no connection string"); + await expect( + worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + yes: true, + }), + ).rejects.toThrow( + "Source instance uses postgres mode but has no connection string", + ); - const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const restoredConfig = JSON.parse( + fs.readFileSync(currentPaths.configPath, "utf8"), + ); const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8"); - const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8"); + const restoredMarker = fs.readFileSync( + path.join(currentPaths.instanceRoot, "marker.txt"), + "utf8", + ); expect(restoredConfig.server.port).toBe(3114); expect(restoredConfig.database.embeddedPostgresPort).toBe(54341); - expect(restoredEnv).toContain(`TASKCORE_INSTANCE_ID=${currentInstanceId}`); + expect(restoredEnv).toContain( + `TASKCORE_INSTANCE_ID=${currentInstanceId}`, + ); expect(restoredMarker).toBe("keep me"); } finally { process.chdir(originalCwd); @@ -764,27 +927,50 @@ describe("worktree helpers", () => { }); it("copies shared git hooks into a linked worktree git dir", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-hooks-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-hooks-"), + ); const repoRoot = path.join(tempRoot, "repo"); const worktreePath = path.join(tempRoot, "repo-feature"); try { fs.mkdirSync(repoRoot, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test User"], { + cwd: repoRoot, + stdio: "ignore", + }); fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["add", "README.md"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["commit", "-m", "Initial commit"], { + cwd: repoRoot, + stdio: "ignore", + }); const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); - const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt"); - fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 }); + const sourceTokensPath = path.join( + sourceHooksDir, + "forbidden-tokens.txt", + ); + fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { + encoding: "utf8", + mode: 0o755, + }); fs.chmodSync(sourceHookPath, 0o755); fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8"); - execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["worktree", "add", "--detach", worktreePath], { + cwd: repoRoot, + stdio: "ignore", + }); const copied = copyGitHooksToWorktreeGitDir(worktreePath); const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], { @@ -793,26 +979,38 @@ describe("worktree helpers", () => { stdio: ["ignore", "pipe", "ignore"], }).trim(); const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir); - const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks")); + const resolvedTargetHooksDir = fs.realpathSync( + path.resolve(worktreePath, worktreeGitDir, "hooks"), + ); const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit"); - const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt"); + const targetTokensPath = path.join( + resolvedTargetHooksDir, + "forbidden-tokens.txt", + ); expect(copied).toMatchObject({ sourceHooksPath: resolvedSourceHooksDir, targetHooksPath: resolvedTargetHooksDir, copied: true, }); - expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n"); + expect(fs.readFileSync(targetHookPath, "utf8")).toBe( + "#!/usr/bin/env bash\nexit 0\n", + ); expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0); expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n"); } finally { - execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["worktree", "remove", "--force", worktreePath], { + cwd: repoRoot, + stdio: "ignore", + }); fs.rmSync(tempRoot, { recursive: true, force: true }); } }); it("creates and initializes a worktree from the top-level worktree:make command", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-make-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-make-"), + ); const repoRoot = path.join(tempRoot, "repo"); const fakeHome = path.join(tempRoot, "home"); const worktreePath = path.join(fakeHome, "taskcore-make-test"); @@ -823,11 +1021,23 @@ describe("worktree helpers", () => { fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(fakeHome, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test User"], { + cwd: repoRoot, + stdio: "ignore", + }); fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["add", "README.md"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["commit", "-m", "Initial commit"], { + cwd: repoRoot, + stdio: "ignore", + }); process.chdir(repoRoot); @@ -837,8 +1047,12 @@ describe("worktree helpers", () => { }); expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true); - expect(fs.existsSync(path.join(worktreePath, ".taskcore", "config.json"))).toBe(true); - expect(fs.existsSync(path.join(worktreePath, ".taskcore", ".env"))).toBe(true); + expect( + fs.existsSync(path.join(worktreePath, ".taskcore", "config.json")), + ).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".taskcore", ".env"))).toBe( + true, + ); } finally { process.chdir(originalCwd); homedirSpy.mockRestore(); @@ -847,24 +1061,42 @@ describe("worktree helpers", () => { }, 20_000); it("no-ops on the primary checkout unless --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-repair-primary-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-repair-primary-"), + ); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); try { fs.mkdirSync(repoRoot, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test User"], { + cwd: repoRoot, + stdio: "ignore", + }); fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["add", "README.md"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["commit", "-m", "Initial commit"], { + cwd: repoRoot, + stdio: "ignore", + }); process.chdir(repoRoot); await worktreeRepairCommand({}); - expect(fs.existsSync(path.join(repoRoot, ".taskcore", "config.json"))).toBe(false); - expect(fs.existsSync(path.join(repoRoot, ".taskcore", "worktrees"))).toBe(false); + expect( + fs.existsSync(path.join(repoRoot, ".taskcore", "config.json")), + ).toBe(false); + expect(fs.existsSync(path.join(repoRoot, ".taskcore", "worktrees"))).toBe( + false, + ); } finally { process.chdir(originalCwd); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -872,9 +1104,16 @@ describe("worktree helpers", () => { }); it("repairs the current linked worktree when Taskcore metadata is missing", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-repair-current-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-repair-current-"), + ); const repoRoot = path.join(tempRoot, "repo"); - const worktreePath = path.join(repoRoot, ".taskcore", "worktrees", "repair-me"); + const worktreePath = path.join( + repoRoot, + ".taskcore", + "worktrees", + "repair-me", + ); const sourceConfigPath = path.join(tempRoot, "source-config.json"); const worktreeHome = path.join(tempRoot, ".taskcore-worktrees"); const worktreePaths = resolveWorktreeLocalPaths({ @@ -887,20 +1126,44 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test User"], { + cwd: repoRoot, + stdio: "ignore", + }); fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], { + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore", }); + execFileSync("git", ["commit", "-m", "Initial commit"], { + cwd: repoRoot, + stdio: "ignore", + }); + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + execFileSync( + "git", + ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], + { + cwd: repoRoot, + stdio: "ignore", + }, + ); - fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); + fs.writeFileSync( + sourceConfigPath, + JSON.stringify(buildSourceConfig(), null, 2), + "utf8", + ); fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true }); - fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8"); + fs.writeFileSync( + path.join(worktreePaths.instanceRoot, "marker.txt"), + "stale", + "utf8", + ); process.chdir(worktreePath); await worktreeRepairCommand({ @@ -909,9 +1172,15 @@ describe("worktree helpers", () => { noSeed: true, }); - expect(fs.existsSync(path.join(worktreePath, ".taskcore", "config.json"))).toBe(true); - expect(fs.existsSync(path.join(worktreePath, ".taskcore", ".env"))).toBe(true); - expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false); + expect( + fs.existsSync(path.join(worktreePath, ".taskcore", "config.json")), + ).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".taskcore", ".env"))).toBe( + true, + ); + expect( + fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt")), + ).toBe(false); } finally { process.chdir(originalCwd); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -919,22 +1188,45 @@ describe("worktree helpers", () => { }, 20_000); it("creates and repairs a missing branch worktree when --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-repair-branch-")); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "taskcore-worktree-repair-branch-"), + ); const repoRoot = path.join(tempRoot, "repo"); const sourceConfigPath = path.join(tempRoot, "source-config.json"); const worktreeHome = path.join(tempRoot, ".taskcore-worktrees"); const originalCwd = process.cwd(); - const expectedWorktreePath = path.join(repoRoot, ".taskcore", "worktrees", "feature-repair-me"); + const expectedWorktreePath = path.join( + repoRoot, + ".taskcore", + "worktrees", + "feature-repair-me", + ); try { fs.mkdirSync(repoRoot, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test User"], { + cwd: repoRoot, + stdio: "ignore", + }); fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); + execFileSync("git", ["add", "README.md"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["commit", "-m", "Initial commit"], { + cwd: repoRoot, + stdio: "ignore", + }); + fs.writeFileSync( + sourceConfigPath, + JSON.stringify(buildSourceConfig(), null, 2), + "utf8", + ); process.chdir(repoRoot); await worktreeRepairCommand({ @@ -945,8 +1237,14 @@ describe("worktree helpers", () => { }); expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true); - expect(fs.existsSync(path.join(expectedWorktreePath, ".taskcore", "config.json"))).toBe(true); - expect(fs.existsSync(path.join(expectedWorktreePath, ".taskcore", ".env"))).toBe(true); + expect( + fs.existsSync( + path.join(expectedWorktreePath, ".taskcore", "config.json"), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(expectedWorktreePath, ".taskcore", ".env")), + ).toBe(true); } finally { process.chdir(originalCwd); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -956,7 +1254,9 @@ describe("worktree helpers", () => { describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => { it("pauses only routines with enabled schedule triggers", async () => { - const tempDb = await startEmbeddedPostgresTestDatabase("taskcore-worktree-routines-"); + const tempDb = await startEmbeddedPostgresTestDatabase( + "taskcore-worktree-routines-", + ); const db = createDb(tempDb.connectionString); const companyId = randomUUID(); const projectId = randomUUID(); @@ -1072,10 +1372,14 @@ describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => { }, ]); - const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString); + const pausedCount = await pauseSeededScheduledRoutines( + tempDb.connectionString, + ); expect(pausedCount).toBe(1); - const rows = await db.select({ id: routines.id, status: routines.status }).from(routines); + const rows = await db + .select({ id: routines.id, status: routines.status }) + .from(routines); const statusById = new Map(rows.map((row) => [row.id, row.status])); expect(statusById.get(activeScheduledRoutineId)).toBe("paused"); expect(statusById.get(activeApiRoutineId)).toBe("active"); diff --git a/cli/src/checks/config-check.ts b/cli/src/checks/config-check.ts index fd34669..dde69da 100644 --- a/cli/src/checks/config-check.ts +++ b/cli/src/checks/config-check.ts @@ -1,4 +1,8 @@ -import { readConfig, configExists, resolveConfigPath } from "../config/store.js"; +import { + readConfig, + configExists, + resolveConfigPath, +} from "../config/store.js"; import type { CheckResult } from "./index.js"; export function configCheck(configPath?: string): CheckResult { @@ -27,7 +31,8 @@ export function configCheck(configPath?: string): CheckResult { status: "fail", message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`, canRepair: false, - repairHint: "Run `taskcore configure --section database` (or `taskcore onboard` to recreate)", + repairHint: + "Run `taskcore configure --section database` (or `taskcore onboard` to recreate)", }; } } diff --git a/cli/src/checks/database-check.ts b/cli/src/checks/database-check.ts index b50ca35..2579f2e 100644 --- a/cli/src/checks/database-check.ts +++ b/cli/src/checks/database-check.ts @@ -3,7 +3,10 @@ import type { TaskcoreConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; import { resolveRuntimeLikePath } from "./path-resolver.js"; -export async function databaseCheck(config: TaskcoreConfig, configPath?: string): Promise { +export async function databaseCheck( + config: TaskcoreConfig, + configPath?: string, +): Promise { if (config.database.mode === "postgres") { if (!config.database.connectionString) { return { @@ -30,13 +33,17 @@ export async function databaseCheck(config: TaskcoreConfig, configPath?: string) status: "fail", message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`, canRepair: false, - repairHint: "Check your connection string and ensure PostgreSQL is running", + repairHint: + "Check your connection string and ensure PostgreSQL is running", }; } } if (config.database.mode === "embedded-postgres") { - const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath); + const dataDir = resolveRuntimeLikePath( + config.database.embeddedPostgresDataDir, + configPath, + ); const reportedPath = dataDir; if (!fs.existsSync(dataDir)) { fs.mkdirSync(reportedPath, { recursive: true }); diff --git a/cli/src/checks/deployment-auth-check.ts b/cli/src/checks/deployment-auth-check.ts index 0b6c097..f5a142d 100644 --- a/cli/src/checks/deployment-auth-check.ts +++ b/cli/src/checks/deployment-auth-check.ts @@ -15,7 +15,8 @@ export function deploymentAuthCheck(config: TaskcoreConfig): CheckResult { status: "fail", message: `local_trusted requires loopback binding (found ${bind})`, canRepair: false, - repairHint: "Run `taskcore configure --section server` and choose Local trusted / loopback reachability", + repairHint: + "Run `taskcore configure --section server` and choose Local trusted / loopback reachability", }; } return { @@ -32,7 +33,8 @@ export function deploymentAuthCheck(config: TaskcoreConfig): CheckResult { return { name: "Deployment/auth mode", status: "fail", - message: "authenticated mode requires BETTER_AUTH_SECRET (or TASKCORE_AGENT_JWT_SECRET)", + message: + "authenticated mode requires BETTER_AUTH_SECRET (or TASKCORE_AGENT_JWT_SECRET)", canRepair: false, repairHint: "Set BETTER_AUTH_SECRET before starting Taskcore", }; @@ -44,7 +46,8 @@ export function deploymentAuthCheck(config: TaskcoreConfig): CheckResult { status: "fail", message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl", canRepair: false, - repairHint: "Run `taskcore configure --section server` and provide a base URL", + repairHint: + "Run `taskcore configure --section server` and provide a base URL", }; } @@ -55,7 +58,8 @@ export function deploymentAuthCheck(config: TaskcoreConfig): CheckResult { status: "fail", message: "authenticated/public requires explicit auth.publicBaseUrl", canRepair: false, - repairHint: "Run `taskcore configure --section server` and select public exposure", + repairHint: + "Run `taskcore configure --section server` and select public exposure", }; } try { @@ -75,7 +79,8 @@ export function deploymentAuthCheck(config: TaskcoreConfig): CheckResult { status: "fail", message: "auth.publicBaseUrl is not a valid URL", canRepair: false, - repairHint: "Run `taskcore configure --section server` and provide a valid URL", + repairHint: + "Run `taskcore configure --section server` and provide a valid URL", }; } } diff --git a/cli/src/checks/llm-check.ts b/cli/src/checks/llm-check.ts index abc635e..e450351 100644 --- a/cli/src/checks/llm-check.ts +++ b/cli/src/checks/llm-check.ts @@ -34,7 +34,11 @@ export async function llmCheck(config: TaskcoreConfig): Promise { }), }); if (res.ok || res.status === 400) { - return { name: "LLM provider", status: "pass", message: "Claude API key is valid" }; + return { + name: "LLM provider", + status: "pass", + message: "Claude API key is valid", + }; } if (res.status === 401) { return { @@ -55,7 +59,11 @@ export async function llmCheck(config: TaskcoreConfig): Promise { headers: { Authorization: `Bearer ${config.llm.apiKey}` }, }); if (res.ok) { - return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" }; + return { + name: "LLM provider", + status: "pass", + message: "OpenAI API key is valid", + }; } if (res.status === 401) { return { diff --git a/cli/src/checks/log-check.ts b/cli/src/checks/log-check.ts index 6e4e14e..8a2f217 100644 --- a/cli/src/checks/log-check.ts +++ b/cli/src/checks/log-check.ts @@ -3,7 +3,10 @@ import type { TaskcoreConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; import { resolveRuntimeLikePath } from "./path-resolver.js"; -export function logCheck(config: TaskcoreConfig, configPath?: string): CheckResult { +export function logCheck( + config: TaskcoreConfig, + configPath?: string, +): CheckResult { const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath); const reportedDir = logDir; diff --git a/cli/src/checks/secrets-check.ts b/cli/src/checks/secrets-check.ts index 1584ba6..a5e5637 100644 --- a/cli/src/checks/secrets-check.ts +++ b/cli/src/checks/secrets-check.ts @@ -27,7 +27,10 @@ function decodeMasterKey(raw: string): Buffer | null { } function withStrictModeNote( - base: Pick, + base: Pick< + CheckResult, + "name" | "status" | "message" | "canRepair" | "repair" | "repairHint" + >, config: TaskcoreConfig, ): CheckResult { const strictModeDisabledInDeployedSetup = @@ -45,7 +48,10 @@ function withStrictModeNote( }; } -export function secretsCheck(config: TaskcoreConfig, configPath?: string): CheckResult { +export function secretsCheck( + config: TaskcoreConfig, + configPath?: string, +): CheckResult { const provider = config.secrets.provider; if (provider !== "local_encrypted") { return { @@ -53,7 +59,8 @@ export function secretsCheck(config: TaskcoreConfig, configPath?: string): Check status: "fail", message: `${provider} is configured, but this build only supports local_encrypted`, canRepair: false, - repairHint: "Run `taskcore configure --section secrets` and set provider to local_encrypted", + repairHint: + "Run `taskcore configure --section secrets` and set provider to local_encrypted", }; } @@ -66,7 +73,8 @@ export function secretsCheck(config: TaskcoreConfig, configPath?: string): Check message: "TASKCORE_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)", canRepair: false, - repairHint: "Set TASKCORE_SECRETS_MASTER_KEY to a valid key or unset it to use a key file", + repairHint: + "Set TASKCORE_SECRETS_MASTER_KEY to a valid key or unset it to use a key file", }; } @@ -74,7 +82,8 @@ export function secretsCheck(config: TaskcoreConfig, configPath?: string): Check { name: "Secrets adapter", status: "pass", - message: "Local encrypted provider configured via TASKCORE_SECRETS_MASTER_KEY", + message: + "Local encrypted provider configured via TASKCORE_SECRETS_MASTER_KEY", }, config, ); @@ -106,7 +115,8 @@ export function secretsCheck(config: TaskcoreConfig, configPath?: string): Check // best effort } }, - repairHint: "Run with --repair to create a local encrypted secrets key file", + repairHint: + "Run with --repair to create a local encrypted secrets key file", }, config, ); @@ -131,7 +141,8 @@ export function secretsCheck(config: TaskcoreConfig, configPath?: string): Check status: "fail", message: `Invalid key material in ${keyFilePath}`, canRepair: false, - repairHint: "Replace with valid key material or delete it and run doctor --repair", + repairHint: + "Replace with valid key material or delete it and run doctor --repair", }; } diff --git a/cli/src/checks/storage-check.ts b/cli/src/checks/storage-check.ts index b217b3b..55d3083 100644 --- a/cli/src/checks/storage-check.ts +++ b/cli/src/checks/storage-check.ts @@ -3,9 +3,15 @@ import type { TaskcoreConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; import { resolveRuntimeLikePath } from "./path-resolver.js"; -export function storageCheck(config: TaskcoreConfig, configPath?: string): CheckResult { +export function storageCheck( + config: TaskcoreConfig, + configPath?: string, +): CheckResult { if (config.storage.provider === "local_disk") { - const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath); + const baseDir = resolveRuntimeLikePath( + config.storage.localDisk.baseDir, + configPath, + ); if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true }); } @@ -48,4 +54,3 @@ export function storageCheck(config: TaskcoreConfig, configPath?: string): Check repairHint: "Verify credentials and endpoint in deployment environment", }; } - diff --git a/cli/src/client/board-auth.ts b/cli/src/client/board-auth.ts index 0c182a7..4cfad22 100644 --- a/cli/src/client/board-auth.ts +++ b/cli/src/client/board-auth.ts @@ -53,7 +53,9 @@ function defaultBoardAuthStore(): BoardAuthStore { } function toStringOrNull(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } function normalizeApiBase(apiBase: string): string { @@ -62,7 +64,8 @@ function normalizeApiBase(apiBase: string): string { export function resolveBoardAuthStorePath(overridePath?: string): string { if (overridePath?.trim()) return path.resolve(overridePath.trim()); - if (process.env.TASKCORE_AUTH_STORE?.trim()) return path.resolve(process.env.TASKCORE_AUTH_STORE.trim()); + if (process.env.TASKCORE_AUTH_STORE?.trim()) + return path.resolve(process.env.TASKCORE_AUTH_STORE.trim()); return resolveDefaultCliAuthPath(); } @@ -70,8 +73,13 @@ export function readBoardAuthStore(storePath?: string): BoardAuthStore { const filePath = resolveBoardAuthStorePath(storePath); if (!fs.existsSync(filePath)) return defaultBoardAuthStore(); - const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial | null; - const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {}; + const raw = JSON.parse( + fs.readFileSync(filePath, "utf8"), + ) as Partial | null; + const credentials = + raw?.credentials && typeof raw.credentials === "object" + ? raw.credentials + : {}; const normalized: Record = {}; for (const [key, value] of Object.entries(credentials)) { @@ -97,13 +105,21 @@ export function readBoardAuthStore(storePath?: string): BoardAuthStore { }; } -export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void { +export function writeBoardAuthStore( + store: BoardAuthStore, + storePath?: string, +): void { const filePath = resolveBoardAuthStorePath(storePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { + mode: 0o600, + }); } -export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null { +export function getStoredBoardCredential( + apiBase: string, + storePath?: string, +): BoardAuthCredential | null { const store = readBoardAuthStore(storePath); return store.credentials[normalizeApiBase(apiBase)] ?? null; } @@ -130,7 +146,10 @@ export function setStoredBoardCredential(input: { return credential; } -export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean { +export function removeStoredBoardCredential( + apiBase: string, + storePath?: string, +): boolean { const normalizedApiBase = normalizeApiBase(apiBase); const store = readBoardAuthStore(storePath); if (!store.credentials[normalizedApiBase]) return false; @@ -160,7 +179,9 @@ async function requestJson(url: string, init?: RequestInit): Promise { if (!response.ok) { const body = await response.json().catch(() => null); const message = - body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string" + body && + typeof body === "object" && + typeof (body as { error?: unknown }).error === "string" ? (body as { error: string }).error : `Request failed: ${response.status}`; throw new Error(message); @@ -178,7 +199,10 @@ export function openUrl(url: string): boolean { return true; } if (platform === "win32") { - const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }); + const child = spawn("cmd", ["/c", "start", "", url], { + detached: true, + stdio: "ignore", + }); child.unref(); return true; } @@ -213,10 +237,13 @@ export async function loginBoardCli(params: { }), }); - const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`; + const approvalUrl = + challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`; if (params.print !== false) { console.error(pc.bold("Board authentication required")); - console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`); + console.error( + `Open this URL in your browser to approve CLI access:\n${approvalUrl}`, + ); } const opened = openUrl(approvalUrl); @@ -233,14 +260,14 @@ export async function loginBoardCli(params: { ); if (status.status === "approved") { - const me = await requestJson<{ userId: string; user?: { id: string } | null }>( - `${apiBase}/api/cli-auth/me`, - { - headers: { - authorization: `Bearer ${challenge.boardApiToken}`, - }, + const me = await requestJson<{ + userId: string; + user?: { id: string } | null; + }>(`${apiBase}/api/cli-auth/me`, { + headers: { + authorization: `Bearer ${challenge.boardApiToken}`, }, - ); + }); setStoredBoardCredential({ apiBase, token: challenge.boardApiToken, @@ -272,11 +299,14 @@ export async function revokeStoredBoardCredential(params: { token: string; }): Promise { const apiBase = normalizeApiBase(params.apiBase); - await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, { - method: "POST", - headers: { - authorization: `Bearer ${params.token}`, + await requestJson<{ revoked: boolean }>( + `${apiBase}/api/cli-auth/revoke-current`, + { + method: "POST", + headers: { + authorization: `Bearer ${params.token}`, + }, + body: JSON.stringify({}), }, - body: JSON.stringify({}), - }); + ); } diff --git a/cli/src/client/context.ts b/cli/src/client/context.ts index 734cdac..e1aebce 100644 --- a/cli/src/client/context.ts +++ b/cli/src/client/context.ts @@ -22,7 +22,11 @@ function findContextFileFromAncestors(startDir: string): string | null { let currentDir = absoluteStartDir; while (true) { - const candidate = path.resolve(currentDir, ".taskcore", DEFAULT_CONTEXT_BASENAME); + const candidate = path.resolve( + currentDir, + ".taskcore", + DEFAULT_CONTEXT_BASENAME, + ); if (fs.existsSync(candidate)) { return candidate; } @@ -37,8 +41,11 @@ function findContextFileFromAncestors(startDir: string): string | null { export function resolveContextPath(overridePath?: string): string { if (overridePath) return path.resolve(overridePath); - if (process.env.TASKCORE_CONTEXT) return path.resolve(process.env.TASKCORE_CONTEXT); - return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath(); + if (process.env.TASKCORE_CONTEXT) + return path.resolve(process.env.TASKCORE_CONTEXT); + return ( + findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath() + ); } export function defaultClientContext(): ClientContext { @@ -55,16 +62,21 @@ function parseJson(filePath: string): unknown { try { return JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (err) { - throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); } } function toStringOrUndefined(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; } function normalizeProfile(value: unknown): ClientContextProfile { - if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return {}; const profile = value as Record; return { @@ -81,13 +93,20 @@ function normalizeContext(raw: unknown): ClientContext { const record = raw as Record; const version = record.version === 1 ? 1 : 1; - const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE; + const currentProfile = + toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE; const rawProfiles = record.profiles; const profiles: Record = {}; - if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) { - for (const [name, profile] of Object.entries(rawProfiles as Record)) { + if ( + typeof rawProfiles === "object" && + rawProfiles !== null && + !Array.isArray(rawProfiles) + ) { + for (const [name, profile] of Object.entries( + rawProfiles as Record, + )) { if (!name.trim()) continue; profiles[name] = normalizeProfile(profile); } @@ -118,13 +137,18 @@ export function readContext(contextPath?: string): ClientContext { return normalizeContext(raw); } -export function writeContext(context: ClientContext, contextPath?: string): void { +export function writeContext( + context: ClientContext, + contextPath?: string, +): void { const filePath = resolveContextPath(contextPath); const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); const normalized = normalizeContext(context); - fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); + fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { + mode: 0o600, + }); } export function upsertProfile( @@ -145,7 +169,10 @@ export function upsertProfile( if (patch.companyId !== undefined && patch.companyId.trim().length === 0) { delete merged.companyId; } - if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) { + if ( + patch.apiKeyEnvVarName !== undefined && + patch.apiKeyEnvVarName.trim().length === 0 + ) { delete merged.apiKeyEnvVarName; } @@ -155,7 +182,10 @@ export function upsertProfile( return context; } -export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext { +export function setCurrentProfile( + profileName: string, + contextPath?: string, +): ClientContext { const context = readContext(contextPath); if (!context.profiles[profileName]) { context.profiles[profileName] = {}; diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 732aac1..db0b8aa 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -5,7 +5,12 @@ export class ApiRequestError extends Error { details?: unknown; body?: unknown; - constructor(status: number, message: string, details?: unknown, body?: unknown) { + constructor( + status: number, + message: string, + details?: unknown, + body?: unknown, + ) { super(message); this.status = status; this.details = details; @@ -26,7 +31,14 @@ export class ApiConnectionError extends Error { }) { const url = buildUrl(input.apiBase, input.path); const causeMessage = formatConnectionCause(input.cause); - super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage })); + super( + buildConnectionErrorMessage({ + apiBase: input.apiBase, + url, + method: input.method, + causeMessage, + }), + ); this.url = url; this.method = input.method; this.causeMessage = causeMessage; @@ -67,18 +79,34 @@ export class TaskcoreApiClient { return this.request(path, { method: "GET" }, opts); } - post(path: string, body?: unknown, opts?: RequestOptions): Promise { - return this.request(path, { - method: "POST", - body: body === undefined ? undefined : JSON.stringify(body), - }, opts); + post( + path: string, + body?: unknown, + opts?: RequestOptions, + ): Promise { + return this.request( + path, + { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }, + opts, + ); } - patch(path: string, body?: unknown, opts?: RequestOptions): Promise { - return this.request(path, { - method: "PATCH", - body: body === undefined ? undefined : JSON.stringify(body), - }, opts); + patch( + path: string, + body?: unknown, + opts?: RequestOptions, + ): Promise { + return this.request( + path, + { + method: "PATCH", + body: body === undefined ? undefined : JSON.stringify(body), + }, + opts, + ); } delete(path: string, opts?: RequestOptions): Promise { @@ -194,7 +222,12 @@ async function toApiError(response: Response): Promise { return new ApiRequestError(response.status, message, body.details, parsed); } - return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed); + return new ApiRequestError( + response.status, + `Request failed with status ${response.status}`, + undefined, + parsed, + ); } function buildConnectionErrorMessage(input: { @@ -241,10 +274,14 @@ function formatConnectionCause(error: unknown): string | undefined { return message || undefined; } -function toStringRecord(headers: HeadersInit | undefined): Record { +function toStringRecord( + headers: HeadersInit | undefined, +): Record { if (!headers) return {}; if (Array.isArray(headers)) { - return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); + return Object.fromEntries( + headers.map(([key, value]) => [key, String(value)]), + ); } if (headers instanceof Headers) { return Object.fromEntries(headers.entries()); diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts index c7bc9e1..af23f1f 100644 --- a/cli/src/commands/allowed-hostname.ts +++ b/cli/src/commands/allowed-hostname.ts @@ -3,17 +3,26 @@ import pc from "picocolors"; import { normalizeHostnameInput } from "../config/hostnames.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; -export async function addAllowedHostname(host: string, opts: { config?: string }): Promise { +export async function addAllowedHostname( + host: string, + opts: { config?: string }, +): Promise { const configPath = resolveConfigPath(opts.config); const config = readConfig(opts.config); if (!config) { - p.log.error(`No config found at ${configPath}. Run ${pc.cyan("taskcore onboard")} first.`); + p.log.error( + `No config found at ${configPath}. Run ${pc.cyan("taskcore onboard")} first.`, + ); return; } const normalized = normalizeHostnameInput(host); - const current = new Set((config.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)); + const current = new Set( + (config.server.allowedHostnames ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ); const existed = current.has(normalized); current.add(normalized); @@ -31,10 +40,16 @@ export async function addAllowedHostname(host: string, opts: { config?: string } ); } - if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) { + if ( + !( + config.server.deploymentMode === "authenticated" && + config.server.exposure === "private" + ) + ) { p.log.message( - pc.dim("Note: allowed hostnames are enforced only in authenticated/private mode."), + pc.dim( + "Note: allowed hostnames are enforced only in authenticated/private mode.", + ), ); } } - diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index bfeaf82..ce39260 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -19,7 +19,10 @@ function resolveDbUrl(configPath?: string, explicitDbUrl?: string) { if (explicitDbUrl) return explicitDbUrl; const config = readConfig(configPath); if (process.env.DATABASE_URL) return process.env.DATABASE_URL; - if (config?.database.mode === "postgres" && config.database.connectionString) { + if ( + config?.database.mode === "postgres" && + config.database.connectionString + ) { return config.database.connectionString; } if (config?.database.mode === "embedded-postgres") { @@ -41,11 +44,12 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { return config.auth.publicBaseUrl.replace(/\/+$/, ""); } - const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host); + const bind = + config?.server.bind ?? inferBindModeFromHost(config?.server.host); const host = bind === "custom" - ? config?.server.customBindHost ?? config?.server.host ?? "localhost" - : config?.server.host ?? "localhost"; + ? (config?.server.customBindHost ?? config?.server.host ?? "localhost") + : (config?.server.host ?? "localhost"); const port = config?.server.port ?? 3100; const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host; return `http://${publicHost}:${port}`; @@ -62,20 +66,22 @@ export async function bootstrapCeoInvite(opts: { loadTaskcoreEnvFile(configPath); const config = readConfig(configPath); if (!config) { - p.log.error(`No config found at ${configPath}. Run ${pc.cyan("taskcore onboard")} first.`); + p.log.error( + `No config found at ${configPath}. Run ${pc.cyan("taskcore onboard")} first.`, + ); return; } if (config.server.deploymentMode !== "authenticated") { - p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode."); + p.log.info( + "Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.", + ); return; } const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { - p.log.error( - "Could not resolve database connection for bootstrap.", - ); + p.log.error("Could not resolve database connection for bootstrap."); return; } @@ -93,7 +99,9 @@ export async function bootstrapCeoInvite(opts: { .then((rows) => rows.length); if (existingAdminCount > 0 && !opts.force) { - p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite."); + p.log.info( + "Instance already has an admin user. Use --force to generate a new bootstrap invite.", + ); return; } @@ -111,7 +119,10 @@ export async function bootstrapCeoInvite(opts: { ); const token = createInviteToken(); - const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72)); + const expiresHours = Math.max( + 1, + Math.min(24 * 30, opts.expiresHours ?? 72), + ); const created = await db .insert(invites) .values({ @@ -130,8 +141,12 @@ export async function bootstrapCeoInvite(opts: { p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`); p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`); } catch (err) { - p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`); - p.log.info("If using embedded-postgres, start the Taskcore server and run this command again."); + p.log.error( + `Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`, + ); + p.log.info( + "If using embedded-postgres, start the Taskcore server and run this command again.", + ); } finally { await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); } diff --git a/cli/src/commands/client/activity.ts b/cli/src/commands/client/activity.ts index a72f0c1..74e8e4c 100644 --- a/cli/src/commands/client/activity.ts +++ b/cli/src/commands/client/activity.ts @@ -17,7 +17,9 @@ interface ActivityListOptions extends BaseClientOptions { } export function registerActivityCommands(program: Command): void { - const activity = program.command("activity").description("Activity log operations"); + const activity = program + .command("activity") + .description("Activity log operations"); addCommonClientOptions( activity diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 89625d9..d214dd3 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -47,13 +47,17 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); function codexSkillsHome(): string { const fromEnv = process.env.CODEX_HOME?.trim(); - const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); + const base = + fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); return path.join(base, "skills"); } function claudeSkillsHome(): string { const fromEnv = process.env.CLAUDE_HOME?.trim(); - const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); + const base = + fromEnv && fromEnv.length > 0 + ? fromEnv + : path.join(os.homedir(), ".claude"); return path.join(base, "skills"); } @@ -167,7 +171,10 @@ export function registerAgentCommands(program: Command): void { .action(async (opts: AgentListOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const rows = (await ctx.api.get(`/api/companies/${ctx.companyId}/agents`)) ?? []; + const rows = + (await ctx.api.get( + `/api/companies/${ctx.companyId}/agents`, + )) ?? []; if (ctx.json) { printOutput(rows, { json: true }); @@ -240,15 +247,22 @@ export function registerAgentCommands(program: Command): void { } const now = new Date().toISOString().replaceAll(":", "-"); - const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; - const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + const keyName = opts.keyName?.trim() + ? opts.keyName.trim() + : `local-cli-${now}`; + const key = await ctx.api.post( + `/api/agents/${agentRow.id}/keys`, + { name: keyName }, + ); if (!key) { throw new Error("Failed to create API key"); } const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { - const skillsDir = await resolveTaskcoreSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]); + const skillsDir = await resolveTaskcoreSkillsDir(__moduleDir, [ + path.resolve(process.cwd(), "skills"), + ]); if (!skillsDir) { throw new Error( "Could not locate local Taskcore skills directory. Expected ./skills in the repo checkout.", @@ -256,8 +270,16 @@ export function registerAgentCommands(program: Command): void { } installSummaries.push( - await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), - await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + await installSkillsForTarget( + skillsDir, + codexSkillsHome(), + "codex", + ), + await installSkillsForTarget( + skillsDir, + claudeSkillsHome(), + "claude", + ), ); } @@ -304,7 +326,9 @@ export function registerAgentCommands(program: Command): void { } } console.log(""); - console.log("# Run this in your shell before launching codex/claude:"); + console.log( + "# Run this in your shell before launching codex/claude:", + ); console.log(exportsText); } catch (err) { handleCommandError(err); diff --git a/cli/src/commands/client/approval.ts b/cli/src/commands/client/approval.ts index 469563c..49ea125 100644 --- a/cli/src/commands/client/approval.ts +++ b/cli/src/commands/client/approval.ts @@ -43,7 +43,9 @@ interface ApprovalCommentOptions extends BaseClientOptions { } export function registerApprovalCommands(program: Command): void { - const approval = program.command("approval").description("Approval operations"); + const approval = program + .command("approval") + .description("Approval operations"); addCommonClientOptions( approval @@ -98,7 +100,9 @@ export function registerApprovalCommands(program: Command): void { .action(async (approvalId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const row = await ctx.api.get(`/api/approvals/${approvalId}`); + const row = await ctx.api.get( + `/api/approvals/${approvalId}`, + ); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -111,7 +115,10 @@ export function registerApprovalCommands(program: Command): void { .command("create") .description("Create an approval request") .requiredOption("-C, --company-id ", "Company ID") - .requiredOption("--type ", "Approval type (hire_agent|approve_ceo_strategy)") + .requiredOption( + "--type ", + "Approval type (hire_agent|approve_ceo_strategy)", + ) .requiredOption("--payload ", "Approval payload as JSON object") .option("--requested-by-agent-id ", "Requesting agent ID") .option("--issue-ids ", "Comma-separated linked issue IDs") @@ -125,7 +132,10 @@ export function registerApprovalCommands(program: Command): void { requestedByAgentId: opts.requestedByAgentId, issueIds: parseCsv(opts.issueIds), }); - const created = await ctx.api.post(`/api/companies/${ctx.companyId}/approvals`, payload); + const created = await ctx.api.post( + `/api/companies/${ctx.companyId}/approvals`, + payload, + ); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -148,7 +158,10 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/approve`, payload); + const updated = await ctx.api.post( + `/api/approvals/${approvalId}/approve`, + payload, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -170,7 +183,10 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/reject`, payload); + const updated = await ctx.api.post( + `/api/approvals/${approvalId}/reject`, + payload, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -192,7 +208,10 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/request-revision`, payload); + const updated = await ctx.api.post( + `/api/approvals/${approvalId}/request-revision`, + payload, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -210,9 +229,14 @@ export function registerApprovalCommands(program: Command): void { try { const ctx = resolveCommandContext(opts); const payload = resubmitApprovalSchema.parse({ - payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined, + payload: opts.payload + ? parseJsonObject(opts.payload, "payload") + : undefined, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/resubmit`, payload); + const updated = await ctx.api.post( + `/api/approvals/${approvalId}/resubmit`, + payload, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -229,9 +253,12 @@ export function registerApprovalCommands(program: Command): void { .action(async (approvalId: string, opts: ApprovalCommentOptions) => { try { const ctx = resolveCommandContext(opts); - const created = await ctx.api.post(`/api/approvals/${approvalId}/comments`, { - body: opts.body, - }); + const created = await ctx.api.post( + `/api/approvals/${approvalId}/comments`, + { + body: opts.body, + }, + ); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -242,18 +269,27 @@ export function registerApprovalCommands(program: Command): void { function parseCsv(value: string | undefined): string[] | undefined { if (!value) return undefined; - const rows = value.split(",").map((v) => v.trim()).filter(Boolean); + const rows = value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); return rows.length > 0 ? rows : undefined; } function parseJsonObject(value: string, name: string): Record { try { const parsed = JSON.parse(value) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { throw new Error(`${name} must be a JSON object`); } return parsed as Record; } catch (err) { - throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/cli/src/commands/client/auth.ts b/cli/src/commands/client/auth.ts index 09f896b..0a54f22 100644 --- a/cli/src/commands/client/auth.ts +++ b/cli/src/commands/client/auth.ts @@ -17,21 +17,27 @@ interface AuthLoginOptions extends BaseClientOptions { instanceAdmin?: boolean; } -interface AuthLogoutOptions extends BaseClientOptions { } -interface AuthWhoamiOptions extends BaseClientOptions { } +interface AuthLogoutOptions extends BaseClientOptions {} +interface AuthWhoamiOptions extends BaseClientOptions {} export function registerClientAuthCommands(auth: Command): void { addCommonClientOptions( auth .command("login") .description("Authenticate the CLI for board-user access") - .option("--instance-admin", "Request instance-admin approval instead of plain board access", false) + .option( + "--instance-admin", + "Request instance-admin approval instead of plain board access", + false, + ) .action(async (opts: AuthLoginOptions) => { try { const ctx = resolveCommandContext(opts); const login = await loginBoardCli({ apiBase: ctx.api.apiBase, - requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board", + requestedAccess: opts.instanceAdmin + ? "instance_admin_required" + : "board", requestedCompanyId: ctx.companyId ?? null, command: "taskcore auth login", }); @@ -60,7 +66,15 @@ export function registerClientAuthCommands(auth: Command): void { const ctx = resolveCommandContext(opts); const credential = getStoredBoardCredential(ctx.api.apiBase); if (!credential) { - printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json }); + printOutput( + { + ok: true, + apiBase: ctx.api.apiBase, + revoked: false, + removedLocalCredential: false, + }, + { json: ctx.json }, + ); return; } let revoked = false; @@ -73,7 +87,9 @@ export function registerClientAuthCommands(auth: Command): void { } catch { // Remove the local credential even if the server-side revoke fails. } - const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase); + const removedLocalCredential = removeStoredBoardCredential( + ctx.api.apiBase, + ); printOutput( { ok: true, diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index 979d0a6..c931a6c 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -1,9 +1,16 @@ import pc from "picocolors"; import type { Command } from "commander"; -import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js"; +import { + getStoredBoardCredential, + loginBoardCli, +} from "../../client/board-auth.js"; import { buildCliCommandLabel } from "../../client/command-label.js"; import { readConfig } from "../../config/store.js"; -import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js"; +import { + readContext, + resolveProfile, + type ClientContextProfile, +} from "../../client/context.js"; import { ApiRequestError, TaskcoreApiClient } from "../../client/http.js"; export interface BaseClientOptions { @@ -25,10 +32,16 @@ export interface ResolvedClientContext { json: boolean; } -export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command { +export function addCommonClientOptions( + command: Command, + opts?: { includeCompany?: boolean }, +): Command { command .option("-c, --config ", "Path to Taskcore config file") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("--context ", "Path to CLI context file") .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Taskcore API") @@ -36,7 +49,10 @@ export function addCommonClientOptions(command: Command, opts?: { includeCompany .option("--json", "Output raw JSON"); if (opts?.includeCompany) { - command.option("-C, --company-id ", "Company ID (overrides context default)"); + command.option( + "-C, --company-id ", + "Company ID (overrides context default)", + ); } return command; @@ -47,7 +63,10 @@ export function resolveCommandContext( opts?: { requireCompany?: boolean }, ): ResolvedClientContext { const context = readContext(options.context); - const { name: profileName, profile } = resolveProfile(context, options.profile); + const { name: profileName, profile } = resolveProfile( + context, + options.profile, + ); const apiBase = options.apiBase?.trim() || @@ -59,7 +78,9 @@ export function resolveCommandContext( options.apiKey?.trim() || process.env.TASKCORE_API_KEY?.trim() || readKeyFromProfileEnv(profile); - const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase); + const storedBoardCredential = explicitApiKey + ? null + : getStoredBoardCredential(apiBase); const apiKey = explicitApiKey || storedBoardCredential?.token; const companyId = @@ -76,23 +97,26 @@ export function resolveCommandContext( const api = new TaskcoreApiClient({ apiBase, apiKey, - recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth() - ? undefined - : async ({ error }) => { - const requestedAccess = error.message.includes("Instance admin required") - ? "instance_admin_required" - : "board"; - if (!shouldRecoverBoardAuth(error)) { - return null; - } - const login = await loginBoardCli({ - apiBase, - requestedAccess, - requestedCompanyId: companyId ?? null, - command: buildCliCommandLabel(), - }); - return login.token; - }, + recoverAuth: + explicitApiKey || !canAttemptInteractiveBoardAuth() + ? undefined + : async ({ error }) => { + const requestedAccess = error.message.includes( + "Instance admin required", + ) + ? "instance_admin_required" + : "board"; + if (!shouldRecoverBoardAuth(error)) { + return null; + } + const login = await loginBoardCli({ + apiBase, + requestedAccess, + requestedCompanyId: companyId ?? null, + command: buildCliCommandLabel(), + }); + return login.token; + }, }); return { api, @@ -106,14 +130,20 @@ export function resolveCommandContext( function shouldRecoverBoardAuth(error: ApiRequestError): boolean { if (error.status === 401) return true; if (error.status !== 403) return false; - return error.message.includes("Board access required") || error.message.includes("Instance admin required"); + return ( + error.message.includes("Board access required") || + error.message.includes("Instance admin required") + ); } function canAttemptInteractiveBoardAuth(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void { +export function printOutput( + data: unknown, + opts: { json?: boolean; label?: string } = {}, +): void { if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -152,7 +182,15 @@ export function printOutput(data: unknown, opts: { json?: boolean; label?: strin } export function formatInlineRecord(record: Record): string { - const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"]; + const keyOrder = [ + "identifier", + "id", + "name", + "status", + "priority", + "title", + "action", + ]; const seen = new Set(); const parts: string[] = []; @@ -203,15 +241,22 @@ function inferApiBaseFromConfig(configPath?: string): string { return `http://${envHost}:${port}`; } -function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined { +function readKeyFromProfileEnv( + profile: ClientContextProfile, +): string | undefined { if (!profile.apiKeyEnvVarName) return undefined; return process.env[profile.apiKeyEnvVarName]?.trim() || undefined; } export function handleCommandError(error: unknown): never { if (error instanceof ApiRequestError) { - const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : ""; - console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`)); + const detailSuffix = + error.details !== undefined + ? ` details=${JSON.stringify(error.details)}` + : ""; + console.error( + pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`), + ); process.exit(1); } diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b56748c..79229c2 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -30,7 +30,7 @@ import { serializeFeedbackTraces, } from "./feedback.js"; -interface CompanyCommandOptions extends BaseClientOptions { } +interface CompanyCommandOptions extends BaseClientOptions {} type CompanyDeleteSelectorMode = "auto" | "id" | "prefix"; type CompanyImportTargetMode = "new" | "existing"; type CompanyCollisionMode = "rename" | "skip" | "replace"; @@ -99,12 +99,24 @@ const IMPORT_INCLUDE_OPTIONS: Array<{ label: string; hint: string; }> = [ - { value: "company", label: "Company", hint: "name, branding, and company settings" }, - { value: "projects", label: "Projects", hint: "projects and workspace metadata" }, - { value: "issues", label: "Tasks", hint: "tasks and recurring routines" }, - { value: "agents", label: "Agents", hint: "agent records and org structure" }, - { value: "skills", label: "Skills", hint: "company skill packages and references" }, - ]; + { + value: "company", + label: "Company", + hint: "name, branding, and company settings", + }, + { + value: "projects", + label: "Projects", + hint: "projects and workspace metadata", + }, + { value: "issues", label: "Tasks", hint: "tasks and recurring routines" }, + { value: "agents", label: "Agents", hint: "agent records and org structure" }, + { + value: "skills", + label: "Skills", + hint: "company skill packages and references", + }, +]; const IMPORT_PREVIEW_SAMPLE_LIMIT = 6; @@ -115,7 +127,12 @@ type ImportSelectionCatalog = { includedByDefault: boolean; files: string[]; }; - projects: Array<{ key: string; label: string; hint?: string; files: string[] }>; + projects: Array<{ + key: string; + label: string; + hint?: string; + files: string[]; + }>; issues: Array<{ key: string; label: string; hint?: string; files: string[] }>; agents: Array<{ key: string; label: string; hint?: string; files: string[] }>; skills: Array<{ key: string; label: string; hint?: string; files: string[] }>; @@ -130,8 +147,12 @@ type ImportSelectionState = { skills: Set; }; -function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry { - const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; +function readPortableFileEntry( + filePath: string, + contents: Buffer, +): CompanyPortabilityFileEntry { + const contentType = + binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; if (!contentType) return contents.toString("utf8"); return { encoding: "base64", @@ -140,13 +161,17 @@ function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPorta }; } -function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array { +function portableFileEntryToWriteValue( + entry: CompanyPortabilityFileEntry, +): string | Uint8Array { if (typeof entry === "string") return entry; return Buffer.from(entry.data, "base64"); } function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); } function normalizeSelector(input: string): string { @@ -158,7 +183,10 @@ function parseInclude( fallback: CompanyPortabilityInclude = DEFAULT_EXPORT_INCLUDE, ): CompanyPortabilityInclude { if (!input || !input.trim()) return { ...fallback }; - const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); + const values = input + .split(",") + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); const include = { company: values.includes("company"), agents: values.includes("agents"), @@ -166,8 +194,16 @@ function parseInclude( issues: values.includes("issues") || values.includes("tasks"), skills: values.includes("skills"), }; - if (!include.company && !include.agents && !include.projects && !include.issues && !include.skills) { - throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills"); + if ( + !include.company && + !include.agents && + !include.projects && + !include.issues && + !include.skills + ) { + throw new Error( + "Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills", + ); } return include; } @@ -176,21 +212,33 @@ function parseAgents(input: string | undefined): "all" | string[] { if (!input || !input.trim()) return "all"; const normalized = input.trim().toLowerCase(); if (normalized === "all") return "all"; - const values = input.split(",").map((part) => part.trim()).filter(Boolean); + const values = input + .split(",") + .map((part) => part.trim()) + .filter(Boolean); if (values.length === 0) return "all"; return Array.from(new Set(values)); } function parseCsvValues(input: string | undefined): string[] { if (!input || !input.trim()) return []; - return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); + return Array.from( + new Set( + input + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ), + ); } function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude { +function resolveImportInclude( + input: string | undefined, +): CompanyPortabilityInclude { return parseInclude(input, DEFAULT_IMPORT_INCLUDE); } @@ -201,15 +249,24 @@ function normalizePortablePath(filePath: string): string { function shouldIncludePortableFile(filePath: string): boolean { const baseName = path.basename(filePath); const isMarkdown = baseName.endsWith(".md"); - const isTaskcoreYaml = baseName === ".taskcore.yaml" || baseName === ".taskcore.yml"; - const contentType = binaryContentTypeByExtension[path.extname(baseName).toLowerCase()]; + const isTaskcoreYaml = + baseName === ".taskcore.yaml" || baseName === ".taskcore.yml"; + const contentType = + binaryContentTypeByExtension[path.extname(baseName).toLowerCase()]; return isMarkdown || isTaskcoreYaml || Boolean(contentType); } -function findPortableExtensionPath(files: Record): string | null { +function findPortableExtensionPath( + files: Record, +): string | null { if (files[".taskcore.yaml"] !== undefined) return ".taskcore.yaml"; if (files[".taskcore.yml"] !== undefined) return ".taskcore.yml"; - return Object.keys(files).find((entry) => entry.endsWith("/.taskcore.yaml") || entry.endsWith("/.taskcore.yml")) ?? null; + return ( + Object.keys(files).find( + (entry) => + entry.endsWith("/.taskcore.yaml") || entry.endsWith("/.taskcore.yml"), + ) ?? null + ); } function collectFilesUnderDirectory( @@ -217,14 +274,24 @@ function collectFilesUnderDirectory( directory: string, opts?: { excludePrefixes?: string[] }, ): string[] { - const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, ""); + const normalizedDirectory = normalizePortablePath(directory).replace( + /\/+$/, + "", + ); if (!normalizedDirectory) return []; const prefix = `${normalizedDirectory}/`; - const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean); + const excluded = (opts?.excludePrefixes ?? []) + .map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")) + .filter(Boolean); return Object.keys(files) .map(normalizePortablePath) .filter((filePath) => filePath.startsWith(prefix)) - .filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`))) + .filter( + (filePath) => + !excluded.some((excludePrefix) => + filePath.startsWith(`${excludePrefix}/`), + ), + ) .sort((left, right) => left.localeCompare(right)); } @@ -234,7 +301,9 @@ function collectEntityFiles( opts?: { excludePrefixes?: string[] }, ): string[] { const normalizedPath = normalizePortablePath(entryPath); - const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : ""; + const directory = normalizedPath.includes("/") + ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) + : ""; const selected = new Set([normalizedPath]); if (directory) { for (const filePath of collectFilesUnderDirectory(files, directory, opts)) { @@ -244,30 +313,43 @@ function collectEntityFiles( return Array.from(selected).sort((left, right) => left.localeCompare(right)); } -export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog { +export function buildImportSelectionCatalog( + preview: CompanyPortabilityPreviewResult, +): ImportSelectionCatalog { const selectedAgentSlugs = new Set(preview.selectedAgentSlugs); const companyFiles = new Set(); - const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null; + const companyPath = preview.manifest.company?.path + ? normalizePortablePath(preview.manifest.company.path) + : null; if (companyPath) { companyFiles.add(companyPath); } - const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md"); + const readmePath = Object.keys(preview.files).find( + (entry) => normalizePortablePath(entry) === "README.md", + ); if (readmePath) { companyFiles.add(normalizePortablePath(readmePath)); } - const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null; + const logoPath = preview.manifest.company?.logoPath + ? normalizePortablePath(preview.manifest.company.logoPath) + : null; if (logoPath && preview.files[logoPath] !== undefined) { companyFiles.add(logoPath); } return { company: { - includedByDefault: preview.include.company && preview.manifest.company !== null, - files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)), + includedByDefault: + preview.include.company && preview.manifest.company !== null, + files: Array.from(companyFiles).sort((left, right) => + left.localeCompare(right), + ), }, projects: preview.manifest.projects.map((project) => { const projectPath = normalizePortablePath(project.path); - const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : ""; + const projectDir = projectPath.includes("/") + ? projectPath.slice(0, projectPath.lastIndexOf("/")) + : ""; return { key: project.slug, label: project.name, @@ -281,21 +363,33 @@ export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewRe key: issue.slug, label: issue.title, hint: issue.identifier ?? issue.slug, - files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)), + files: collectEntityFiles( + preview.files, + normalizePortablePath(issue.path), + ), })), agents: preview.manifest.agents - .filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug)) + .filter( + (agent) => + selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug), + ) .map((agent) => ({ key: agent.slug, label: agent.name, hint: agent.slug, - files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)), + files: collectEntityFiles( + preview.files, + normalizePortablePath(agent.path), + ), })), skills: preview.manifest.skills.map((skill) => ({ key: skill.slug, label: skill.name, hint: skill.slug, - files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)), + files: collectEntityFiles( + preview.files, + normalizePortablePath(skill.path), + ), })), extensionPath: findPortableExtensionPath(preview.files), }; @@ -305,7 +399,9 @@ function toKeySet(items: Array<{ key: string }>): Set { return new Set(items.map((item) => item.key)); } -export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState { +export function buildDefaultImportSelectionState( + catalog: ImportSelectionCatalog, +): ImportSelectionState { return { company: catalog.company.includedByDefault, projects: toKeySet(catalog.projects), @@ -315,15 +411,25 @@ export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog }; } -function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number { +function countSelected( + state: ImportSelectionState, + group: ImportSelectableGroup, +): number { return state[group].size; } -function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number { +function countTotal( + catalog: ImportSelectionCatalog, + group: ImportSelectableGroup, +): number { return catalog[group].length; } -function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string { +function summarizeGroupSelection( + catalog: ImportSelectionCatalog, + state: ImportSelectionState, + group: ImportSelectableGroup, +): string { return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`; } @@ -370,12 +476,18 @@ export function buildSelectedFilesFromImportSelection( } export function buildDefaultImportAdapterOverrides( - preview: Pick, + preview: Pick< + CompanyPortabilityPreviewResult, + "manifest" | "selectedAgentSlugs" + >, ): Record | undefined { const selectedAgentSlugs = new Set(preview.selectedAgentSlugs); const overrides = Object.fromEntries( preview.manifest.agents - .filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug)) + .filter( + (agent) => + selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug), + ) .filter((agent) => agent.adapterType === "process") .map((agent) => [ agent.slug, @@ -392,26 +504,34 @@ function buildDefaultImportAdapterMessages( overrides: Record | undefined, ): string[] { if (!overrides) return []; - const adapterTypes = Array.from(new Set(Object.values(overrides).map((override) => override.adapterType))) - .map((adapterType) => adapterType.replace(/_/g, "-")); + const adapterTypes = Array.from( + new Set(Object.values(overrides).map((override) => override.adapterType)), + ).map((adapterType) => adapterType.replace(/_/g, "-")); const agentCount = Object.keys(overrides).length; return [ `Using ${adapterTypes.join(", ")} adapter${adapterTypes.length === 1 ? "" : "s"} for ${agentCount} imported ${pluralize(agentCount, "agent")} without an explicit adapter.`, ]; } -async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise { +async function promptForImportSelection( + preview: CompanyPortabilityPreviewResult, +): Promise { const catalog = buildImportSelectionCatalog(preview); const state = buildDefaultImportSelectionState(catalog); while (true) { - const choice = await p.select({ + const choice = await p.select< + ImportSelectableGroup | "company" | "confirm" + >({ message: "Select what Taskcore should import", options: [ { value: "company", label: state.company ? "Company: included" : "Company: skipped", - hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package", + hint: + catalog.company.files.length > 0 + ? "toggle company metadata" + : "no company metadata in package", }, { value: "projects", @@ -448,9 +568,15 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult } if (choice === "confirm") { - const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state); + const selectedFiles = buildSelectedFilesFromImportSelection( + catalog, + state, + ); if (selectedFiles.length === 0) { - p.note("Select at least one import target before confirming.", "Nothing selected"); + p.note( + "Select at least one import target before confirming.", + "Nothing selected", + ); continue; } return selectedFiles; @@ -458,7 +584,10 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult if (choice === "company") { if (catalog.company.files.length === 0) { - p.note("This package does not include company metadata to toggle.", "No company metadata"); + p.note( + "This package does not include company metadata to toggle.", + "No company metadata", + ); continue; } state.company = !state.company; @@ -468,7 +597,10 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult const group = choice; const groupItems = catalog[group]; if (groupItems.length === 0) { - p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`); + p.note( + `This package does not include any ${getGroupLabel(group).toLowerCase()}.`, + `No ${getGroupLabel(group)}`, + ); continue; } @@ -492,13 +624,17 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult } function summarizeInclude(include: CompanyPortabilityInclude): string { - const labels = IMPORT_INCLUDE_OPTIONS - .filter((option) => include[option.value]) - .map((option) => option.label.toLowerCase()); + const labels = IMPORT_INCLUDE_OPTIONS.filter( + (option) => include[option.value], + ).map((option) => option.label.toLowerCase()); return labels.length > 0 ? labels.join(", ") : "nothing selected"; } -function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } | { type: "github"; url: string }): string { +function formatSourceLabel( + source: + | { type: "inline"; rootPath?: string | null } + | { type: "github"; url: string }, +): string { if (source.type === "github") { return `GitHub: ${source.url}`; } @@ -506,18 +642,31 @@ function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } } function formatTargetLabel( - target: { mode: "existing_company"; companyId?: string | null } | { mode: "new_company"; newCompanyName?: string | null }, + target: + | { mode: "existing_company"; companyId?: string | null } + | { mode: "new_company"; newCompanyName?: string | null }, preview?: CompanyPortabilityPreviewResult, ): string { if (target.mode === "existing_company") { const targetName = preview?.targetCompanyName?.trim(); - const targetId = preview?.targetCompanyId?.trim() || target.companyId?.trim() || "unknown-company"; + const targetId = + preview?.targetCompanyId?.trim() || + target.companyId?.trim() || + "unknown-company"; return targetName ? `${targetName} (${targetId})` : targetId; } - return target.newCompanyName?.trim() || preview?.manifest.company?.name || "new company"; + return ( + target.newCompanyName?.trim() || + preview?.manifest.company?.name || + "new company" + ); } -function pluralize(count: number, singular: string, plural = `${singular}s`): string { +function pluralize( + count: number, + singular: string, + plural = `${singular}s`, +): string { return count === 1 ? singular : plural; } @@ -536,7 +685,9 @@ function summarizePlanCounts( return `${plans.length} ${pluralize(plans.length, noun)} total (${parts.join(", ")})`; } -function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["agents"]): string { +function summarizeImportAgentResults( + agents: CompanyPortabilityImportResult["agents"], +): string { if (agents.length === 0) return "0 agents changed"; const created = agents.filter((agent) => agent.action === "created").length; const updated = agents.filter((agent) => agent.action === "updated").length; @@ -548,11 +699,19 @@ function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["age return `${agents.length} ${pluralize(agents.length, "agent")} total (${parts.join(", ")})`; } -function summarizeImportProjectResults(projects: CompanyPortabilityImportResult["projects"]): string { +function summarizeImportProjectResults( + projects: CompanyPortabilityImportResult["projects"], +): string { if (projects.length === 0) return "0 projects changed"; - const created = projects.filter((project) => project.action === "created").length; - const updated = projects.filter((project) => project.action === "updated").length; - const skipped = projects.filter((project) => project.action === "skipped").length; + const created = projects.filter( + (project) => project.action === "created", + ).length; + const updated = projects.filter( + (project) => project.action === "updated", + ).length; + const skipped = projects.filter( + (project) => project.action === "skipped", + ).length; const parts: string[] = []; if (created > 0) parts.push(`${created} created`); if (updated > 0) parts.push(`${updated} updated`); @@ -588,7 +747,9 @@ function appendPreviewExamples( lines.push(pc.bold(title)); const shown = entries.slice(0, IMPORT_PREVIEW_SAMPLE_LIMIT); for (const entry of shown) { - const reason = entry.reason?.trim() ? pc.dim(` (${entry.reason.trim()})`) : ""; + const reason = entry.reason?.trim() + ? pc.dim(` (${entry.reason.trim()})`) + : ""; lines.push(`- ${actionChip(entry.action)} ${entry.label}${reason}`); } if (entries.length > shown.length) { @@ -596,7 +757,11 @@ function appendPreviewExamples( } } -function appendMessageBlock(lines: string[], title: string, messages: string[]): void { +function appendMessageBlock( + lines: string[], + title: string, + messages: string[], +): void { if (messages.length === 0) return; lines.push(""); lines.push(pc.bold(title)); @@ -628,18 +793,32 @@ export function renderCompanyImportPreview( ]; if (preview.envInputs.length > 0) { - const requiredCount = preview.envInputs.filter((item) => item.requirement === "required").length; - lines.push(`- env inputs: ${preview.envInputs.length} (${requiredCount} required)`); + const requiredCount = preview.envInputs.filter( + (item) => item.requirement === "required", + ).length; + lines.push( + `- env inputs: ${preview.envInputs.length} (${requiredCount} required)`, + ); } lines.push(""); lines.push(pc.bold("Plan")); - lines.push(`- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`); - lines.push(`- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`); - lines.push(`- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`); - lines.push(`- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`); + lines.push( + `- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`, + ); + lines.push( + `- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`, + ); + lines.push( + `- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`, + ); + lines.push( + `- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`, + ); if (preview.include.skills) { - lines.push(`- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`); + lines.push( + `- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`, + ); } appendPreviewExamples( @@ -725,7 +904,11 @@ export function renderCompanyImportResult( return lines.join("\n"); } -function printCompanyImportView(title: string, body: string, opts?: { interactive?: boolean }): void { +function printCompanyImportView( + title: string, + body: string, + opts?: { interactive?: boolean }, +): void { if (opts?.interactive) { p.note(body, title); return; @@ -742,17 +925,24 @@ export function resolveCompanyImportApiPath(input: { if (input.targetMode === "existing_company") { const companyId = input.companyId?.trim(); if (!companyId) { - throw new Error("Existing-company imports require a companyId to resolve the API route."); + throw new Error( + "Existing-company imports require a companyId to resolve the API route.", + ); } return input.dryRun ? `/api/companies/${companyId}/imports/preview` : `/api/companies/${companyId}/imports/apply`; } - return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import"; + return input.dryRun + ? "/api/companies/import/preview" + : "/api/companies/import"; } -export function buildCompanyDashboardUrl(apiBase: string, issuePrefix: string): string { +export function buildCompanyDashboardUrl( + apiBase: string, + issuePrefix: string, +): string { const url = new URL(apiBase); const normalizedPrefix = issuePrefix.trim().replace(/^\/+|\/+$/g, ""); url.pathname = `${url.pathname.replace(/\/+$/, "")}/${normalizedPrefix}/dashboard`; @@ -818,7 +1008,9 @@ export function isGithubShorthand(input: string): boolean { return segments.length >= 2 && segments.every(isGithubSegment); } -function normalizeGithubImportPath(input: string | null | undefined): string | null { +function normalizeGithubImportPath( + input: string | null | undefined, +): string | null { if (!input) return null; const trimmed = input.trim().replace(/^\/+|\/+$/g, ""); return trimmed || null; @@ -833,7 +1025,9 @@ function buildGithubImportUrl(input: { companyPath?: string | null; }): string { const host = input.hostname || "github.com"; - const url = new URL(`https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`); + const url = new URL( + `https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`, + ); const ref = input.ref?.trim(); if (ref) { url.searchParams.set("ref", ref); @@ -850,7 +1044,10 @@ function buildGithubImportUrl(input: { return url.toString(); } -export function normalizeGithubImportSource(input: string, refOverride?: string): string { +export function normalizeGithubImportSource( + input: string, + refOverride?: string, +): string { const trimmed = input.trim(); const ref = refOverride?.trim(); @@ -865,7 +1062,9 @@ export function normalizeGithubImportSource(input: string, refOverride?: string) } if (!looksLikeRepoUrl(trimmed)) { - throw new Error("GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand."); + throw new Error( + "GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand.", + ); } if (!ref) { return trimmed; @@ -881,18 +1080,44 @@ export function normalizeGithubImportSource(input: string, refOverride?: string) const owner = parts[0]!; const repo = parts[1]!; const existingPath = normalizeGithubImportPath(url.searchParams.get("path")); - const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath")); + const existingCompanyPath = normalizeGithubImportPath( + url.searchParams.get("companyPath"), + ); if (existingCompanyPath) { - return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: existingCompanyPath }); + return buildGithubImportUrl({ + hostname, + owner, + repo, + ref, + companyPath: existingCompanyPath, + }); } if (existingPath) { - return buildGithubImportUrl({ hostname, owner, repo, ref, path: existingPath }); + return buildGithubImportUrl({ + hostname, + owner, + repo, + ref, + path: existingPath, + }); } if (parts[2] === "tree") { - return buildGithubImportUrl({ hostname, owner, repo, ref, path: parts.slice(4).join("/") }); + return buildGithubImportUrl({ + hostname, + owner, + repo, + ref, + path: parts.slice(4).join("/"), + }); } if (parts[2] === "blob") { - return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: parts.slice(4).join("/") }); + return buildGithubImportUrl({ + hostname, + owner, + repo, + ref, + companyPath: parts.slice(4).join("/"), + }); } return buildGithubImportUrl({ hostname, owner, repo, ref }); } @@ -922,7 +1147,10 @@ async function collectPackageFiles( if (!entry.isFile()) continue; const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); if (!shouldIncludePortableFile(relativePath)) continue; - files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath)); + files[relativePath] = readPortableFileEntry( + relativePath, + await readFile(absolutePath), + ); } } @@ -932,10 +1160,15 @@ export async function resolveInlineSourceFromPath(inputPath: string): Promise<{ }> { const resolved = path.resolve(inputPath); const resolvedStat = await stat(resolved); - if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") { + if ( + resolvedStat.isFile() && + path.extname(resolved).toLowerCase() === ".zip" + ) { const archive = await readZipArchive(await readFile(resolved)); const filteredFiles = Object.fromEntries( - Object.entries(archive.files).filter(([relativePath]) => shouldIncludePortableFile(relativePath)), + Object.entries(archive.files).filter(([relativePath]) => + shouldIncludePortableFile(relativePath), + ), ); return { rootPath: archive.rootPath ?? path.basename(resolved, ".zip"), @@ -943,7 +1176,9 @@ export async function resolveInlineSourceFromPath(inputPath: string): Promise<{ }; } - const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); + const rootDir = resolvedStat.isDirectory() + ? resolved + : path.dirname(resolved); const files: Record = {}; await collectPackageFiles(rootDir, rootDir, files); return { @@ -952,7 +1187,10 @@ export async function resolveInlineSourceFromPath(inputPath: string): Promise<{ }; } -async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { +async function writeExportToFolder( + outDir: string, + exported: CompanyPortabilityExportResult, +): Promise { const root = path.resolve(outDir); await mkdir(root, { recursive: true }); for (const [relativePath, content] of Object.entries(exported.files)) { @@ -973,14 +1211,18 @@ async function confirmOverwriteExportDirectory(outDir: string): Promise { const stats = await stat(root).catch(() => null); if (!stats) return; if (!stats.isDirectory()) { - throw new Error(`Export output path ${root} exists and is not a directory.`); + throw new Error( + `Export output path ${root} exists and is not a directory.`, + ); } const entries = await readdir(root); if (entries.length === 0) return; if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`); + throw new Error( + `Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`, + ); } const confirmed = await p.confirm({ @@ -1008,7 +1250,9 @@ export function resolveCompanyForDeletion( } const idMatch = companies.find((company) => company.id === selector); - const prefixMatch = companies.find((company) => matchesPrefix(company, selector)); + const prefixMatch = companies.find((company) => + matchesPrefix(company, selector), + ); if (by === "id") { if (!idMatch) { @@ -1038,7 +1282,10 @@ export function resolveCompanyForDeletion( ); } -export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOptions): void { +export function assertDeleteConfirmation( + company: Company, + opts: CompanyDeleteOptions, +): void { if (!opts.yes) { throw new Error("Deletion requires --yes."); } @@ -1051,7 +1298,8 @@ export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOp } const confirmsById = confirm === company.id; - const confirmsByPrefix = confirm.toUpperCase() === company.issuePrefix.toUpperCase(); + const confirmsByPrefix = + confirm.toUpperCase() === company.issuePrefix.toUpperCase(); if (!confirmsById && !confirmsByPrefix) { throw new Error( `Confirmation '${confirm}' does not match target company. Expected ID '${company.id}' or prefix '${company.issuePrefix}'.`, @@ -1097,7 +1345,8 @@ export function registerCompanyCommands(program: Command): void { status: row.status, budgetMonthlyCents: row.budgetMonthlyCents, spentMonthlyCents: row.spentMonthlyCents, - requireBoardApprovalForNewAgents: row.requireBoardApprovalForNewAgents, + requireBoardApprovalForNewAgents: + row.requireBoardApprovalForNewAgents, })); for (const row of formatted) { console.log(formatInlineRecord(row)); @@ -1134,16 +1383,29 @@ export function registerCompanyCommands(program: Command): void { .option("--status ", "Filter by trace status") .option("--project-id ", "Filter by project ID") .option("--issue-id ", "Filter by issue ID") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--include-payload", "Include stored payload snapshots in the response") + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--include-payload", + "Include stored payload snapshots in the response", + ) .action(async (opts: CompanyFeedbackOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const traces = (await ctx.api.get( - `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, - )) ?? []; + const traces = + (await ctx.api.get( + `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + )) ?? []; if (ctx.json) { printOutput(traces, { json: true }); return; @@ -1176,32 +1438,53 @@ export function registerCompanyCommands(program: Command): void { .option("--status ", "Filter by trace status") .option("--project-id ", "Filter by project ID") .option("--issue-id ", "Filter by issue ID") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--include-payload", "Include stored payload snapshots in the export") + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--include-payload", + "Include stored payload snapshots in the export", + ) .option("--out ", "Write export to a file path instead of stdout") .option("--format ", "Export format: json or ndjson", "ndjson") .action(async (opts: CompanyFeedbackOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const traces = (await ctx.api.get( - `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, - )) ?? []; + const traces = + (await ctx.api.get( + `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + )) ?? []; const serialized = serializeFeedbackTraces(traces, opts.format); if (opts.out?.trim()) { await writeFile(opts.out, serialized, "utf8"); if (ctx.json) { printOutput( - { out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) }, + { + out: opts.out, + count: traces.length, + format: normalizeFeedbackTraceExportFormat(opts.format), + }, { json: true }, ); return; } - console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`); + console.log( + `Wrote ${traces.length} feedback trace(s) to ${opts.out}`, + ); return; } - process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`); + process.stdout.write( + `${serialized}${serialized.endsWith("\n") ? "" : "\n"}`, + ); } catch (err) { handleCommandError(err); } @@ -1215,12 +1498,29 @@ export function registerCompanyCommands(program: Command): void { .description("Export a company into a portable markdown package") .argument("", "Company ID") .requiredOption("--out ", "Output directory") - .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") + .option( + "--include ", + "Comma-separated include set: company,agents,projects,issues,tasks,skills", + "company,agents", + ) .option("--skills ", "Comma-separated skill slugs/keys to export") - .option("--projects ", "Comma-separated project shortnames/ids to export") - .option("--issues ", "Comma-separated issue identifiers/ids to export") - .option("--project-issues ", "Comma-separated project shortnames/ids whose issues should be exported") - .option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false) + .option( + "--projects ", + "Comma-separated project shortnames/ids to export", + ) + .option( + "--issues ", + "Comma-separated issue identifiers/ids to export", + ) + .option( + "--project-issues ", + "Comma-separated project shortnames/ids whose issues should be exported", + ) + .option( + "--expand-referenced-skills", + "Vendor skill contents instead of exporting upstream references", + false, + ) .action(async (companyId: string, opts: CompanyExportOptions) => { try { const ctx = resolveCommandContext(opts); @@ -1266,17 +1566,37 @@ export function registerCompanyCommands(program: Command): void { addCommonClientOptions( company .command("import") - .description("Import a portable markdown company package from local path, URL, or GitHub") + .description( + "Import a portable markdown company package from local path, URL, or GitHub", + ) .argument("", "Source path or URL") - .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills") + .option( + "--include ", + "Comma-separated include set: company,agents,projects,issues,tasks,skills", + ) .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") .option("--new-company-name ", "Name override for --target new") - .option("--agents ", "Comma-separated agent slugs to import, or all", "all") - .option("--collision ", "Collision strategy: rename | skip | replace", "rename") - .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") + .option( + "--agents ", + "Comma-separated agent slugs to import, or all", + "all", + ) + .option( + "--collision ", + "Collision strategy: rename | skip | replace", + "rename", + ) + .option( + "--ref ", + "Git ref to use for GitHub imports (branch, tag, or commit)", + ) .option("--taskcore-url ", "Alias for --api-base on this command") - .option("--yes", "Accept default selection and skip the pre-import confirmation prompt", false) + .option( + "--yes", + "Accept default selection and skip the pre-import confirmation prompt", + false, + ) .option("--dry-run", "Run preview only without applying", false) .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { @@ -1292,51 +1612,75 @@ export function registerCompanyCommands(program: Command): void { const include = resolveImportInclude(opts.include); const agents = parseAgents(opts.agents); - const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode; + const collision = ( + opts.collision ?? "rename" + ).toLowerCase() as CompanyCollisionMode; if (!["rename", "skip", "replace"].includes(collision)) { - throw new Error("Invalid --collision value. Use: rename, skip, replace"); + throw new Error( + "Invalid --collision value. Use: rename, skip, replace", + ); } - const inferredTarget = opts.target ?? (opts.companyId || ctx.companyId ? "existing" : "new"); - const target = inferredTarget.toLowerCase() as CompanyImportTargetMode; + const inferredTarget = + opts.target ?? + (opts.companyId || ctx.companyId ? "existing" : "new"); + const target = + inferredTarget.toLowerCase() as CompanyImportTargetMode; if (!["new", "existing"].includes(target)) { throw new Error("Invalid --target value. Use: new | existing"); } - const existingTargetCompanyId = opts.companyId?.trim() || ctx.companyId; + const existingTargetCompanyId = + opts.companyId?.trim() || ctx.companyId; const targetPayload = target === "existing" ? { - mode: "existing_company" as const, - companyId: existingTargetCompanyId, - } + mode: "existing_company" as const, + companyId: existingTargetCompanyId, + } : { - mode: "new_company" as const, - newCompanyName: opts.newCompanyName?.trim() || null, - }; - - if (targetPayload.mode === "existing_company" && !targetPayload.companyId) { - throw new Error("Target existing company requires --company-id (or context default companyId)."); + mode: "new_company" as const, + newCompanyName: opts.newCompanyName?.trim() || null, + }; + + if ( + targetPayload.mode === "existing_company" && + !targetPayload.companyId + ) { + throw new Error( + "Target existing company requires --company-id (or context default companyId).", + ); } let sourcePayload: - | { type: "inline"; rootPath?: string | null; files: Record } + | { + type: "inline"; + rootPath?: string | null; + files: Record; + } | { type: "github"; url: string }; - const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from); - const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath); + const treatAsLocalPath = !isHttpUrl(from) && (await pathExists(from)); + const isGithubSource = + looksLikeRepoUrl(from) || + (isGithubShorthand(from) && !treatAsLocalPath); if (isHttpUrl(from) || isGithubSource) { if (!looksLikeRepoUrl(from) && !isGithubShorthand(from)) { throw new Error( "Only GitHub URLs and local paths are supported for import. " + - "Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.", + "Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.", ); } - sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) }; + sourcePayload = { + type: "github", + url: normalizeGithubImportSource(from, opts.ref), + }; } else { if (opts.ref?.trim()) { - throw new Error("--ref is only supported for GitHub import sources."); + throw new Error( + "--ref is only supported for GitHub import sources.", + ); } const inline = await resolveInlineSourceFromPath(from); sourcePayload = { @@ -1351,18 +1695,25 @@ export function registerCompanyCommands(program: Command): void { const previewApiPath = resolveCompanyImportApiPath({ dryRun: true, targetMode: targetPayload.mode, - companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, + companyId: + targetPayload.mode === "existing_company" + ? targetPayload.companyId + : null, }); let selectedFiles: string[] | undefined; if (interactiveView && !opts.yes && !opts.include?.trim()) { - const initialPreview = await ctx.api.post(previewApiPath, { - source: sourcePayload, - include, - target: targetPayload, - agents, - collisionStrategy: collision, - }); + const initialPreview = + await ctx.api.post( + previewApiPath, + { + source: sourcePayload, + include, + target: targetPayload, + agents, + collisionStrategy: collision, + }, + ); if (!initialPreview) { throw new Error("Import preview returned no data."); } @@ -1377,12 +1728,16 @@ export function registerCompanyCommands(program: Command): void { collisionStrategy: collision, selectedFiles, }; - const preview = await ctx.api.post(previewApiPath, previewPayload); + const preview = await ctx.api.post( + previewApiPath, + previewPayload, + ); if (!preview) { throw new Error("Import preview returned no data."); } const adapterOverrides = buildDefaultImportAdapterOverrides(preview); - const adapterMessages = buildDefaultImportAdapterMessages(adapterOverrides); + const adapterMessages = + buildDefaultImportAdapterMessages(adapterOverrides); if (opts.dryRun) { if (ctx.json) { @@ -1432,28 +1787,44 @@ export function registerCompanyCommands(program: Command): void { const importApiPath = resolveCompanyImportApiPath({ dryRun: false, targetMode: targetPayload.mode, - companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, - }); - const imported = await ctx.api.post(importApiPath, { - ...previewPayload, - adapterOverrides, + companyId: + targetPayload.mode === "existing_company" + ? targetPayload.companyId + : null, }); + const imported = await ctx.api.post( + importApiPath, + { + ...previewPayload, + adapterOverrides, + }, + ); if (!imported) { throw new Error("Import request returned no data."); } const tc = getTelemetryClient(); if (tc) { const isPrivate = sourcePayload.type !== "github"; - const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from; - trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate }); + const sourceRef = + sourcePayload.type === "github" ? sourcePayload.url : from; + trackCompanyImported(tc, { + sourceType: sourcePayload.type, + sourceRef, + isPrivate, + }); } let companyUrl: string | undefined; if (!ctx.json) { try { - const importedCompany = await ctx.api.get(`/api/companies/${imported.company.id}`); + const importedCompany = await ctx.api.get( + `/api/companies/${imported.company.id}`, + ); const issuePrefix = importedCompany?.issuePrefix?.trim(); if (issuePrefix) { - companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix); + companyUrl = buildCompanyDashboardUrl( + ctx.api.apiBase, + issuePrefix, + ); } } catch { companyUrl = undefined; @@ -1480,7 +1851,9 @@ export function registerCompanyCommands(program: Command): void { if (openUrl(companyUrl)) { p.log.info(`Opened ${companyUrl}`); } else { - p.log.warn(`Could not open your browser automatically. Open this URL manually:\n${companyUrl}`); + p.log.warn( + `Could not open your browser automatically. Open this URL manually:\n${companyUrl}`, + ); } } } @@ -1496,21 +1869,25 @@ export function registerCompanyCommands(program: Command): void { .command("delete") .description("Delete a company by ID or shortname/prefix (destructive)") .argument("", "Company ID or issue prefix (for example PAP)") + .option("--by ", "Selector mode: auto | id | prefix", "auto") .option( - "--by ", - "Selector mode: auto | id | prefix", - "auto", + "--yes", + "Required safety flag to confirm destructive action", + false, ) - .option("--yes", "Required safety flag to confirm destructive action", false) .option( "--confirm ", "Required safety value: target company ID or shortname/prefix", ) .action(async (selector: string, opts: CompanyDeleteOptions) => { try { - const by = (opts.by ?? "auto").trim().toLowerCase() as CompanyDeleteSelectorMode; + const by = (opts.by ?? "auto") + .trim() + .toLowerCase() as CompanyDeleteSelectorMode; if (!["auto", "id", "prefix"].includes(by)) { - throw new Error(`Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`); + throw new Error( + `Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`, + ); } const ctx = resolveCommandContext(opts); @@ -1518,21 +1895,34 @@ export function registerCompanyCommands(program: Command): void { assertDeleteFlags(opts); let target: Company | null = null; - const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector)); + const shouldTryIdLookup = + by === "id" || (by === "auto" && isUuidLike(normalizedSelector)); if (shouldTryIdLookup) { - const byId = await ctx.api.get(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true }); + const byId = await ctx.api.get( + `/api/companies/${normalizedSelector}`, + { ignoreNotFound: true }, + ); if (byId) { target = byId; } else if (by === "id") { - throw new Error(`No company found by ID '${normalizedSelector}'.`); + throw new Error( + `No company found by ID '${normalizedSelector}'.`, + ); } } if (!target && ctx.companyId) { - const scoped = await ctx.api.get(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true }); + const scoped = await ctx.api.get( + `/api/companies/${ctx.companyId}`, + { ignoreNotFound: true }, + ); if (scoped) { try { - target = resolveCompanyForDeletion([scoped], normalizedSelector, by); + target = resolveCompanyForDeletion( + [scoped], + normalizedSelector, + by, + ); } catch { // Fallback to board-wide lookup below. } @@ -1541,10 +1931,19 @@ export function registerCompanyCommands(program: Command): void { if (!target) { try { - const companies = (await ctx.api.get("/api/companies")) ?? []; - target = resolveCompanyForDeletion(companies, normalizedSelector, by); + const companies = + (await ctx.api.get("/api/companies")) ?? []; + target = resolveCompanyForDeletion( + companies, + normalizedSelector, + by, + ); } catch (error) { - if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) { + if ( + error instanceof ApiRequestError && + error.status === 403 && + error.message.includes("Board access required") + ) { throw new Error( "Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.", ); @@ -1554,7 +1953,9 @@ export function registerCompanyCommands(program: Command): void { } if (!target) { - throw new Error(`No company found for selector '${normalizedSelector}'.`); + throw new Error( + `No company found for selector '${normalizedSelector}'.`, + ); } assertDeleteConfirmation(target, opts); diff --git a/cli/src/commands/client/context.ts b/cli/src/commands/client/context.ts index 30563cc..5263b8d 100644 --- a/cli/src/commands/client/context.ts +++ b/cli/src/commands/client/context.ts @@ -24,12 +24,17 @@ interface ContextSetOptions extends ContextOptions { } export function registerContextCommands(program: Command): void { - const context = program.command("context").description("Manage CLI client context profiles"); + const context = program + .command("context") + .description("Manage CLI client context profiles"); context .command("show") .description("Show current context and active profile") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("--context ", "Path to CLI context file") .option("--profile ", "Profile to inspect") .option("--json", "Output raw JSON") @@ -50,7 +55,10 @@ export function registerContextCommands(program: Command): void { context .command("list") .description("List available context profiles") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("--context ", "Path to CLI context file") .option("--json", "Output raw JSON") .action((opts: ContextOptions) => { @@ -69,7 +77,10 @@ export function registerContextCommands(program: Command): void { .command("use") .description("Set active context profile") .argument("", "Profile name") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("--context ", "Path to CLI context file") .action((profile: string, opts: ContextOptions) => { setCurrentProfile(profile, opts.context); @@ -79,17 +90,24 @@ export function registerContextCommands(program: Command): void { context .command("set") .description("Set values on a profile") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("--context ", "Path to CLI context file") .option("--profile ", "Profile name (default: current profile)") .option("--api-base ", "Default API base URL") .option("--company-id ", "Default company ID") - .option("--api-key-env-var-name ", "Env var containing API key (recommended)") + .option( + "--api-key-env-var-name ", + "Env var containing API key (recommended)", + ) .option("--use", "Set this profile as active") .option("--json", "Output raw JSON") .action((opts: ContextSetOptions) => { const existing = readContext(opts.context); - const targetProfile = opts.profile?.trim() || existing.currentProfile || "default"; + const targetProfile = + opts.profile?.trim() || existing.currentProfile || "default"; upsertProfile( targetProfile, diff --git a/cli/src/commands/client/dashboard.ts b/cli/src/commands/client/dashboard.ts index a3f22e5..0e19b6e 100644 --- a/cli/src/commands/client/dashboard.ts +++ b/cli/src/commands/client/dashboard.ts @@ -13,7 +13,9 @@ interface DashboardGetOptions extends BaseClientOptions { } export function registerDashboardCommands(program: Command): void { - const dashboard = program.command("dashboard").description("Dashboard summary operations"); + const dashboard = program + .command("dashboard") + .description("Dashboard summary operations"); addCommonClientOptions( dashboard @@ -23,7 +25,9 @@ export function registerDashboardCommands(program: Command): void { .action(async (opts: DashboardGetOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const row = await ctx.api.get(`/api/companies/${ctx.companyId}/dashboard`); + const row = await ctx.api.get( + `/api/companies/${ctx.companyId}/dashboard`, + ); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); diff --git a/cli/src/commands/client/feedback.ts b/cli/src/commands/client/feedback.ts index d1d4d2f..c34f1d9 100644 --- a/cli/src/commands/client/feedback.ts +++ b/cli/src/commands/client/feedback.ts @@ -2,7 +2,11 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import pc from "picocolors"; import { Command } from "commander"; -import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@taskcore/shared"; +import type { + Company, + FeedbackTrace, + FeedbackTraceBundle, +} from "@taskcore/shared"; import { addCommonClientOptions, handleCommandError, @@ -73,7 +77,9 @@ interface FeedbackExportResult { } export function registerFeedbackCommands(program: Command): void { - const feedback = program.command("feedback").description("Inspect and export local feedback traces"); + const feedback = program + .command("feedback") + .description("Inspect and export local feedback traces"); addCommonClientOptions( feedback @@ -85,10 +91,23 @@ export function registerFeedbackCommands(program: Command): void { .option("--status ", "Filter by trace status") .option("--project-id ", "Filter by project ID") .option("--issue-id ", "Filter by issue ID") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--payloads", "Include raw payload dumps in the terminal report", false) + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--payloads", + "Include raw payload dumps in the terminal report", + false, + ) .action(async (opts: FeedbackReportOptions) => { try { const ctx = resolveCommandContext(opts); @@ -107,13 +126,15 @@ export function registerFeedbackCommands(program: Command): void { ); return; } - console.log(renderFeedbackReport({ - apiBase: ctx.api.apiBase, - companyId, - traces, - summary, - includePayloads: Boolean(opts.payloads), - })); + console.log( + renderFeedbackReport({ + apiBase: ctx.api.apiBase, + companyId, + traces, + summary, + includePayloads: Boolean(opts.payloads), + }), + ); } catch (err) { handleCommandError(err); } @@ -124,29 +145,46 @@ export function registerFeedbackCommands(program: Command): void { addCommonClientOptions( feedback .command("export") - .description("Export feedback votes and raw trace bundles into a folder plus zip archive") + .description( + "Export feedback votes and raw trace bundles into a folder plus zip archive", + ) .option("-C, --company-id ", "Company ID (overrides context default)") .option("--target-type ", "Filter by target type") .option("--vote ", "Filter by vote value") .option("--status ", "Filter by trace status") .option("--project-id ", "Filter by project ID") .option("--issue-id ", "Filter by issue ID") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--out ", "Output directory (default: ./feedback-export-)") + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--out ", + "Output directory (default: ./feedback-export-)", + ) .action(async (opts: FeedbackExportOptions) => { try { const ctx = resolveCommandContext(opts); const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId); const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts); - const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName()); + const outputDir = path.resolve( + opts.out?.trim() || defaultFeedbackExportDirName(), + ); const exported = await writeFeedbackExportBundle({ apiBase: ctx.api.apiBase, companyId, traces, outputDir, - traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id), + traceBundleFetcher: (trace) => + fetchFeedbackTraceBundle(ctx, trace.id), }); if (ctx.json) { printOutput( @@ -185,7 +223,10 @@ export async function resolveFeedbackCompanyId( return companyId; } -export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string { +export function buildFeedbackTraceQuery( + opts: FeedbackTraceQueryOptions, + includePayload = true, +): string { const params = new URLSearchParams(); if (opts.targetType) params.set("targetType", opts.targetType); if (opts.vote) params.set("vote", opts.vote); @@ -200,13 +241,18 @@ export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, include return query ? `?${query}` : ""; } -export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" { +export function normalizeFeedbackTraceExportFormat( + value: string | undefined, +): "json" | "ndjson" { if (!value || value === "ndjson") return "ndjson"; if (value === "json") return "json"; throw new Error(`Unsupported export format: ${value}`); } -export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string { +export function serializeFeedbackTraces( + traces: FeedbackTrace[], + format: string | undefined, +): string { if (normalizeFeedbackTraceExportFormat(format) === "json") { return JSON.stringify(traces, null, 2); } @@ -229,14 +275,18 @@ export async function fetchFeedbackTraceBundle( ctx: ResolvedClientContext, traceId: string, ): Promise { - const bundle = await ctx.api.get(`/api/feedback-traces/${traceId}/bundle`); + const bundle = await ctx.api.get( + `/api/feedback-traces/${traceId}/bundle`, + ); if (!bundle) { throw new Error(`Feedback trace bundle ${traceId} not found`); } return bundle; } -export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary { +export function summarizeFeedbackTraces( + traces: FeedbackTrace[], +): FeedbackSummary { const statuses: Record = {}; let thumbsUp = 0; let thumbsDown = 0; @@ -282,14 +332,22 @@ export function renderFeedbackReport(input: { lines.push(pc.bold(pc.cyan("Summary"))); lines.push(horizontalRule()); - lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`); - lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`); - lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`); + lines.push( + ` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`, + ); + lines.push( + ` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`, + ); + lines.push( + ` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`, + ); lines.push(` ${pc.bold(String(input.summary.total))} total traces`); lines.push(""); lines.push(pc.dim("Export status:")); for (const status of ["pending", "sent", "local_only", "failed"]) { - lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`); + lines.push( + ` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`, + ); } lines.push(""); lines.push(pc.bold(pc.cyan("Trace Details"))); @@ -325,7 +383,8 @@ export function renderFeedbackReport(input: { if (!trace.payloadSnapshot) continue; const issueRef = trace.issueIdentifier ?? trace.issueId; lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`); - const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? []; + const body = + JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? []; for (const line of body) { lines.push(` ${pc.dim(line)}`); } @@ -334,7 +393,9 @@ export function renderFeedbackReport(input: { } lines.push(horizontalRule()); - lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`)); + lines.push( + pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`), + ); lines.push(""); return lines.join("\n"); } @@ -359,7 +420,9 @@ export async function writeFeedbackExportBundle(input: { const issueSet = new Set(); for (const trace of input.traces) { - const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId); + const issueRef = sanitizeFileSegment( + trace.issueIdentifier ?? trace.issueId, + ); const voteRecord = buildFeedbackVoteRecord(trace); const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`; const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`; @@ -380,7 +443,11 @@ export async function writeFeedbackExportBundle(input: { if (input.traceBundleFetcher) { const bundle = await input.traceBundleFetcher(trace); const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`; - const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName); + const bundleDir = path.join( + input.outputDir, + "full-traces", + bundleDirName, + ); await mkdir(bundleDir, { recursive: true }); fullTraceDirs.push(bundleDirName); await writeFile( @@ -388,12 +455,20 @@ export async function writeFeedbackExportBundle(input: { `${JSON.stringify(bundle, null, 2)}\n`, "utf8", ); - fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json")); + fullTraceFiles.push( + path.posix.join("full-traces", bundleDirName, "bundle.json"), + ); for (const file of bundle.files) { const targetPath = path.join(bundleDir, file.path); await mkdir(path.dirname(targetPath), { recursive: true }); await writeFile(targetPath, file.contents, "utf8"); - fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/"))); + fullTraceFiles.push( + path.posix.join( + "full-traces", + bundleDirName, + file.path.replace(/\\/g, "/"), + ), + ); } } } @@ -406,12 +481,18 @@ export async function writeFeedbackExportBundle(input: { summary: { ...summary, uniqueIssues: issueSet.size, - issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)), + issues: Array.from(issueSet).sort((left, right) => + left.localeCompare(right), + ), }, files: { votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)), - traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)), - fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)), + traces: traceFiles + .slice() + .sort((left, right) => left.localeCompare(right)), + fullTraces: fullTraceDirs + .slice() + .sort((left, right) => left.localeCompare(right)), zip: path.basename(zipPath), }, }; @@ -427,7 +508,10 @@ export async function writeFeedbackExportBundle(input: { ...manifest.files.traces.map((file) => path.posix.join("traces", file)), ...fullTraceFiles, ]); - await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir))); + await writeFile( + zipPath, + createStoredZipArchive(archiveFiles, path.basename(input.outputDir)), + ); return { outputDir: input.outputDir, @@ -436,7 +520,9 @@ export async function writeFeedbackExportBundle(input: { }; } -export function renderFeedbackExportSummary(exported: FeedbackExportResult): string { +export function renderFeedbackExportSummary( + exported: FeedbackExportResult, +): string { const lines: string[] = []; lines.push(""); lines.push(pc.bold(pc.magenta("Taskcore Feedback Export"))); @@ -448,16 +534,30 @@ export function renderFeedbackExportSummary(exported: FeedbackExportResult): str lines.push(""); lines.push(pc.bold("Export Summary")); lines.push(horizontalRule()); - lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`); - lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`); - lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`); - lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`); + lines.push( + ` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`, + ); + lines.push( + ` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`, + ); + lines.push( + ` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`, + ); + lines.push( + ` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`, + ); lines.push(""); lines.push(pc.dim("Files:")); lines.push(` ${path.join(exported.outputDir, "index.json")}`); - lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`); - lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`); - lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`); + lines.push( + ` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`, + ); + lines.push( + ` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`, + ); + lines.push( + ` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`, + ); lines.push(` ${exported.zipPath}`); lines.push(""); return lines.join("\n"); @@ -494,7 +594,10 @@ function asRecord(value: unknown): Record | null { return value as Record; } -function compactText(value: string | null | undefined, maxLength = 88): string | null { +function compactText( + value: string | null | undefined, + maxLength = 88, +): string | null { if (!value) return null; const compact = value.replace(/\s+/g, " ").trim(); if (!compact) return null; @@ -503,7 +606,8 @@ function compactText(value: string | null | undefined, maxLength = 88): string | } function formatTimestamp(value: unknown): string { - if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " "); + if (value instanceof Date) + return value.toISOString().slice(0, 19).replace("T", " "); if (typeof value === "string") return value.slice(0, 19).replace("T", " "); return "-"; } @@ -517,7 +621,10 @@ function padRight(value: string, width: number): string { } function defaultFeedbackExportDirName(): string { - const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + const iso = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z"); return `feedback-export-${iso}`; } @@ -525,11 +632,15 @@ async function ensureEmptyOutputDirectory(outputDir: string): Promise { try { const info = await stat(outputDir); if (!info.isDirectory()) { - throw new Error(`Output path already exists and is not a directory: ${outputDir}`); + throw new Error( + `Output path already exists and is not a directory: ${outputDir}`, + ); } const entries = await readdir(outputDir); if (entries.length > 0) { - throw new Error(`Output directory already exists and is not empty: ${outputDir}`); + throw new Error( + `Output directory already exists and is not empty: ${outputDir}`, + ); } } catch (error) { const message = error instanceof Error ? error.message : ""; @@ -548,13 +659,19 @@ async function collectJsonFilesForArchive( const files: Record = {}; for (const relativePath of relativePaths) { const normalized = relativePath.replace(/\\/g, "/"); - files[normalized] = await readFile(path.join(outputDir, normalized), "utf8"); + files[normalized] = await readFile( + path.join(outputDir, normalized), + "utf8", + ); } return files; } function sanitizeFileSegment(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback"; + return ( + value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || + "feedback" + ); } function writeUint16(target: Uint8Array, offset: number, value: number) { @@ -580,14 +697,19 @@ function crc32(bytes: Uint8Array) { return (crc ^ 0xffffffff) >>> 0; } -function createStoredZipArchive(files: Record, rootPath: string): Uint8Array { +function createStoredZipArchive( + files: Record, + rootPath: string, +): Uint8Array { const encoder = new TextEncoder(); const localChunks: Uint8Array[] = []; const centralChunks: Uint8Array[] = []; let localOffset = 0; let entryCount = 0; - for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { + for (const [relativePath, content] of Object.entries(files).sort( + ([left], [right]) => left.localeCompare(right), + )) { const fileName = encoder.encode(`${rootPath}/${relativePath}`); const body = encoder.encode(content); const checksum = crc32(body); @@ -622,9 +744,14 @@ function createStoredZipArchive(files: Record, rootPath: string) entryCount += 1; } - const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const centralDirectoryLength = centralChunks.reduce( + (sum, chunk) => sum + chunk.length, + 0, + ); const archive = new Uint8Array( - localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22, + localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + + centralDirectoryLength + + 22, ); let offset = 0; for (const chunk of localChunks) { diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 7ab0a39..f5ff96c 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -91,13 +91,17 @@ export function registerIssueCommands(program: Command): void { .option("--status ", "Comma-separated statuses") .option("--assignee-agent-id ", "Filter by assignee agent ID") .option("--project-id ", "Filter by project ID") - .option("--match ", "Local text match on identifier/title/description") + .option( + "--match ", + "Local text match on identifier/title/description", + ) .action(async (opts: IssueBaseOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const params = new URLSearchParams(); if (opts.status) params.set("status", opts.status); - if (opts.assigneeAgentId) params.set("assigneeAgentId", opts.assigneeAgentId); + if (opts.assigneeAgentId) + params.set("assigneeAgentId", opts.assigneeAgentId); if (opts.projectId) params.set("projectId", opts.projectId); const query = params.toString(); @@ -182,7 +186,10 @@ export function registerIssueCommands(program: Command): void { billingCode: opts.billingCode, }); - const created = await ctx.api.post(`/api/companies/${ctx.companyId}/issues`, payload); + const created = await ctx.api.post( + `/api/companies/${ctx.companyId}/issues`, + payload, + ); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -207,7 +214,10 @@ export function registerIssueCommands(program: Command): void { .option("--request-depth ", "Request depth integer") .option("--billing-code ", "Billing code") .option("--comment ", "Optional comment to add with update") - .option("--hidden-at ", "Set hiddenAt timestamp or literal 'null'") + .option( + "--hidden-at ", + "Set hiddenAt timestamp or literal 'null'", + ) .action(async (issueId: string, opts: IssueUpdateOptions) => { try { const ctx = resolveCommandContext(opts); @@ -226,7 +236,9 @@ export function registerIssueCommands(program: Command): void { hiddenAt: parseHiddenAt(opts.hiddenAt), }); - const updated = await ctx.api.patch(`/api/issues/${issueId}`, payload); + const updated = await ctx.api.patch< + Issue & { comment?: IssueComment | null } + >(`/api/issues/${issueId}`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -248,7 +260,10 @@ export function registerIssueCommands(program: Command): void { body: opts.body, reopen: opts.reopen, }); - const comment = await ctx.api.post(`/api/issues/${issueId}/comments`, payload); + const comment = await ctx.api.post( + `/api/issues/${issueId}/comments`, + payload, + ); printOutput(comment, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -264,16 +279,29 @@ export function registerIssueCommands(program: Command): void { .option("--target-type ", "Filter by target type") .option("--vote ", "Filter by vote value") .option("--status ", "Filter by trace status") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--include-payload", "Include stored payload snapshots in the response") + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--include-payload", + "Include stored payload snapshots in the response", + ) .action(async (issueId: string, opts: IssueFeedbackOptions) => { try { const ctx = resolveCommandContext(opts); - const traces = (await ctx.api.get( - `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, - )) ?? []; + const traces = + (await ctx.api.get( + `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + )) ?? []; if (ctx.json) { printOutput(traces, { json: true }); return; @@ -303,32 +331,53 @@ export function registerIssueCommands(program: Command): void { .option("--target-type ", "Filter by target type") .option("--vote ", "Filter by vote value") .option("--status ", "Filter by trace status") - .option("--from ", "Only include traces created at or after this timestamp") - .option("--to ", "Only include traces created at or before this timestamp") - .option("--shared-only", "Only include traces eligible for sharing/export") - .option("--include-payload", "Include stored payload snapshots in the export") + .option( + "--from ", + "Only include traces created at or after this timestamp", + ) + .option( + "--to ", + "Only include traces created at or before this timestamp", + ) + .option( + "--shared-only", + "Only include traces eligible for sharing/export", + ) + .option( + "--include-payload", + "Include stored payload snapshots in the export", + ) .option("--out ", "Write export to a file path instead of stdout") .option("--format ", "Export format: json or ndjson", "ndjson") .action(async (issueId: string, opts: IssueFeedbackOptions) => { try { const ctx = resolveCommandContext(opts); - const traces = (await ctx.api.get( - `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, - )) ?? []; + const traces = + (await ctx.api.get( + `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + )) ?? []; const serialized = serializeFeedbackTraces(traces, opts.format); if (opts.out?.trim()) { await writeFile(opts.out, serialized, "utf8"); if (ctx.json) { printOutput( - { out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) }, + { + out: opts.out, + count: traces.length, + format: normalizeFeedbackTraceExportFormat(opts.format), + }, { json: true }, ); return; } - console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`); + console.log( + `Wrote ${traces.length} feedback trace(s) to ${opts.out}`, + ); return; } - process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`); + process.stdout.write( + `${serialized}${serialized.endsWith("\n") ? "" : "\n"}`, + ); } catch (err) { handleCommandError(err); } @@ -353,7 +402,10 @@ export function registerIssueCommands(program: Command): void { agentId: opts.agentId, expectedStatuses: parseCsv(opts.expectedStatuses), }); - const updated = await ctx.api.post(`/api/issues/${issueId}/checkout`, payload); + const updated = await ctx.api.post( + `/api/issues/${issueId}/checkout`, + payload, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -369,7 +421,10 @@ export function registerIssueCommands(program: Command): void { .action(async (issueId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const updated = await ctx.api.post(`/api/issues/${issueId}/release`, {}); + const updated = await ctx.api.post( + `/api/issues/${issueId}/release`, + {}, + ); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -380,7 +435,10 @@ export function registerIssueCommands(program: Command): void { function parseCsv(value: string | undefined): string[] { if (!value) return []; - return value.split(",").map((v) => v.trim()).filter(Boolean); + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); } function parseOptionalInt(value: string | undefined): number | undefined { diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 6b9c34a..9c7b742 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -25,7 +25,6 @@ interface PluginRecord { updatedAt: string; } - // --------------------------------------------------------------------------- // Option types // --------------------------------------------------------------------------- @@ -92,7 +91,9 @@ function formatPlugin(p: PluginRecord): string { // --------------------------------------------------------------------------- export function registerPluginCommands(program: Command): void { - const plugin = program.command("plugin").description("Plugin lifecycle management"); + const plugin = program + .command("plugin") + .description("Plugin lifecycle management"); // ------------------------------------------------------------------------- // plugin list @@ -101,12 +102,19 @@ export function registerPluginCommands(program: Command): void { plugin .command("list") .description("List installed plugins") - .option("--status ", "Filter by status (ready, error, disabled, installed, upgrade_pending)") + .option( + "--status ", + "Filter by status (ready, error, disabled, installed, upgrade_pending)", + ) .action(async (opts: PluginListOptions) => { try { const ctx = resolveCommandContext(opts); - const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ""; - const plugins = await ctx.api.get(`/api/plugins${qs}`); + const qs = opts.status + ? `?status=${encodeURIComponent(opts.status)}` + : ""; + const plugins = await ctx.api.get( + `/api/plugins${qs}`, + ); if (ctx.json) { printOutput(plugins, { json: true }); @@ -136,13 +144,20 @@ export function registerPluginCommands(program: Command): void { .command("install ") .description( "Install a plugin from a local path or npm package.\n" + - " Examples:\n" + - " taskcore plugin install ./my-plugin # local path\n" + - " taskcore plugin install @acme/plugin-linear # npm package\n" + - " taskcore plugin install @acme/plugin-linear@1.2 # pinned version", + " Examples:\n" + + " taskcore plugin install ./my-plugin # local path\n" + + " taskcore plugin install @acme/plugin-linear # npm package\n" + + " taskcore plugin install @acme/plugin-linear@1.2 # pinned version", + ) + .option( + "-l, --local", + "Treat as a local filesystem path", + false, + ) + .option( + "--version ", + "Specific npm version to install (npm packages only)", ) - .option("-l, --local", "Treat as a local filesystem path", false) - .option("--version ", "Specific npm version to install (npm packages only)") .action(async (packageArg: string, opts: PluginInstallOptions) => { try { const ctx = resolveCommandContext(opts); @@ -167,11 +182,14 @@ export function registerPluginCommands(program: Command): void { ); } - const installedPlugin = await ctx.api.post("/api/plugins/install", { - packageName: resolvedPackage, - version: opts.version, - isLocalPath: isLocal, - }); + const installedPlugin = await ctx.api.post( + "/api/plugins/install", + { + packageName: resolvedPackage, + version: opts.version, + isLocalPath: isLocal, + }, + ); if (ctx.json) { printOutput(installedPlugin, { json: true }); @@ -206,9 +224,13 @@ export function registerPluginCommands(program: Command): void { .command("uninstall ") .description( "Uninstall a plugin by its plugin key or database ID.\n" + - " Use --force to hard-purge all state and config.", + " Use --force to hard-purge all state and config.", + ) + .option( + "--force", + "Purge all plugin state and config (hard delete)", + false, ) - .option("--force", "Purge all plugin state and config (hard delete)", false) .action(async (pluginKey: string, opts: PluginUninstallOptions) => { try { const ctx = resolveCommandContext(opts); @@ -234,7 +256,11 @@ export function registerPluginCommands(program: Command): void { return; } - console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`)); + console.log( + pc.green( + `✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`, + ), + ); } catch (err) { handleCommandError(err); } @@ -260,7 +286,11 @@ export function registerPluginCommands(program: Command): void { return; } - console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + console.log( + pc.green( + `✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`, + ), + ); } catch (err) { handleCommandError(err); } @@ -286,7 +316,11 @@ export function registerPluginCommands(program: Command): void { return; } - console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + console.log( + pc.dim( + `Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`, + ), + ); } catch (err) { handleCommandError(err); } @@ -362,8 +396,8 @@ export function registerPluginCommands(program: Command): void { for (const ex of rows) { console.log( `${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` + - ` ${ex.description}\n` + - ` ${pc.cyan(`taskcore plugin install ${ex.localPath}`)}`, + ` ${ex.description}\n` + + ` ${pc.cyan(`taskcore plugin install ${ex.localPath}`)}`, ); } } catch (err) { diff --git a/cli/src/commands/client/zip.ts b/cli/src/commands/client/zip.ts index 1324a3c..f1ff2fd 100644 --- a/cli/src/commands/client/zip.ts +++ b/cli/src/commands/client/zip.ts @@ -14,11 +14,7 @@ export const binaryContentTypeByExtension: Record = { }; function normalizeArchivePath(pathValue: string) { - return pathValue - .replace(/\\/g, "/") - .split("/") - .filter(Boolean) - .join("/"); + return pathValue.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); } function readUint16(source: Uint8Array, offset: number) { @@ -27,11 +23,12 @@ function readUint16(source: Uint8Array, offset: number) { function readUint32(source: Uint8Array, offset: number) { return ( - source[offset]! | - (source[offset + 1]! << 8) | - (source[offset + 2]! << 16) | - (source[offset + 3]! << 24) - ) >>> 0; + (source[offset]! | + (source[offset + 1]! << 8) | + (source[offset + 2]! << 16) | + (source[offset + 3]! << 24)) >>> + 0 + ); } function sharedArchiveRoot(paths: string[]) { @@ -41,13 +38,19 @@ function sharedArchiveRoot(paths: string[]) { .filter((parts) => parts.length > 0); if (firstSegments.length === 0) return null; const candidate = firstSegments[0]![0]!; - return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate) + return firstSegments.every( + (parts) => parts.length > 1 && parts[0] === candidate, + ) ? candidate : null; } -function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry { - const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()]; +function bytesToPortableFileEntry( + pathValue: string, + bytes: Uint8Array, +): CompanyPortabilityFileEntry { + const contentType = + binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()]; if (!contentType) return textDecoder.decode(bytes); return { encoding: "base64", @@ -59,17 +62,22 @@ function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): Company async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) { if (compressionMethod === 0) return bytes; if (compressionMethod !== 8) { - throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported."); + throw new Error( + "Unsupported zip archive: only STORE and DEFLATE entries are supported.", + ); } return new Uint8Array(inflateRawSync(bytes)); } -export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{ +export async function readZipArchive( + source: ArrayBuffer | Uint8Array, +): Promise<{ rootPath: string | null; files: Record; }> { const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); - const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = []; + const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = + []; let offset = 0; while (offset + 4 <= bytes.length) { @@ -90,7 +98,9 @@ export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise< const extraFieldLength = readUint16(bytes, offset + 28); if ((generalPurposeFlag & 0x0008) !== 0) { - throw new Error("Unsupported zip archive: data descriptors are not supported."); + throw new Error( + "Unsupported zip archive: data descriptors are not supported.", + ); } const nameOffset = offset + 30; @@ -100,11 +110,16 @@ export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise< throw new Error("Invalid zip archive: truncated file contents."); } - const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)); + const rawArchivePath = textDecoder.decode( + bytes.slice(nameOffset, nameOffset + fileNameLength), + ); const archivePath = normalizeArchivePath(rawArchivePath); const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/")); if (archivePath && !isDirectoryEntry) { - const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd)); + const entryBytes = await inflateZipEntry( + compressionMethod, + bytes.slice(bodyOffset, bodyEnd), + ); entries.push({ path: archivePath, body: bytesToPortableFileEntry(archivePath, entryBytes), diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index c4faf0a..83ca1ba 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -1,6 +1,11 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; -import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js"; +import { + readConfig, + writeConfig, + configExists, + resolveConfigPath, +} from "../config/store.js"; import type { TaskcoreConfig } from "../config/schema.js"; import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js"; import { promptDatabase } from "../prompts/database.js"; @@ -17,7 +22,13 @@ import { } from "../config/home.js"; import { printTaskcoreCliBanner } from "../utils/banner.js"; -type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets"; +type Section = + | "llm" + | "database" + | "logging" + | "server" + | "storage" + | "secrets"; const SECTION_LABELS: Record = { llm: "LLM Provider", @@ -101,7 +112,9 @@ export async function configure(opts: { let section: Section | undefined = opts.section as Section | undefined; if (section && !SECTION_LABELS[section]) { - p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`); + p.log.error( + `Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`, + ); p.outro(""); return; } @@ -162,13 +175,27 @@ export async function configure(opts: { { const keyResult = ensureLocalSecretsKeyFile(config, configPath); if (keyResult.status === "created") { - p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + p.log.success( + `Created local secrets key file at ${pc.dim(keyResult.path)}`, + ); } else if (keyResult.status === "existing") { - p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + p.log.message( + pc.dim( + `Using existing local secrets key file at ${keyResult.path}`, + ), + ); } else if (keyResult.status === "skipped_provider") { - p.log.message(pc.dim("Skipping local key file management for non-local provider")); + p.log.message( + pc.dim( + "Skipping local key file management for non-local provider", + ), + ); } else { - p.log.message(pc.dim("Skipping local key file management because TASKCORE_SECRETS_MASTER_KEY is set")); + p.log.message( + pc.dim( + "Skipping local key file management because TASKCORE_SECRETS_MASTER_KEY is set", + ), + ); } } break; diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index b65beb8..b402bd9 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -18,13 +18,22 @@ type DbBackupOptions = { json?: boolean; }; -function resolveConnectionString(configPath?: string): { value: string; source: string } { +function resolveConnectionString(configPath?: string): { + value: string; + source: string; +} { const envUrl = process.env.DATABASE_URL?.trim(); if (envUrl) return { value: envUrl, source: "DATABASE_URL" }; const config = readConfig(configPath); - if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) { - return { value: config.database.connectionString.trim(), source: "config.database.connectionString" }; + if ( + config?.database.mode === "postgres" && + config.database.connectionString?.trim() + ) { + return { + value: config.database.connectionString.trim(), + source: "config.database.connectionString", + }; } const port = config?.database.embeddedPostgresPort ?? 54329; @@ -34,10 +43,15 @@ function resolveConnectionString(configPath?: string): { value: string; source: }; } -function normalizeRetentionDays(value: number | undefined, fallback: number): number { +function normalizeRetentionDays( + value: number | undefined, + fallback: number, +): number { const candidate = value ?? fallback; if (!Number.isInteger(candidate) || candidate < 1) { - throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`); + throw new Error( + `Invalid retention days '${String(candidate)}'. Use a positive integer.`, + ); } return candidate; } @@ -54,7 +68,8 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const config = readConfig(opts.config); const connection = resolveConnectionString(opts.config); const defaultDir = resolveDefaultBackupDir(resolveTaskcoreInstanceId()); - const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir; + const configuredDir = + opts.dir?.trim() || config?.database.backup.dir || defaultDir; const backupDir = resolveBackupDir(configuredDir); const retentionDays = normalizeRetentionDays( opts.retentionDays, diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 5ea380c..c152274 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -53,7 +53,8 @@ export async function doctor(opts: { status: "fail", message: `Could not read config: ${err instanceof Error ? err.message : String(err)}`, canRepair: false, - repairHint: "Run `taskcore configure --section database` or `taskcore onboard`", + repairHint: + "Run `taskcore configure --section database` or `taskcore onboard`", }; results.push(readResult); printResult(readResult); @@ -136,7 +137,8 @@ async function maybeRepair( result: CheckResult, opts: { repair?: boolean; yes?: boolean }, ): Promise { - if (result.status === "pass" || !result.canRepair || !result.repair) return false; + if (result.status === "pass" || !result.canRepair || !result.repair) + return false; if (!opts.repair) return false; let shouldRepair = opts.yes; @@ -155,7 +157,9 @@ async function maybeRepair( p.log.success(`Repaired: ${result.name}`); return true; } catch (err) { - p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`); + p.log.error( + `Repair failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } return false; @@ -179,7 +183,11 @@ async function runRepairableCheck(input: { return result; } -function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } { +function printSummary(results: CheckResult[]): { + passed: number; + warned: number; + failed: number; +} { const passed = results.filter((r) => r.status === "pass").length; const warned = results.filter((r) => r.status === "warn").length; const failed = results.filter((r) => r.status === "fail").length; @@ -192,7 +200,9 @@ function printSummary(results: CheckResult[]): { passed: number; warned: number; p.note(parts.join(", "), "Summary"); if (failed > 0) { - p.outro(pc.red("Some checks failed. Fix the issues above and re-run doctor.")); + p.outro( + pc.red("Some checks failed. Fix the issues above and re-run doctor."), + ); } else if (warned > 0) { p.outro(pc.yellow("All critical checks passed with some warnings.")); } else { diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index 7e669df..3469b4d 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -1,7 +1,11 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import type { TaskcoreConfig } from "../config/schema.js"; -import { configExists, readConfig, resolveConfigPath } from "../config/store.js"; +import { + configExists, + readConfig, + resolveConfigPath, +} from "../config/store.js"; import { readAgentJwtSecretFromEnv, readAgentJwtSecretFromEnvFile, @@ -56,8 +60,13 @@ export async function envCommand(opts: { config?: string }): Promise { } const rows = collectDeploymentEnvRows(config, configPath); - const missingRequired = rows.filter((row) => row.required && row.source === "missing"); - const sortedRows = rows.sort((a, b) => Number(b.required) - Number(a.required) || a.key.localeCompare(b.key)); + const missingRequired = rows.filter( + (row) => row.required && row.source === "missing", + ); + const sortedRows = rows.sort( + (a, b) => + Number(b.required) - Number(a.required) || a.key.localeCompare(b.key), + ); const requiredRows = sortedRows.filter((row) => row.required); const optionalRows = sortedRows.filter((row) => !row.required); @@ -67,7 +76,12 @@ export async function envCommand(opts: { config?: string }): Promise { p.log.message(pc.bold(title)); for (const entry of entries) { - const status = entry.source === "missing" ? pc.red("missing") : entry.source === "default" ? pc.yellow("default") : pc.green("set"); + const status = + entry.source === "missing" + ? pc.red("missing") + : entry.source === "default" + ? pc.yellow("default") + : pc.green("set"); const sourceNote = { env: "environment", config: "config", @@ -84,9 +98,13 @@ export async function envCommand(opts: { config?: string }): Promise { formatSection("Required environment variables", requiredRows); formatSection("Optional environment variables", optionalRows); - const exportRows = rows.map((row) => (row.source === "missing" ? { ...row, value: "" } : row)); + const exportRows = rows.map((row) => + row.source === "missing" ? { ...row, value: "" } : row, + ); const uniqueRows = uniqueByKey(exportRows); - const exportBlock = uniqueRows.map((row) => `export ${row.key}=${quoteShellValue(row.value)}`).join("\n"); + const exportBlock = uniqueRows + .map((row) => `export ${row.key}=${quoteShellValue(row.value)}`) + .join("\n"); if (configReadError) { p.log.error(`Could not load config cleanly: ${configReadError}`); @@ -109,15 +127,25 @@ export async function envCommand(opts: { config?: string }): Promise { p.outro("Done"); } -function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: string): EnvVarRow[] { +function collectDeploymentEnvRows( + config: TaskcoreConfig | null, + configPath: string, +): EnvVarRow[] { const agentJwtEnvFile = resolveAgentJwtEnvFile(configPath); const jwtEnv = readAgentJwtSecretFromEnv(configPath); - const jwtFile = jwtEnv ? null : readAgentJwtSecretFromEnvFile(agentJwtEnvFile); + const jwtFile = jwtEnv + ? null + : readAgentJwtSecretFromEnvFile(agentJwtEnvFile); const jwtSource = jwtEnv ? "env" : jwtFile ? "file" : "missing"; - const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; + const dbUrl = + process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; const databaseMode = config?.database?.mode ?? "embedded-postgres"; - const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing"; + const dbUrlSource: EnvSource = process.env.DATABASE_URL + ? "env" + : config?.database?.connectionString + ? "config" + : "missing"; const publicUrl = process.env.TASKCORE_PUBLIC_URL ?? process.env.TASKCORE_AUTH_PUBLIC_BASE_URL ?? @@ -125,14 +153,15 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str process.env.BETTER_AUTH_BASE_URL ?? config?.auth?.publicBaseUrl ?? ""; - const publicUrlSource: EnvSource = - process.env.TASKCORE_PUBLIC_URL + const publicUrlSource: EnvSource = process.env.TASKCORE_PUBLIC_URL + ? "env" + : process.env.TASKCORE_AUTH_PUBLIC_BASE_URL || + process.env.BETTER_AUTH_URL || + process.env.BETTER_AUTH_BASE_URL ? "env" - : process.env.TASKCORE_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL - ? "env" - : config?.auth?.publicBaseUrl - ? "config" - : "missing"; + : config?.auth?.publicBaseUrl + ? "config" + : "missing"; let trustedOriginsDefault = ""; if (publicUrl) { try { @@ -142,7 +171,9 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str } } - const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; + const heartbeatInterval = + process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? + DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; const secretsProvider = process.env.TASKCORE_SECRETS_PROVIDER ?? @@ -176,9 +207,7 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str config?.storage?.s3?.endpoint ?? ""; const storageS3Prefix = - process.env.TASKCORE_STORAGE_S3_PREFIX ?? - config?.storage?.s3?.prefix ?? - ""; + process.env.TASKCORE_STORAGE_S3_PREFIX ?? config?.storage?.s3?.prefix ?? ""; const storageS3ForcePathStyle = process.env.TASKCORE_STORAGE_S3_FORCE_PATH_STYLE ?? String(config?.storage?.s3?.forcePathStyle ?? false); @@ -210,8 +239,14 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str key: "PORT", value: process.env.PORT ?? - (config?.server?.port !== undefined ? String(config.server.port) : "3100"), - source: process.env.PORT ? "env" : config?.server?.port !== undefined ? "config" : "default", + (config?.server?.port !== undefined + ? String(config.server.port) + : "3100"), + source: process.env.PORT + ? "env" + : config?.server?.port !== undefined + ? "config" + : "default", required: false, note: "HTTP listen port", }, @@ -235,7 +270,9 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str }, { key: "TASKCORE_AGENT_JWT_TTL_SECONDS", - value: process.env.TASKCORE_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS, + value: + process.env.TASKCORE_AGENT_JWT_TTL_SECONDS ?? + DEFAULT_AGENT_JWT_TTL_SECONDS, source: process.env.TASKCORE_AGENT_JWT_TTL_SECONDS ? "env" : "default", required: false, note: "JWT lifetime in seconds", @@ -249,7 +286,8 @@ function collectDeploymentEnvRows(config: TaskcoreConfig | null, configPath: str }, { key: "TASKCORE_AGENT_JWT_AUDIENCE", - value: process.env.TASKCORE_AGENT_JWT_AUDIENCE ?? DEFAULT_AGENT_JWT_AUDIENCE, + value: + process.env.TASKCORE_AGENT_JWT_AUDIENCE ?? DEFAULT_AGENT_JWT_AUDIENCE, source: process.env.TASKCORE_AGENT_JWT_AUDIENCE ? "env" : "default", required: false, note: "JWT audience", @@ -406,6 +444,6 @@ function uniqueByKey(rows: EnvVarRow[]): EnvVarRow[] { } function quoteShellValue(value: string): string { - if (value === "") return "\"\""; + if (value === "") return '""'; return `'${value.replaceAll("'", "'\\''")}'`; } diff --git a/cli/src/commands/heartbeat-run.ts b/cli/src/commands/heartbeat-run.ts index 0de141f..2d6dde5 100644 --- a/cli/src/commands/heartbeat-run.ts +++ b/cli/src/commands/heartbeat-run.ts @@ -1,12 +1,27 @@ import { setTimeout as delay } from "node:timers/promises"; import pc from "picocolors"; -import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@taskcore/shared"; +import type { + Agent, + HeartbeatRun, + HeartbeatRunEvent, + HeartbeatRunStatus, +} from "@taskcore/shared"; import { getCLIAdapter } from "../adapters/index.js"; import { resolveCommandContext } from "./client/common.js"; -const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const; +const HEARTBEAT_SOURCES = [ + "timer", + "assignment", + "on_demand", + "automation", +] as const; const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const; -const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const TERMINAL_STATUSES = new Set([ + "succeeded", + "failed", + "cancelled", + "timed_out", +]); const POLL_INTERVAL_MS = 200; type HeartbeatSource = (typeof HEARTBEAT_SOURCES)[number]; @@ -62,7 +77,9 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { const source = HEARTBEAT_SOURCES.includes(opts.source as HeartbeatSource) ? (opts.source as HeartbeatSource) : "on_demand"; - const triggerDetail = HEARTBEAT_TRIGGERS.includes(opts.trigger as HeartbeatTrigger) + const triggerDetail = HEARTBEAT_TRIGGERS.includes( + opts.trigger as HeartbeatTrigger, + ) ? (opts.trigger as HeartbeatTrigger) : "manual"; @@ -99,7 +116,11 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } const run = invokeRes as HeartbeatRun; - console.log(pc.cyan(`Invoked heartbeat run ${run.id} for agent ${agent.name} (${agent.id})`)); + console.log( + pc.cyan( + `Invoked heartbeat run ${run.id} for agent ${agent.name} (${agent.id})`, + ), + ); const runId = run.id; let activeRunId: string | null = null; @@ -107,35 +128,46 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { let logOffset = 0; let stdoutJsonBuffer = ""; - const printRawChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => { - if (stream === "stdout") process.stdout.write(pc.green("[stdout] ") + chunk); - else if (stream === "stderr") process.stdout.write(pc.red("[stderr] ") + chunk); + const printRawChunk = ( + stream: "stdout" | "stderr" | "system", + chunk: string, + ) => { + if (stream === "stdout") + process.stdout.write(pc.green("[stdout] ") + chunk); + else if (stream === "stderr") + process.stdout.write(pc.red("[stderr] ") + chunk); else process.stdout.write(pc.yellow("[system] ") + chunk); }; const printAdapterInvoke = (payload: Record) => { - const adapterType = typeof payload.adapterType === "string" ? payload.adapterType : "unknown"; + const adapterType = + typeof payload.adapterType === "string" ? payload.adapterType : "unknown"; const command = typeof payload.command === "string" ? payload.command : ""; const cwd = typeof payload.cwd === "string" ? payload.cwd : ""; const args = Array.isArray(payload.commandArgs) && - (payload.commandArgs as unknown[]).every((v) => typeof v === "string") + (payload.commandArgs as unknown[]).every((v) => typeof v === "string") ? (payload.commandArgs as string[]) : []; const env = - typeof payload.env === "object" && payload.env !== null && !Array.isArray(payload.env) + typeof payload.env === "object" && + payload.env !== null && + !Array.isArray(payload.env) ? (payload.env as Record) : null; const prompt = typeof payload.prompt === "string" ? payload.prompt : ""; const context = - typeof payload.context === "object" && payload.context !== null && !Array.isArray(payload.context) + typeof payload.context === "object" && + payload.context !== null && + !Array.isArray(payload.context) ? (payload.context as Record) : null; console.log(pc.cyan(`Adapter: ${adapterType}`)); if (cwd) console.log(pc.cyan(`Working dir: ${cwd}`)); if (command) { - const rendered = args.length > 0 ? `${command} ${args.join(" ")}` : command; + const rendered = + args.length > 0 ? `${command} ${args.join(" ")}` : command; console.log(pc.cyan(`Command: ${rendered}`)); } if (env) { @@ -155,7 +187,10 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { const adapterType: AdapterType = agent.adapterType ?? "claude_local"; const cliAdapter = getCLIAdapter(adapterType); - const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => { + const handleStreamChunk = ( + stream: "stdout" | "stderr" | "system", + chunk: string, + ) => { if (debug) { printRawChunk(stream, chunk); return; @@ -177,11 +212,12 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { const handleEvent = (event: HeartbeatRunEventRecord) => { const payload = normalizePayload(event.payload); if (event.runId !== runId) return; - const eventType = typeof event.eventType === "string" - ? event.eventType - : typeof event.type === "string" - ? event.type - : ""; + const eventType = + typeof event.eventType === "string" + ? event.eventType + : typeof event.type === "string" + ? event.type + : ""; if (eventType === "heartbeat.run.status") { const status = typeof payload.status === "string" ? payload.status : null; @@ -191,14 +227,19 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } else if (eventType === "adapter.invoke") { printAdapterInvoke(payload); } else if (eventType === "heartbeat.run.log") { - const stream = typeof payload.stream === "string" ? payload.stream : "system"; + const stream = + typeof payload.stream === "string" ? payload.stream : "system"; const chunk = typeof payload.chunk === "string" ? payload.chunk : ""; if (!chunk) return; if (stream === "stdout" || stream === "stderr" || stream === "system") { handleStreamChunk(stream, chunk); } } else if (typeof event.message === "string") { - console.log(pc.gray(`[event] ${eventType || "heartbeat.run.event"}: ${event.message}`)); + console.log( + pc.gray( + `[event] ${eventType || "heartbeat.run.event"}: ${event.message}`, + ), + ); } lastEventSeq = Math.max(lastEventSeq, event.seq ?? 0); @@ -219,13 +260,16 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { const events = await api.get( `/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`, ); - for (const event of Array.isArray(events) ? (events as HeartbeatRunEventRecord[]) : []) { + for (const event of Array.isArray(events) + ? (events as HeartbeatRunEventRecord[]) + : []) { handleEvent(event); } - const runList = (await api.get<(HeartbeatRun | null)[]>( - `/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`, - )) || []; + const runList = + (await api.get<(HeartbeatRun | null)[]>( + `/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`, + )) || []; const currentRun = runList.find((r) => r && r.id === activeRunId) ?? null; if (!currentRun) { @@ -292,21 +336,32 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { if (finalRun) { const resultObj = asRecord(finalRun.resultJson); if (resultObj) { - const subtype = typeof resultObj.subtype === "string" ? resultObj.subtype : ""; + const subtype = + typeof resultObj.subtype === "string" ? resultObj.subtype : ""; const isError = resultObj.is_error === true; - const errors = Array.isArray(resultObj.errors) ? resultObj.errors.map(asErrorText).filter(Boolean) : []; - const resultText = typeof resultObj.result === "string" ? resultObj.result.trim() : ""; + const errors = Array.isArray(resultObj.errors) + ? resultObj.errors.map(asErrorText).filter(Boolean) + : []; + const resultText = + typeof resultObj.result === "string" ? resultObj.result.trim() : ""; if (subtype || isError || errors.length > 0 || resultText) { console.log(pc.red("Claude result details:")); if (subtype) console.log(pc.red(` subtype: ${subtype}`)); if (isError) console.log(pc.red(" is_error: true")); - if (errors.length > 0) console.log(pc.red(` errors: ${errors.join(" | ")}`)); + if (errors.length > 0) + console.log(pc.red(` errors: ${errors.join(" | ")}`)); if (resultText) console.log(pc.red(` result: ${resultText}`)); } } - const stderrExcerpt = typeof finalRun.stderrExcerpt === "string" ? finalRun.stderrExcerpt.trim() : ""; - const stdoutExcerpt = typeof finalRun.stdoutExcerpt === "string" ? finalRun.stdoutExcerpt.trim() : ""; + const stderrExcerpt = + typeof finalRun.stderrExcerpt === "string" + ? finalRun.stderrExcerpt.trim() + : ""; + const stdoutExcerpt = + typeof finalRun.stdoutExcerpt === "string" + ? finalRun.stdoutExcerpt.trim() + : ""; if (stderrExcerpt) { console.log(pc.red("stderr excerpt:")); console.log(stderrExcerpt); @@ -324,14 +379,20 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } function normalizePayload(payload: unknown): Record { - return typeof payload === "object" && payload !== null ? (payload as Record) : {}; + return typeof payload === "object" && payload !== null + ? (payload as Record) + : {}; } -function safeParseLogLine(line: string): { stream: "stdout" | "stderr" | "system"; chunk: string } | null { +function safeParseLogLine( + line: string, +): { stream: "stdout" | "stderr" | "system"; chunk: string } | null { try { const parsed = JSON.parse(line) as { stream?: unknown; chunk?: unknown }; const stream = - parsed.stream === "stdout" || parsed.stream === "stderr" || parsed.stream === "system" + parsed.stream === "stdout" || + parsed.stream === "stderr" || + parsed.stream === "system" ? parsed.stream : "system"; const chunk = typeof parsed.chunk === "string" ? parsed.chunk : ""; diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 7d0298b..1fa77ff 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -17,7 +17,12 @@ import { type SecretProvider, type StorageProvider, } from "@taskcore/shared"; -import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; +import { + configExists, + readConfig, + resolveConfigPath, + writeConfig, +} from "../config/store.js"; import type { TaskcoreConfig } from "../config/schema.js"; import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js"; @@ -54,7 +59,10 @@ type OnboardOptions = { bind?: BindMode; }; -type OnboardDefaults = Pick; +type OnboardDefaults = Pick< + TaskcoreConfig, + "database" | "logging" | "server" | "auth" | "storage" | "secrets" +>; const TAILNET_BIND_WARNING = "No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or TASKCORE_TAILNET_BIND_HOST is set."; @@ -106,7 +114,10 @@ function parseNumberFromEnv(rawValue: string | undefined): number | null { return parsed; } -function parseEnumFromEnv(rawValue: string | undefined, allowedValues: readonly T[]): T | null { +function parseEnumFromEnv( + rawValue: string | undefined, + allowedValues: readonly T[], +): T | null { if (!rawValue) return null; return allowedValues.includes(rawValue as T) ? (rawValue as T) : null; } @@ -116,11 +127,16 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null { return path.resolve(expandHomePrefix(rawValue.trim())); } -function describeServerBinding(server: Pick): string { +function describeServerBinding( + server: Pick< + TaskcoreConfig["server"], + "bind" | "customBindHost" | "host" | "port" + >, +): string { const bind = server.bind ?? inferBindModeFromHost(server.host); const detail = bind === "custom" - ? server.customBindHost ?? server.host + ? (server.customBindHost ?? server.host) : bind === "tailnet" ? "detected tailscale address" : server.host; @@ -139,33 +155,41 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; const publicUrl = preferTrustedLocal ? undefined - : ( - process.env.TASKCORE_PUBLIC_URL?.trim() || + : process.env.TASKCORE_PUBLIC_URL?.trim() || process.env.TASKCORE_AUTH_PUBLIC_BASE_URL?.trim() || process.env.BETTER_AUTH_URL?.trim() || process.env.BETTER_AUTH_BASE_URL?.trim() || - undefined - ); + undefined; const deploymentMode = preferTrustedLocal ? "local_trusted" - : (parseEnumFromEnv(process.env.TASKCORE_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"); + : (parseEnumFromEnv( + process.env.TASKCORE_DEPLOYMENT_MODE, + DEPLOYMENT_MODES, + ) ?? "local_trusted"); const deploymentExposureFromEnv = parseEnumFromEnv( process.env.TASKCORE_DEPLOYMENT_EXPOSURE, DEPLOYMENT_EXPOSURES, ); const deploymentExposure = - deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); - const bindFromEnv = parseEnumFromEnv(process.env.TASKCORE_BIND, BIND_MODES); - const customBindHostFromEnv = process.env.TASKCORE_BIND_HOST?.trim() || undefined; + deploymentMode === "local_trusted" + ? "private" + : (deploymentExposureFromEnv ?? "private"); + const bindFromEnv = parseEnumFromEnv( + process.env.TASKCORE_BIND, + BIND_MODES, + ); + const customBindHostFromEnv = + process.env.TASKCORE_BIND_HOST?.trim() || undefined; const hostFromEnv = process.env.HOST?.trim() || undefined; const configuredBindHost = customBindHostFromEnv ?? hostFromEnv; const bind = preferTrustedLocal ? "loopback" - : ( - deploymentMode === "local_trusted" - ? "loopback" - : (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan")) - ); + : deploymentMode === "local_trusted" + ? "loopback" + : (bindFromEnv ?? + (configuredBindHost + ? inferBindModeFromHost(configuredBindHost) + : "lan")); const resolvedBind = resolveRuntimeBind({ bind, host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"), @@ -177,29 +201,34 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { process.env.TASKCORE_AUTH_BASE_URL_MODE, AUTH_BASE_URL_MODES, ); - const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto"); + const authBaseUrlMode = + authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto"); const allowedHostnamesFromEnv = process.env.TASKCORE_ALLOWED_HOSTNAMES - ? process.env.TASKCORE_ALLOWED_HOSTNAMES - .split(",") - .map((value) => value.trim().toLowerCase()) - .filter((value) => value.length > 0) + ? process.env.TASKCORE_ALLOWED_HOSTNAMES.split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0) : []; const hostnameFromPublicUrl = publicUrl ? (() => { - try { - return new URL(publicUrl).hostname.trim().toLowerCase(); - } catch { - return null; - } - })() + try { + return new URL(publicUrl).hostname.trim().toLowerCase(); + } catch { + return null; + } + })() : null; const storageProvider = - parseEnumFromEnv(process.env.TASKCORE_STORAGE_PROVIDER, STORAGE_PROVIDERS) ?? - defaultStorage.provider; + parseEnumFromEnv( + process.env.TASKCORE_STORAGE_PROVIDER, + STORAGE_PROVIDERS, + ) ?? defaultStorage.provider; const secretsProvider = - parseEnumFromEnv(process.env.TASKCORE_SECRETS_PROVIDER, SECRET_PROVIDERS) ?? - defaultSecrets.provider; - const databaseBackupEnabled = parseBooleanFromEnv(process.env.TASKCORE_DB_BACKUP_ENABLED) ?? true; + parseEnumFromEnv( + process.env.TASKCORE_SECRETS_PROVIDER, + SECRET_PROVIDERS, + ) ?? defaultSecrets.provider; + const databaseBackupEnabled = + parseBooleanFromEnv(process.env.TASKCORE_DB_BACKUP_ENABLED) ?? true; const databaseBackupIntervalMinutes = Math.max( 1, parseNumberFromEnv(process.env.TASKCORE_DB_BACKUP_INTERVAL_MINUTES) ?? 60, @@ -218,7 +247,9 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { enabled: databaseBackupEnabled, intervalMinutes: databaseBackupIntervalMinutes, retentionDays: databaseBackupRetentionDays, - dir: resolvePathFromEnv(process.env.TASKCORE_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId), + dir: + resolvePathFromEnv(process.env.TASKCORE_DB_BACKUP_DIR) ?? + resolveDefaultBackupDir(instanceId), }, }, logging: { @@ -229,10 +260,17 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { deploymentMode, exposure: deploymentExposure, bind: resolvedBind.bind, - ...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}), + ...(resolvedBind.customBindHost + ? { customBindHost: resolvedBind.customBindHost } + : {}), host: resolvedBind.host, port: Number(process.env.PORT) || 3100, - allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])), + allowedHostnames: Array.from( + new Set([ + ...allowedHostnamesFromEnv, + ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : []), + ]), + ), serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true, }, auth: { @@ -244,21 +282,30 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { provider: storageProvider, localDisk: { baseDir: - resolvePathFromEnv(process.env.TASKCORE_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir, + resolvePathFromEnv(process.env.TASKCORE_STORAGE_LOCAL_DIR) ?? + defaultStorage.localDisk.baseDir, }, s3: { - bucket: process.env.TASKCORE_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket, - region: process.env.TASKCORE_STORAGE_S3_REGION ?? defaultStorage.s3.region, - endpoint: process.env.TASKCORE_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint, - prefix: process.env.TASKCORE_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix, + bucket: + process.env.TASKCORE_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket, + region: + process.env.TASKCORE_STORAGE_S3_REGION ?? defaultStorage.s3.region, + endpoint: + process.env.TASKCORE_STORAGE_S3_ENDPOINT ?? + defaultStorage.s3.endpoint, + prefix: + process.env.TASKCORE_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix, forcePathStyle: - parseBooleanFromEnv(process.env.TASKCORE_STORAGE_S3_FORCE_PATH_STYLE) ?? - defaultStorage.s3.forcePathStyle, + parseBooleanFromEnv( + process.env.TASKCORE_STORAGE_S3_FORCE_PATH_STYLE, + ) ?? defaultStorage.s3.forcePathStyle, }, }, secrets: { provider: secretsProvider, - strictMode: parseBooleanFromEnv(process.env.TASKCORE_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode, + strictMode: + parseBooleanFromEnv(process.env.TASKCORE_SECRETS_STRICT_MODE) ?? + defaultSecrets.strictMode, localEncrypted: { keyFilePath: resolvePathFromEnv(process.env.TASKCORE_SECRETS_MASTER_KEY_FILE) ?? @@ -268,7 +315,8 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { }; const ignoredEnvKeys: Array<{ key: string; reason: string }> = []; if (preferTrustedLocal) { - const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults"; + const forcedLocalReason = + "Ignored because --yes quickstart forces trusted local loopback defaults"; for (const key of [ "TASKCORE_DEPLOYMENT_MODE", "TASKCORE_DEPLOYMENT_EXPOSURE", @@ -286,28 +334,41 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { } } } - if (deploymentMode === "local_trusted" && process.env.TASKCORE_DEPLOYMENT_EXPOSURE !== undefined) { + if ( + deploymentMode === "local_trusted" && + process.env.TASKCORE_DEPLOYMENT_EXPOSURE !== undefined + ) { ignoredEnvKeys.push({ key: "TASKCORE_DEPLOYMENT_EXPOSURE", - reason: "Ignored because deployment mode local_trusted always forces private exposure", + reason: + "Ignored because deployment mode local_trusted always forces private exposure", }); } - if (deploymentMode === "local_trusted" && process.env.TASKCORE_BIND !== undefined) { + if ( + deploymentMode === "local_trusted" && + process.env.TASKCORE_BIND !== undefined + ) { ignoredEnvKeys.push({ key: "TASKCORE_BIND", - reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + reason: + "Ignored because deployment mode local_trusted always uses loopback reachability", }); } - if (deploymentMode === "local_trusted" && process.env.TASKCORE_BIND_HOST !== undefined) { + if ( + deploymentMode === "local_trusted" && + process.env.TASKCORE_BIND_HOST !== undefined + ) { ignoredEnvKeys.push({ key: "TASKCORE_BIND_HOST", - reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + reason: + "Ignored because deployment mode local_trusted always uses loopback reachability", }); } if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) { ignoredEnvKeys.push({ key: "HOST", - reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + reason: + "Ignored because deployment mode local_trusted always uses loopback reachability", }); } @@ -318,13 +379,20 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { return { defaults, usedEnvKeys, ignoredEnvKeys }; } -function canCreateBootstrapInviteImmediately(config: Pick): boolean { - return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres"; +function canCreateBootstrapInviteImmediately( + config: Pick, +): boolean { + return ( + config.server.deploymentMode === "authenticated" && + config.database.mode !== "embedded-postgres" + ); } export async function onboard(opts: OnboardOptions): Promise { if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) { - throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`); + throw new Error( + `Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`, + ); } printTaskcoreCliBanner(); @@ -354,32 +422,50 @@ export async function onboard(opts: OnboardOptions): Promise { if (existingConfig) { p.log.message( - pc.dim("Existing Taskcore install detected; keeping the current configuration unchanged."), + pc.dim( + "Existing Taskcore install detected; keeping the current configuration unchanged.", + ), + ); + p.log.message( + pc.dim( + `Use ${pc.cyan("taskcore configure")} if you want to change settings.`, + ), ); - p.log.message(pc.dim(`Use ${pc.cyan("taskcore configure")} if you want to change settings.`)); const jwtSecret = ensureAgentJwtSecret(configPath); const envFilePath = resolveAgentJwtEnvFile(configPath); if (jwtSecret.created) { - p.log.success(`Created ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + p.log.success( + `Created ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`, + ); } else if (process.env.TASKCORE_AGENT_JWT_SECRET?.trim()) { - p.log.info(`Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} from environment`); + p.log.info( + `Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} from environment`, + ); } else { - p.log.info(`Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + p.log.info( + `Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`, + ); } const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath); if (keyResult.status === "created") { - p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + p.log.success( + `Created local secrets key file at ${pc.dim(keyResult.path)}`, + ); } else if (keyResult.status === "existing") { - p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + p.log.message( + pc.dim(`Using existing local secrets key file at ${keyResult.path}`), + ); } p.note( [ "Existing config preserved", `Database: ${existingConfig.database.mode}`, - existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured", + existingConfig.llm + ? `LLM: ${existingConfig.llm.provider}` + : "LLM: not configured", `Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`, `Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`, `Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`, @@ -401,7 +487,12 @@ export async function onboard(opts: OnboardOptions): Promise { ); let shouldRunNow = opts.run === true || opts.yes === true; - if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + if ( + !shouldRunNow && + !opts.invokedByRun && + process.stdin.isTTY && + process.stdout.isTTY + ) { const answer = await p.confirm({ message: "Start Taskcore now?", initialValue: true, @@ -459,19 +550,20 @@ export async function onboard(opts: OnboardOptions): Promise { if (tc) trackInstallStarted(tc); let llm: TaskcoreConfig["llm"] | undefined; - const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({ + const { + defaults: derivedDefaults, + usedEnvKeys, + ignoredEnvKeys, + } = quickstartDefaultsFromEnv({ preferTrustedLocal: opts.yes === true && !opts.bind, }); - let { - database, - logging, - server, - auth, - storage, - secrets, - } = derivedDefaults; + let { database, logging, server, auth, storage, secrets } = derivedDefaults; - if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") { + if ( + opts.bind === "loopback" || + opts.bind === "lan" || + opts.bind === "tailnet" + ) { const preset = buildPresetServerConfig(opts.bind, { port: server.port, allowedHostnames: server.allowedHostnames, @@ -497,7 +589,11 @@ export async function onboard(opts: OnboardOptions): Promise { await db.execute("SELECT 1"); s.stop("Database connection successful"); } catch { - s.stop(pc.yellow("Could not connect to database — you can fix this later with `taskcore doctor`")); + s.stop( + pc.yellow( + "Could not connect to database — you can fix this later with `taskcore doctor`", + ), + ); } } @@ -525,7 +621,9 @@ export async function onboard(opts: OnboardOptions): Promise { if (res.ok || res.status === 400) { s.stop("API key is valid"); } else if (res.status === 401) { - s.stop(pc.yellow("API key appears invalid — you can update it later")); + s.stop( + pc.yellow("API key appears invalid — you can update it later"), + ); } else { s.stop(pc.yellow("Could not validate API key — continuing anyway")); } @@ -536,7 +634,9 @@ export async function onboard(opts: OnboardOptions): Promise { if (res.ok) { s.stop("API key is valid"); } else if (res.status === 401) { - s.stop(pc.yellow("API key appears invalid — you can update it later")); + s.stop( + pc.yellow("API key appears invalid — you can update it later"), + ); } else { s.stop(pc.yellow("Could not validate API key — continuing anyway")); } @@ -550,7 +650,10 @@ export async function onboard(opts: OnboardOptions): Promise { logging = await promptLogging(); p.log.step(pc.bold("Server")); - ({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth })); + ({ server, auth } = await promptServer({ + currentServer: server, + currentAuth: auth, + })); p.log.step(pc.bold("Storage")); storage = await promptStorage(storage); @@ -561,7 +664,9 @@ export async function onboard(opts: OnboardOptions): Promise { provider: secrets.provider ?? secretsDefaults.provider, strictMode: secrets.strictMode ?? secretsDefaults.strictMode, localEncrypted: { - keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath, + keyFilePath: + secrets.localEncrypted?.keyFilePath ?? + secretsDefaults.localEncrypted.keyFilePath, }, }; p.log.message( @@ -579,10 +684,16 @@ export async function onboard(opts: OnboardOptions): Promise { ), ); if (usedEnvKeys.length > 0) { - p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`)); + p.log.message( + pc.dim( + `Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`, + ), + ); } else { p.log.message( - pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."), + pc.dim( + "No environment overrides detected: embedded database, file storage, local encrypted secrets.", + ), ); } for (const ignored of ignoredEnvKeys) { @@ -593,11 +704,17 @@ export async function onboard(opts: OnboardOptions): Promise { const jwtSecret = ensureAgentJwtSecret(configPath); const envFilePath = resolveAgentJwtEnvFile(configPath); if (jwtSecret.created) { - p.log.success(`Created ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + p.log.success( + `Created ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`, + ); } else if (process.env.TASKCORE_AGENT_JWT_SECRET?.trim()) { - p.log.info(`Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} from environment`); + p.log.info( + `Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} from environment`, + ); } else { - p.log.info(`Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + p.log.info( + `Using existing ${pc.cyan("TASKCORE_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`, + ); } const config: TaskcoreConfig = { @@ -620,16 +737,21 @@ export async function onboard(opts: OnboardOptions): Promise { const keyResult = ensureLocalSecretsKeyFile(config, configPath); if (keyResult.status === "created") { - p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + p.log.success( + `Created local secrets key file at ${pc.dim(keyResult.path)}`, + ); } else if (keyResult.status === "existing") { - p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + p.log.message( + pc.dim(`Using existing local secrets key file at ${keyResult.path}`), + ); } writeConfig(config, opts.config); - if (tc) trackInstallCompleted(tc, { - adapterType: server.deploymentMode, - }); + if (tc) + trackInstallCompleted(tc, { + adapterType: server.deploymentMode, + }); p.note( [ @@ -661,7 +783,12 @@ export async function onboard(opts: OnboardOptions): Promise { } let shouldRunNow = opts.run === true || opts.yes === true; - if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + if ( + !shouldRunNow && + !opts.invokedByRun && + process.stdin.isTTY && + process.stdout.isTTY + ) { const answer = await p.confirm({ message: "Start Taskcore now?", initialValue: true, @@ -678,7 +805,10 @@ export async function onboard(opts: OnboardOptions): Promise { return; } - if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") { + if ( + server.deploymentMode === "authenticated" && + database.mode === "embedded-postgres" + ) { p.log.info( [ "Bootstrap CEO invite will be created after the server starts.", diff --git a/cli/src/commands/routines.ts b/cli/src/commands/routines.ts index f6f6eb9..7126e39 100644 --- a/cli/src/commands/routines.ts +++ b/cli/src/commands/routines.ts @@ -60,7 +60,9 @@ type ClosableDb = ReturnType & { }; function nonEmpty(value: string | null | undefined): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } async function isPortAvailable(port: number): Promise { @@ -96,7 +98,9 @@ function readPidFilePort(postmasterPidFile: string): number | null { function readRunningPostmasterPid(postmasterPidFile: string): number | null { if (!fs.existsSync(postmasterPidFile)) return null; try { - const pid = Number(fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + const pid = Number( + fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(), + ); if (!Number.isInteger(pid) || pid <= 0) return null; process.kill(pid, 0); return pid; @@ -105,7 +109,10 @@ function readRunningPostmasterPid(postmasterPidFile: string): number | null { } } -async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { +async function ensureEmbeddedPostgres( + dataDir: string, + preferredPort: number, +): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { @@ -123,7 +130,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P return { port: readPidFilePort(postmasterPidFile) ?? preferredPort, startedByThisProcess: false, - stop: async () => { }, + stop: async () => {}, }; } @@ -211,7 +218,9 @@ async function openConfiguredDb(configPath: string): Promise<{ const connectionString = nonEmpty(config.database.connectionString); if (!connectionString) { - throw new Error(`Config at ${configPath} does not define a database connection string.`); + throw new Error( + `Config at ${configPath} does not define a database connection string.`, + ); } await applyPendingMigrations(connectionString); @@ -236,11 +245,13 @@ export async function disableAllRoutinesInConfig( const configPath = resolveConfigPath(options.config); loadTaskcoreEnvFile(configPath); const companyId = - nonEmpty(options.companyId) - ?? nonEmpty(process.env.TASKCORE_COMPANY_ID) - ?? null; + nonEmpty(options.companyId) ?? + nonEmpty(process.env.TASKCORE_COMPANY_ID) ?? + null; if (!companyId) { - throw new Error("Company ID is required. Pass --company-id or set TASKCORE_COMPANY_ID."); + throw new Error( + "Company ID is required. Pass --company-id or set TASKCORE_COMPANY_ID.", + ); } const config = readConfig(configPath); @@ -264,7 +275,9 @@ export async function disableAllRoutinesInConfig( } else { const connectionString = nonEmpty(config.database.connectionString); if (!connectionString) { - throw new Error(`Config at ${configPath} does not define a database connection string.`); + throw new Error( + `Config at ${configPath} does not define a database connection string.`, + ); } await applyPendingMigrations(connectionString); db = createDb(connectionString) as ClosableDb; @@ -278,10 +291,17 @@ export async function disableAllRoutinesInConfig( .from(routines) .where(eq(routines.companyId, companyId)); - const alreadyPausedCount = existing.filter((routine) => routine.status === "paused").length; - const archivedCount = existing.filter((routine) => routine.status === "archived").length; + const alreadyPausedCount = existing.filter( + (routine) => routine.status === "paused", + ).length; + const archivedCount = existing.filter( + (routine) => routine.status === "archived", + ).length; const idsToPause = existing - .filter((routine) => routine.status !== "paused" && routine.status !== "archived") + .filter( + (routine) => + routine.status !== "paused" && routine.status !== "archived", + ) .map((routine) => routine.id); if (idsToPause.length > 0) { @@ -311,7 +331,9 @@ export async function disableAllRoutinesInConfig( } } -export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptions): Promise { +export async function disableAllRoutinesCommand( + options: RoutinesDisableAllOptions, +): Promise { const result = await disableAllRoutinesInConfig(options); if (options.json) { @@ -326,18 +348,25 @@ export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptio console.log( `Paused ${result.pausedCount} routine(s) for company ${result.companyId} ` + - `(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`, + `(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`, ); } export function registerRoutineCommands(program: Command): void { - const routinesCommand = program.command("routines").description("Local routine maintenance commands"); + const routinesCommand = program + .command("routines") + .description("Local routine maintenance commands"); routinesCommand .command("disable-all") - .description("Pause all non-archived routines in the configured local instance for one company") + .description( + "Pause all non-archived routines in the configured local instance for one company", + ) .option("-c, --config ", "Path to config file") - .option("-d, --data-dir ", "Taskcore data directory root (isolates state from ~/.taskcore)") + .option( + "-d, --data-dir ", + "Taskcore data directory root (isolates state from ~/.taskcore)", + ) .option("-C, --company-id ", "Company ID") .option("--json", "Output raw JSON") .action(async (opts: RoutinesDisableAllOptions) => { diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index c3a9388..66f2fdf 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -54,7 +54,9 @@ export async function runCommand(opts: RunOptions): Promise { if (!configExists(configPath)) { if (!process.stdin.isTTY || !process.stdout.isTTY) { p.log.error("No config found and terminal is non-interactive."); - p.log.message(`Run ${pc.cyan("taskcore onboard")} once, then retry ${pc.cyan("taskcore run")}.`); + p.log.message( + `Run ${pc.cyan("taskcore onboard")} once, then retry ${pc.cyan("taskcore run")}.`, + ); process.exit(1); } @@ -102,9 +104,14 @@ function resolveBootstrapInviteBaseUrl( process.env.TASKCORE_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL ?? process.env.BETTER_AUTH_BASE_URL ?? - (config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined); - - if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) { + (config.auth.baseUrlMode === "explicit" + ? config.auth.publicBaseUrl + : undefined); + + if ( + typeof explicitBaseUrl === "string" && + explicitBaseUrl.trim().length > 0 + ) { return explicitBaseUrl.trim().replace(/\/+$/, ""); } @@ -133,7 +140,9 @@ function isModuleNotFoundError(err: unknown): boolean { function getMissingModuleSpecifier(err: unknown): string | null { if (!(err instanceof Error)) return null; - const packageMatch = err.message.match(/Cannot find package '([^']+)' imported from/); + const packageMatch = err.message.match( + /Cannot find package '([^']+)' imported from/, + ); if (packageMatch?.[1]) return packageMatch[1]; const moduleMatch = err.message.match(/Cannot find module '([^']+)'/); if (moduleMatch?.[1]) return moduleMatch[1]; @@ -143,13 +152,19 @@ function getMissingModuleSpecifier(err: unknown): string | null { function maybeEnableUiDevMiddleware(entrypoint: string): void { if (process.env.TASKCORE_UI_DEV_MIDDLEWARE !== undefined) return; const normalized = entrypoint.replaceAll("\\", "/"); - if (normalized.endsWith("/server/src/index.ts") || normalized.endsWith("@taskcore/server/src/index.ts")) { + if ( + normalized.endsWith("/server/src/index.ts") || + normalized.endsWith("@taskcore/server/src/index.ts") + ) { process.env.TASKCORE_UI_DEV_MIDDLEWARE = "true"; } } function ensureDevWorkspaceBuildDeps(projectRoot: string): void { - const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs"); + const buildScript = path.resolve( + projectRoot, + "scripts/ensure-plugin-build-deps.mjs", + ); if (!fs.existsSync(buildScript)) return; const result = spawnSync(process.execPath, [buildScript], { @@ -173,7 +188,10 @@ function ensureDevWorkspaceBuildDeps(projectRoot: string): void { async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) - const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", + ); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { ensureDevWorkspaceBuildDeps(projectRoot); @@ -188,29 +206,40 @@ async function importServerEntry(): Promise { return await startServerFromModule(mod, "@taskcore/server"); } catch (err) { const missingSpecifier = getMissingModuleSpecifier(err); - const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@taskcore/server"; + const missingServerEntrypoint = + !missingSpecifier || missingSpecifier === "@taskcore/server"; if (isModuleNotFoundError(err) && missingServerEntrypoint) { throw new Error( `Could not locate a Taskcore server entrypoint.\n` + - `Tried: ${devEntry}, @taskcore/server\n` + - `${formatError(err)}`, + `Tried: ${devEntry}, @taskcore/server\n` + + `${formatError(err)}`, ); } throw new Error( - `Taskcore server failed to start.\n` + - `${formatError(err)}`, + `Taskcore server failed to start.\n` + `${formatError(err)}`, ); } } -function shouldGenerateBootstrapInviteAfterStart(config: TaskcoreConfig): boolean { - return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres"; +function shouldGenerateBootstrapInviteAfterStart( + config: TaskcoreConfig, +): boolean { + return ( + config.server.deploymentMode === "authenticated" && + config.database.mode === "embedded-postgres" + ); } -async function startServerFromModule(mod: unknown, label: string): Promise { - const startServer = (mod as { startServer?: () => Promise }).startServer; +async function startServerFromModule( + mod: unknown, + label: string, +): Promise { + const startServer = (mod as { startServer?: () => Promise }) + .startServer; if (typeof startServer !== "function") { - throw new Error(`Taskcore server entrypoint did not export startServer(): ${label}`); + throw new Error( + `Taskcore server entrypoint did not export startServer(): ${label}`, + ); } return await startServer(); } diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index cba2b8d..361a379 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -54,7 +54,9 @@ export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } -export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan { +export function resolveWorktreeSeedPlan( + mode: WorktreeSeedMode, +): WorktreeSeedPlan { if (mode === "full") { return { mode, @@ -72,7 +74,9 @@ export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPla } function nonEmpty(value: string | null | undefined): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } function isLoopbackHost(hostname: string): boolean { @@ -89,7 +93,10 @@ export function sanitizeWorktreeInstanceId(rawValue: string): string { return normalized || "worktree"; } -export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string { +export function resolveSuggestedWorktreeName( + cwd: string, + explicitName?: string, +): string { return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); } @@ -102,10 +109,10 @@ function hslComponentToHex(n: number): string { function hslToHex(hue: number, saturation: number, lightness: number): string { const s = Math.max(0, Math.min(100, saturation)) / 100; const l = Math.max(0, Math.min(100, lightness)) / 100; - const c = (1 - Math.abs((2 * l) - 1)) * s; + const c = (1 - Math.abs(2 * l - 1)) * s; const h = ((hue % 360) + 360) % 360; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = l - (c / 2); + const m = l - c / 2; let r = 0; let g = 0; @@ -144,7 +151,9 @@ export function resolveWorktreeLocalPaths(opts: { instanceId: string; }): WorktreeLocalPaths { const cwd = path.resolve(opts.cwd); - const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME)); + const homeDir = path.resolve( + expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME), + ); const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId); const repoConfigDir = path.resolve(cwd, ".taskcore"); return { @@ -164,7 +173,10 @@ export function resolveWorktreeLocalPaths(opts: { }; } -export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { +export function rewriteLocalUrlPort( + rawUrl: string | undefined, + port: number, +): string | undefined { if (!rawUrl) return undefined; try { const parsed = new URL(rawUrl); @@ -187,7 +199,10 @@ export function buildWorktreeConfig(input: { const nowIso = (input.now ?? new Date()).toISOString(); const source = sourceConfig; - const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort); + const authPublicBaseUrl = rewriteLocalUrlPort( + source?.auth.publicBaseUrl, + serverPort, + ); return { $meta: { @@ -215,7 +230,9 @@ export function buildWorktreeConfig(input: { deploymentMode: source?.server.deploymentMode ?? "local_trusted", exposure: source?.server.exposure ?? "private", ...(source?.server.bind ? { bind: source.server.bind } : {}), - ...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}), + ...(source?.server.customBindHost + ? { customBindHost: source.server.customBindHost } + : {}), host: source?.server.host ?? "127.0.0.1", port: serverPort, allowedHostnames: source?.server.allowedHostnames ?? [], diff --git a/cli/src/commands/worktree-merge-history-lib.ts b/cli/src/commands/worktree-merge-history-lib.ts index 532f53a..685ab3c 100644 --- a/cli/src/commands/worktree-merge-history-lib.ts +++ b/cli/src/commands/worktree-merge-history-lib.ts @@ -37,7 +37,10 @@ export type ImportAdjustment = | "clear_attachment_agent"; export type IssueMergeAction = "skip_existing" | "insert"; -export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert"; +export type CommentMergeAction = + | "skip_existing" + | "skip_missing_parent" + | "insert"; export type PlannedIssueInsert = { source: IssueRow; @@ -189,7 +192,11 @@ export type WorktreeMergePlan = { projectImports: PlannedProjectImport[]; issuePlans: Array; commentPlans: Array; - documentPlans: Array; + documentPlans: Array< + | PlannedIssueDocumentInsert + | PlannedIssueDocumentMerge + | PlannedIssueDocumentSkip + >; attachmentPlans: Array; counts: { projectsToImport: number; @@ -215,15 +222,24 @@ export type WorktreeMergePlan = { function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] { const driftKeys: string[] = []; if (source.title !== target.title) driftKeys.push("title"); - if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description"); + if ((source.description ?? null) !== (target.description ?? null)) + driftKeys.push("description"); if (source.status !== target.status) driftKeys.push("status"); if (source.priority !== target.priority) driftKeys.push("priority"); - if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId"); - if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId"); - if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId"); - if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId"); - if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId"); - if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId"); + if ((source.parentId ?? null) !== (target.parentId ?? null)) + driftKeys.push("parentId"); + if ((source.projectId ?? null) !== (target.projectId ?? null)) + driftKeys.push("projectId"); + if ( + (source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null) + ) + driftKeys.push("projectWorkspaceId"); + if ((source.goalId ?? null) !== (target.goalId ?? null)) + driftKeys.push("goalId"); + if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) + driftKeys.push("assigneeAgentId"); + if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) + driftKeys.push("assigneeUserId"); return driftKeys; } @@ -254,15 +270,19 @@ function sameDate(left: Date, right: Date): boolean { function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] { return [...rows].sort((left, right) => { - const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime(); + const createdDelta = + left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime(); if (createdDelta !== 0) return createdDelta; - const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime(); + const linkDelta = + left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime(); if (linkDelta !== 0) return linkDelta; return left.documentId.localeCompare(right.documentId); }); } -function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] { +function sortDocumentRevisions( + rows: DocumentRevisionRow[], +): DocumentRevisionRow[] { return [...rows].sort((left, right) => { const revisionDelta = left.revisionNumber - right.revisionNumber; if (revisionDelta !== 0) return revisionDelta; @@ -274,7 +294,8 @@ function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] { return [...rows].sort((left, right) => { - const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime(); + const createdDelta = + left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime(); if (createdDelta !== 0) return createdDelta; return left.id.localeCompare(right.id); }); @@ -316,7 +337,9 @@ function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] { }); } -export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] { +export function parseWorktreeMergeScopes( + rawValue: string | undefined, +): WorktreeMergeScope[] { if (!rawValue || rawValue.trim().length === 0) { return ["issues", "comments"]; } @@ -362,16 +385,31 @@ export function buildWorktreeMergePlan(input: { importProjectIds?: Iterable; projectIdOverrides?: Record; }): WorktreeMergePlan { - const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue])); - const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id)); + const targetIssuesById = new Map( + input.targetIssues.map((issue) => [issue.id, issue]), + ); + const targetCommentIds = new Set( + input.targetComments.map((comment) => comment.id), + ); const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id)); - const targetProjectIds = new Set(input.targetProjects.map((project) => project.id)); - const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project])); - const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id)); + const targetProjectIds = new Set( + input.targetProjects.map((project) => project.id), + ); + const targetProjectsById = new Map( + input.targetProjects.map((project) => [project.id, project]), + ); + const targetProjectWorkspaceIds = new Set( + input.targetProjectWorkspaces.map((workspace) => workspace.id), + ); const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id)); - const sourceProjectsById = new Map((input.sourceProjects ?? []).map((project) => [project.id, project])); + const sourceProjectsById = new Map( + (input.sourceProjects ?? []).map((project) => [project.id, project]), + ); const sourceProjectWorkspaces = input.sourceProjectWorkspaces ?? []; - const sourceProjectWorkspacesByProjectId = groupBy(sourceProjectWorkspaces, (workspace) => workspace.projectId); + const sourceProjectWorkspacesByProjectId = groupBy( + sourceProjectWorkspaces, + (workspace) => workspace.projectId, + ); const importProjectIds = new Set(input.importProjectIds ?? []); const scopes = new Set(input.scopes); @@ -395,24 +433,30 @@ export function buildWorktreeMergePlan(input: { projectImports.push({ source: sourceProject, targetLeadAgentId: - sourceProject.leadAgentId && targetAgentIds.has(sourceProject.leadAgentId) + sourceProject.leadAgentId && + targetAgentIds.has(sourceProject.leadAgentId) ? sourceProject.leadAgentId : null, targetGoalId: sourceProject.goalId && targetGoalIds.has(sourceProject.goalId) ? sourceProject.goalId : null, - workspaces: [...(sourceProjectWorkspacesByProjectId.get(projectId) ?? [])].sort((left, right) => { + workspaces: [ + ...(sourceProjectWorkspacesByProjectId.get(projectId) ?? []), + ].sort((left, right) => { const primaryDelta = Number(right.isPrimary) - Number(left.isPrimary); if (primaryDelta !== 0) return primaryDelta; - const createdDelta = left.createdAt.getTime() - right.createdAt.getTime(); + const createdDelta = + left.createdAt.getTime() - right.createdAt.getTime(); if (createdDelta !== 0) return createdDelta; return left.id.localeCompare(right.id); }), }); } const importedProjectWorkspaceIds = new Set( - projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)), + projectImports.flatMap((project) => + project.workspaces.map((workspace) => workspace.id), + ), ); const issuePlans: Array = []; @@ -431,29 +475,45 @@ export function buildWorktreeMergePlan(input: { nextPreviewIssueNumber += 1; const adjustments: ImportAdjustment[] = []; const targetAssigneeAgentId = - issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null; + issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) + ? issue.assigneeAgentId + : null; if (issue.assigneeAgentId && !targetAssigneeAgentId) { adjustments.push("clear_assignee_agent"); incrementAdjustment(adjustmentCounts, "clear_assignee_agent"); } const targetCreatedByAgentId = - issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null; + issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) + ? issue.createdByAgentId + : null; let targetProjectId = - issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null; - let projectResolution: PlannedIssueInsert["projectResolution"] = targetProjectId ? "preserved" : "cleared"; + issue.projectId && targetProjectIds.has(issue.projectId) + ? issue.projectId + : null; + let projectResolution: PlannedIssueInsert["projectResolution"] = + targetProjectId ? "preserved" : "cleared"; let mappedProjectName: string | null = null; const overrideProjectId = issue.projectId && input.projectIdOverrides - ? input.projectIdOverrides[issue.projectId] ?? null + ? (input.projectIdOverrides[issue.projectId] ?? null) : null; - if (!targetProjectId && overrideProjectId && targetProjectIds.has(overrideProjectId)) { + if ( + !targetProjectId && + overrideProjectId && + targetProjectIds.has(overrideProjectId) + ) { targetProjectId = overrideProjectId; projectResolution = "mapped"; - mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null; + mappedProjectName = + targetProjectsById.get(overrideProjectId)?.name ?? null; } - if (!targetProjectId && issue.projectId && importProjectIds.has(issue.projectId)) { + if ( + !targetProjectId && + issue.projectId && + importProjectIds.has(issue.projectId) + ) { const sourceProject = sourceProjectsById.get(issue.projectId); if (sourceProject) { targetProjectId = sourceProject.id; @@ -467,11 +527,11 @@ export function buildWorktreeMergePlan(input: { } const targetProjectWorkspaceId = - targetProjectId - && targetProjectId === issue.projectId - && issue.projectWorkspaceId - && (targetProjectWorkspaceIds.has(issue.projectWorkspaceId) - || importedProjectWorkspaceIds.has(issue.projectWorkspaceId)) + targetProjectId && + targetProjectId === issue.projectId && + issue.projectWorkspaceId && + (targetProjectWorkspaceIds.has(issue.projectWorkspaceId) || + importedProjectWorkspaceIds.has(issue.projectWorkspaceId)) ? issue.projectWorkspaceId : null; if (issue.projectWorkspaceId && !targetProjectWorkspaceId) { @@ -488,9 +548,9 @@ export function buildWorktreeMergePlan(input: { let targetStatus = issue.status; if ( - targetStatus === "in_progress" - && !targetAssigneeAgentId - && !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0) + targetStatus === "in_progress" && + !targetAssigneeAgentId && + !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0) ) { targetStatus = "todo"; adjustments.push("coerce_in_progress_to_todo"); @@ -516,7 +576,9 @@ export function buildWorktreeMergePlan(input: { const issueIdsAvailableAfterImport = new Set([ ...input.targetIssues.map((issue) => issue.id), - ...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id), + ...issuePlans + .filter((plan): plan is PlannedIssueInsert => plan.action === "insert") + .map((plan) => plan.source.id), ]); const commentPlans: Array = []; @@ -539,7 +601,9 @@ export function buildWorktreeMergePlan(input: { const adjustments: ImportAdjustment[] = []; const targetAuthorAgentId = - comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null; + comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) + ? comment.authorAgentId + : null; if (comment.authorAgentId && !targetAuthorAgentId) { adjustments.push("clear_author_agent"); incrementAdjustment(adjustmentCounts, "clear_author_agent"); @@ -559,16 +623,35 @@ export function buildWorktreeMergePlan(input: { const sourceDocumentRevisions = input.sourceDocumentRevisions ?? []; const targetDocumentRevisions = input.targetDocumentRevisions ?? []; - const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document])); - const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document])); - const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId); - const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId); + const targetDocumentsById = new Map( + targetDocuments.map((document) => [document.documentId, document]), + ); + const targetDocumentsByIssueKey = new Map( + targetDocuments.map((document) => [ + `${document.issueId}:${document.key}`, + document, + ]), + ); + const sourceRevisionsByDocumentId = groupBy( + sourceDocumentRevisions, + (revision) => revision.documentId, + ); + const targetRevisionsByDocumentId = groupBy( + targetDocumentRevisions, + (revision) => revision.documentId, + ); const commentIdsAvailableAfterImport = new Set([ ...input.targetComments.map((comment) => comment.id), - ...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id), + ...commentPlans + .filter((plan): plan is PlannedCommentInsert => plan.action === "insert") + .map((plan) => plan.source.id), ]); - const documentPlans: Array = []; + const documentPlans: Array< + | PlannedIssueDocumentInsert + | PlannedIssueDocumentMerge + | PlannedIssueDocumentSkip + > = []; for (const document of sortDocumentRows(sourceDocuments)) { if (!issueIdsAvailableAfterImport.has(document.issueId)) { documentPlans.push({ source: document, action: "skip_missing_parent" }); @@ -576,33 +659,52 @@ export function buildWorktreeMergePlan(input: { } const existingDocument = targetDocumentsById.get(document.documentId); - const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`); - if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) { + const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get( + `${document.issueId}:${document.key}`, + ); + if ( + !existingDocument && + conflictingIssueKeyDocument && + conflictingIssueKeyDocument.documentId !== document.documentId + ) { documentPlans.push({ source: document, action: "skip_conflicting_key" }); continue; } const adjustments: ImportAdjustment[] = []; const targetCreatedByAgentId = - document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null; + document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) + ? document.createdByAgentId + : null; const targetUpdatedByAgentId = - document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null; + document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) + ? document.updatedByAgentId + : null; if ( - (document.createdByAgentId && !targetCreatedByAgentId) - || (document.updatedByAgentId && !targetUpdatedByAgentId) + (document.createdByAgentId && !targetCreatedByAgentId) || + (document.updatedByAgentId && !targetUpdatedByAgentId) ) { adjustments.push("clear_document_agent"); incrementAdjustment(adjustmentCounts, "clear_document_agent"); } - const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []); - const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []); - const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id)); - const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber)); - let nextRevisionNumber = targetRevisions.reduce( - (maxValue, revision) => Math.max(maxValue, revision.revisionNumber), - 0, - ) + 1; + const sourceRevisions = sortDocumentRevisions( + sourceRevisionsByDocumentId.get(document.documentId) ?? [], + ); + const targetRevisions = sortDocumentRevisions( + targetRevisionsByDocumentId.get(document.documentId) ?? [], + ); + const existingRevisionIds = new Set( + targetRevisions.map((revision) => revision.id), + ); + const usedRevisionNumbers = new Set( + targetRevisions.map((revision) => revision.revisionNumber), + ); + let nextRevisionNumber = + targetRevisions.reduce( + (maxValue, revision) => Math.max(maxValue, revision.revisionNumber), + 0, + ) + 1; const targetRevisionNumberById = new Map( targetRevisions.map((revision) => [revision.id, revision.revisionNumber]), @@ -624,7 +726,10 @@ export function buildWorktreeMergePlan(input: { const revisionAdjustments: ImportAdjustment[] = []; const targetCreatedByAgentId = - revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null; + revision.createdByAgentId && + targetAgentIds.has(revision.createdByAgentId) + ? revision.createdByAgentId + : null; if (revision.createdByAgentId && !targetCreatedByAgentId) { revisionAdjustments.push("clear_document_revision_agent"); incrementAdjustment(adjustmentCounts, "clear_document_revision_agent"); @@ -638,12 +743,15 @@ export function buildWorktreeMergePlan(input: { }); } - const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null; + const latestRevisionId = + document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null; const latestRevisionNumber = - (latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined) - ?? document.latestRevisionNumber - ?? existingDocument?.latestRevisionNumber - ?? 0; + (latestRevisionId + ? targetRevisionNumberById.get(latestRevisionId) + : undefined) ?? + document.latestRevisionNumber ?? + existingDocument?.latestRevisionNumber ?? + 0; if (!existingDocument) { documentPlans.push({ @@ -660,17 +768,21 @@ export function buildWorktreeMergePlan(input: { } const documentAlreadyMatches = - existingDocument.key === document.key - && existingDocument.title === document.title - && existingDocument.format === document.format - && existingDocument.latestBody === document.latestBody - && (existingDocument.latestRevisionId ?? null) === latestRevisionId - && existingDocument.latestRevisionNumber === latestRevisionNumber - && (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId - && (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null) - && sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt) - && sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt) - && revisionsToInsert.length === 0; + existingDocument.key === document.key && + existingDocument.title === document.title && + existingDocument.format === document.format && + existingDocument.latestBody === document.latestBody && + (existingDocument.latestRevisionId ?? null) === latestRevisionId && + existingDocument.latestRevisionNumber === latestRevisionNumber && + (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId && + (existingDocument.updatedByUserId ?? null) === + (document.updatedByUserId ?? null) && + sameDate( + existingDocument.documentUpdatedAt, + document.documentUpdatedAt, + ) && + sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt) && + revisionsToInsert.length === 0; if (documentAlreadyMatches) { documentPlans.push({ source: document, action: "skip_existing" }); @@ -690,21 +802,29 @@ export function buildWorktreeMergePlan(input: { } const sourceAttachments = input.sourceAttachments ?? []; - const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id)); - const attachmentPlans: Array = []; + const targetAttachmentIds = new Set( + (input.targetAttachments ?? []).map((attachment) => attachment.id), + ); + const attachmentPlans: Array< + PlannedAttachmentInsert | PlannedAttachmentSkip + > = []; for (const attachment of sortAttachments(sourceAttachments)) { if (targetAttachmentIds.has(attachment.id)) { attachmentPlans.push({ source: attachment, action: "skip_existing" }); continue; } if (!issueIdsAvailableAfterImport.has(attachment.issueId)) { - attachmentPlans.push({ source: attachment, action: "skip_missing_parent" }); + attachmentPlans.push({ + source: attachment, + action: "skip_missing_parent", + }); continue; } const adjustments: ImportAdjustment[] = []; const targetCreatedByAgentId = - attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId) + attachment.createdByAgentId && + targetAgentIds.has(attachment.createdByAgentId) ? attachment.createdByAgentId : null; if (attachment.createdByAgentId && !targetCreatedByAgentId) { @@ -716,7 +836,8 @@ export function buildWorktreeMergePlan(input: { source: attachment, action: "insert", targetIssueCommentId: - attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId) + attachment.issueCommentId && + commentIdsAvailableAfterImport.has(attachment.issueCommentId) ? attachment.issueCommentId : null, targetCreatedByAgentId, @@ -726,25 +847,52 @@ export function buildWorktreeMergePlan(input: { const counts = { projectsToImport: projectImports.length, - issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length, - issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length, - issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length, - commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length, - commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length, - commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length, - documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length, - documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length, - documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length, - documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length, - documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length, + issuesToInsert: issuePlans.filter((plan) => plan.action === "insert") + .length, + issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing") + .length, + issueDrift: issuePlans.filter( + (plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0, + ).length, + commentsToInsert: commentPlans.filter((plan) => plan.action === "insert") + .length, + commentsExisting: commentPlans.filter( + (plan) => plan.action === "skip_existing", + ).length, + commentsMissingParent: commentPlans.filter( + (plan) => plan.action === "skip_missing_parent", + ).length, + documentsToInsert: documentPlans.filter((plan) => plan.action === "insert") + .length, + documentsToMerge: documentPlans.filter( + (plan) => plan.action === "merge_existing", + ).length, + documentsExisting: documentPlans.filter( + (plan) => plan.action === "skip_existing", + ).length, + documentsConflictingKey: documentPlans.filter( + (plan) => plan.action === "skip_conflicting_key", + ).length, + documentsMissingParent: documentPlans.filter( + (plan) => plan.action === "skip_missing_parent", + ).length, documentRevisionsToInsert: documentPlans.reduce( (sum, plan) => - sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0), + sum + + (plan.action === "insert" || plan.action === "merge_existing" + ? plan.revisionsToInsert.length + : 0), 0, ), - attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length, - attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length, - attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length, + attachmentsToInsert: attachmentPlans.filter( + (plan) => plan.action === "insert", + ).length, + attachmentsExisting: attachmentPlans.filter( + (plan) => plan.action === "skip_existing", + ).length, + attachmentsMissingParent: attachmentPlans.filter( + (plan) => plan.action === "skip_missing_parent", + ).length, }; return { diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index e6a3a2a..d533754 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -47,7 +47,13 @@ import { formatEmbeddedPostgresError, } from "@taskcore/db"; import type { Command } from "commander"; -import { ensureAgentJwtSecret, loadTaskcoreEnvFile, mergeTaskcoreEnvEntries, readTaskcoreEnvEntries, resolveTaskcoreEnvFile } from "../config/env.js"; +import { + ensureAgentJwtSecret, + loadTaskcoreEnvFile, + mergeTaskcoreEnvEntries, + readTaskcoreEnvEntries, + resolveTaskcoreEnvFile, +} from "../config/env.js"; import { expandHomePrefix } from "../config/home.js"; import type { TaskcoreConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; @@ -187,7 +193,9 @@ type SeedWorktreeDatabaseResult = { }; function nonEmpty(value: string | null | undefined): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { @@ -210,23 +218,37 @@ function resolveWorktreeMakeName(name: string): string { "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", ); } - return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`; + return value.startsWith(WORKTREE_NAME_PREFIX) + ? value + : `${WORKTREE_NAME_PREFIX}${value}`; } function resolveWorktreeHome(explicit?: string): string { - return explicit ?? process.env.TASKCORE_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME; + return ( + explicit ?? process.env.TASKCORE_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME + ); } function resolveWorktreeStartPoint(explicit?: string): string | undefined { - return explicit ?? nonEmpty(process.env.TASKCORE_WORKTREE_START_POINT) ?? undefined; + return ( + explicit ?? nonEmpty(process.env.TASKCORE_WORKTREE_START_POINT) ?? undefined + ); } type ConfiguredStorage = { getObject(companyId: string, objectKey: string): Promise; - putObject(companyId: string, objectKey: string, body: Buffer, contentType: string): Promise; + putObject( + companyId: string, + objectKey: string, + body: Buffer, + contentType: string, + ): Promise; }; -function assertStorageCompanyPrefix(companyId: string, objectKey: string): void { +function assertStorageCompanyPrefix( + companyId: string, + objectKey: string, +): void { if (!objectKey.startsWith(`${companyId}/`) || objectKey.includes("..")) { throw new Error(`Invalid object key for company ${companyId}.`); } @@ -238,7 +260,10 @@ function normalizeStorageObjectKey(objectKey: string): string { throw new Error("Invalid object key."); } const parts = normalized.split("/").filter((part) => part.length > 0); - if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) { + if ( + parts.length === 0 || + parts.some((part) => part === "." || part === "..") + ) { throw new Error("Invalid object key."); } return parts.join("/"); @@ -295,15 +320,22 @@ function buildS3ObjectKey(prefix: string, objectKey: string): string { return prefix ? `${prefix}/${objectKey}` : objectKey; } -const dynamicImport = new Function("specifier", "return import(specifier);") as (specifier: string) => Promise; +const dynamicImport = new Function( + "specifier", + "return import(specifier);", +) as (specifier: string) => Promise; -function createConfiguredStorageFromTaskcoreConfig(config: TaskcoreConfig): ConfiguredStorage { +function createConfiguredStorageFromTaskcoreConfig( + config: TaskcoreConfig, +): ConfiguredStorage { if (config.storage.provider === "local_disk") { const baseDir = expandHomePrefix(config.storage.localDisk.baseDir); return { async getObject(companyId: string, objectKey: string) { assertStorageCompanyPrefix(companyId, objectKey); - return await fsPromises.readFile(resolveLocalStoragePath(baseDir, objectKey)); + return await fsPromises.readFile( + resolveLocalStoragePath(baseDir, objectKey), + ); }, async putObject(companyId: string, objectKey: string, body: Buffer) { assertStorageCompanyPrefix(companyId, objectKey); @@ -345,7 +377,12 @@ function createConfiguredStorageFromTaskcoreConfig(config: TaskcoreConfig): Conf ); return await s3BodyToBuffer(response.Body); }, - async putObject(companyId: string, objectKey: string, body: Buffer, contentType: string) { + async putObject( + companyId: string, + objectKey: string, + body: Buffer, + contentType: string, + ) { assertStorageCompanyPrefix(companyId, objectKey); const { sdk, client } = await getS3Client(); await client.send( @@ -379,12 +416,19 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { export function isMissingStorageObjectError(error: unknown): boolean { if (!error || typeof error !== "object") return false; - const candidate = error as { code?: unknown; status?: unknown; name?: unknown; message?: unknown }; - return candidate.code === "ENOENT" - || candidate.status === 404 - || candidate.name === "NoSuchKey" - || candidate.name === "NotFound" - || candidate.message === "Object not found."; + const candidate = error as { + code?: unknown; + status?: unknown; + name?: unknown; + message?: unknown; + }; + return ( + candidate.code === "ENOENT" || + candidate.status === 404 || + candidate.name === "NoSuchKey" || + candidate.name === "NotFound" || + candidate.message === "Object not found." + ); } export async function readSourceAttachmentBody( @@ -427,10 +471,14 @@ function extractExecSyncErrorMessage(error: unknown): string | null { function localBranchExists(cwd: string, branchName: string): boolean { try { - execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { - cwd, - stdio: "ignore", - }); + execFileSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + { + cwd, + stdio: "ignore", + }, + ); return true; } catch { return false; @@ -447,7 +495,14 @@ export function resolveGitWorktreeAddArgs(input: { return ["worktree", "add", input.targetPath, input.branchName]; } const commitish = input.startPoint ?? "HEAD"; - return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; + return [ + "worktree", + "add", + "-b", + input.branchName, + input.targetPath, + commitish, + ]; } function readPidFilePort(postmasterPidFile: string): number | null { @@ -464,7 +519,9 @@ function readPidFilePort(postmasterPidFile: string): number | null { function readRunningPostmasterPid(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { - const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + const pid = Number( + readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(), + ); if (!Number.isInteger(pid) || pid <= 0) return null; process.kill(pid, 0); return pid; @@ -484,7 +541,10 @@ async function isPortAvailable(port: number): Promise { }); } -async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { +async function findAvailablePort( + preferredPort: number, + reserved = new Set(), +): Promise { let port = Math.max(1, Math.trunc(preferredPort)); while (reserved.has(port) || !(await isPortAvailable(port))) { port += 1; @@ -501,7 +561,11 @@ function resolveRepoManagedWorktreesRoot(cwd: string): string | null { return path.resolve(repoRoot, ".taskcore", "worktrees"); } -function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): { +function collectClaimedWorktreePorts( + homeDir: string, + currentInstanceId: string, + cwd: string, +): { serverPorts: Set; databasePorts: Set; } { @@ -522,9 +586,16 @@ function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd); if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) { - for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + for (const entry of readdirSync(repoManagedWorktreesRoot, { + withFileTypes: true, + })) { if (!entry.isDirectory()) continue; - const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".taskcore", "config.json"); + const configPath = path.resolve( + repoManagedWorktreesRoot, + entry.name, + ".taskcore", + "config.json", + ); if (existsSync(configPath)) { configPaths.add(configPath); } @@ -572,7 +643,9 @@ function validateGitBranchName(cwd: string, branchName: string): string { stdio: ["ignore", "pipe", "pipe"], }); } catch (error) { - throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`); + throw new Error( + `Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, + ); } return value; } @@ -594,7 +667,8 @@ function resolvePrimaryGitRepoRoot(cwd: string): string { } function resolveRepairWorktreeDirName(branchName: string): string { - const normalized = branchName.trim() + const normalized = branchName + .trim() .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-._]+|[-._]+$/g, ""); @@ -608,21 +682,29 @@ function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); - const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); + const commonDirRaw = execFileSync( + "git", + ["rev-parse", "--git-common-dir"], + { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); - const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); + const hooksPathRaw = execFileSync( + "git", + ["rev-parse", "--git-path", "hooks"], + { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); return { root: path.resolve(root), commonDir: path.resolve(root, commonDirRaw), @@ -673,7 +755,9 @@ function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { return copied; } -export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null { +export function copyGitHooksToWorktreeGitDir( + cwd: string, +): CopiedGitHooksResult | null { const workspace = detectGitWorkspaceInfo(cwd); if (!workspace) return null; @@ -776,22 +860,31 @@ async function rebindSeededProjectWorkspaces(input: { } export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { - if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); + if (opts.sourceConfigPathOverride) + return path.resolve(opts.sourceConfigPathOverride); if (opts.fromConfig) return path.resolve(opts.fromConfig); if (!opts.fromDataDir && !opts.fromInstance) { return resolveConfigPath(); } - const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.taskcore")); - const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); + const sourceHome = path.resolve( + expandHomePrefix(opts.fromDataDir ?? "~/.taskcore"), + ); + const sourceInstanceId = sanitizeWorktreeInstanceId( + opts.fromInstance ?? "default", + ); return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); } -export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource { +export function resolveWorktreeReseedSource( + input: WorktreeReseedOptions, +): ResolvedWorktreeReseedSource { const fromSelector = nonEmpty(input.from); const fromConfig = nonEmpty(input.fromConfig); const fromDataDir = nonEmpty(input.fromDataDir); const fromInstance = nonEmpty(input.fromInstance); - const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance); + const hasExplicitConfigSource = Boolean( + fromConfig || fromDataDir || fromInstance, + ); if (fromSelector && hasExplicitConfigSource) { throw new Error( @@ -800,7 +893,9 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol } if (fromSelector) { - const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true }); + const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { + allowCurrent: true, + }); return { configPath: endpoint.configPath, label: endpoint.label, @@ -824,7 +919,9 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol ); } -function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource { +function resolveWorktreeRepairSource( + input: WorktreeRepairOptions, +): ResolvedWorktreeReseedSource { const fromConfig = nonEmpty(input.fromConfig); const fromDataDir = nonEmpty(input.fromDataDir); const fromInstance = nonEmpty(input.fromInstance) ?? "default"; @@ -843,7 +940,9 @@ export function resolveWorktreeReseedTargetPaths(input: { configPath: string; rootPath: string; }): WorktreeLocalPaths { - const envEntries = readTaskcoreEnvEntries(resolveTaskcoreEnvFile(input.configPath)); + const envEntries = readTaskcoreEnvEntries( + resolveTaskcoreEnvFile(input.configPath), + ); const homeDir = nonEmpty(envEntries.TASKCORE_HOME); const instanceId = nonEmpty(envEntries.TASKCORE_INSTANCE_ID); @@ -860,7 +959,10 @@ export function resolveWorktreeReseedTargetPaths(input: { }); } -function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null { +function resolveExistingGitWorktree( + selector: string, + cwd: string, +): MergeSourceChoice | null { const trimmed = selector.trim(); if (trimmed.length === 0) return null; @@ -870,17 +972,22 @@ function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceC worktree: directPath, branch: null, branchLabel: path.basename(directPath), - hasTaskcoreConfig: existsSync(path.resolve(directPath, ".taskcore", "config.json")), + hasTaskcoreConfig: existsSync( + path.resolve(directPath, ".taskcore", "config.json"), + ), isCurrent: directPath === path.resolve(cwd), }; } - return toMergeSourceChoices(cwd).find((choice) => - choice.worktree === directPath - || path.basename(choice.worktree) === trimmed - || choice.branchLabel === trimmed - || choice.branch === trimmed, - ) ?? null; + return ( + toMergeSourceChoices(cwd).find( + (choice) => + choice.worktree === directPath || + path.basename(choice.worktree) === trimmed || + choice.branchLabel === trimmed || + choice.branch === trimmed, + ) ?? null + ); } async function ensureRepairTargetWorktree(input: { @@ -890,7 +997,11 @@ async function ensureRepairTargetWorktree(input: { }): Promise { const cwd = process.cwd(); const currentRoot = path.resolve(cwd); - const currentConfigPath = path.resolve(currentRoot, ".taskcore", "config.json"); + const currentConfigPath = path.resolve( + currentRoot, + ".taskcore", + "config.json", + ); if (!input.selector) { if (isPrimaryGitWorktree(cwd)) { @@ -911,7 +1022,8 @@ async function ensureRepairTargetWorktree(input: { rootPath: existing.worktree, configPath: path.resolve(existing.worktree, ".taskcore", "config.json"), label: existing.branchLabel, - branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel, + branchName: + existing.branchLabel === "(detached)" ? null : existing.branchLabel, created: false, }; } @@ -926,7 +1038,9 @@ async function ensureRepairTargetWorktree(input: { ); if (existsSync(targetPath)) { - throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`); + throw new Error( + `Target path already exists but is not a registered git worktree: ${targetPath}`, + ); } mkdirSync(path.dirname(targetPath), { recursive: true }); @@ -934,14 +1048,18 @@ async function ensureRepairTargetWorktree(input: { const spinner = p.spinner(); spinner.start(`Creating git worktree for ${branchName}...`); try { - execFileSync("git", resolveGitWorktreeAddArgs({ - branchName, - targetPath, - branchExists: localBranchExists(repoRoot, branchName), - }), { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); + execFileSync( + "git", + resolveGitWorktreeAddArgs({ + branchName, + targetPath, + branchExists: localBranchExists(repoRoot, branchName), + }), + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }, + ); spinner.stop(`Created git worktree at ${targetPath}.`); } catch (error) { spinner.stop(pc.red("Failed to create git worktree.")); @@ -959,9 +1077,15 @@ async function ensureRepairTargetWorktree(input: { }; } -function resolveSourceConnectionString(config: TaskcoreConfig, envEntries: Record, portOverride?: number): string { +function resolveSourceConnectionString( + config: TaskcoreConfig, + envEntries: Record, + portOverride?: number, +): string { if (config.database.mode === "postgres") { - const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); + const connectionString = + nonEmpty(envEntries.DATABASE_URL) ?? + nonEmpty(config.database.connectionString); if (!connectionString) { throw new Error( "Source instance uses postgres mode but has no connection string in config or adjacent .env.", @@ -986,10 +1110,14 @@ export function copySeededSecretsKey(input: { mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); - const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); + const allowProcessEnvFallback = isCurrentSourceConfigPath( + input.sourceConfigPath, + ); const sourceInlineMasterKey = nonEmpty(input.sourceEnvEntries.TASKCORE_SECRETS_MASTER_KEY) ?? - (allowProcessEnvFallback ? nonEmpty(process.env.TASKCORE_SECRETS_MASTER_KEY) : null); + (allowProcessEnvFallback + ? nonEmpty(process.env.TASKCORE_SECRETS_MASTER_KEY) + : null); if (sourceInlineMasterKey) { writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { encoding: "utf8", @@ -1005,9 +1133,16 @@ export function copySeededSecretsKey(input: { const sourceKeyFileOverride = nonEmpty(input.sourceEnvEntries.TASKCORE_SECRETS_MASTER_KEY_FILE) ?? - (allowProcessEnvFallback ? nonEmpty(process.env.TASKCORE_SECRETS_MASTER_KEY_FILE) : null); - const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; - const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); + (allowProcessEnvFallback + ? nonEmpty(process.env.TASKCORE_SECRETS_MASTER_KEY_FILE) + : null); + const sourceConfiguredKeyPath = + sourceKeyFileOverride ?? + input.sourceConfig.secrets.localEncrypted.keyFilePath; + const sourceKeyFilePath = resolveRuntimeLikePath( + sourceConfiguredKeyPath, + input.sourceConfigPath, + ); if (!existsSync(sourceKeyFilePath)) { throw new Error( @@ -1023,7 +1158,10 @@ export function copySeededSecretsKey(input: { } } -async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { +async function ensureEmbeddedPostgres( + dataDir: string, + preferredPort: number, +): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { @@ -1041,7 +1179,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P return { port: readPidFilePort(postmasterPidFile) ?? preferredPort, startedByThisProcess: false, - stop: async () => { }, + stop: async () => {}, }; } @@ -1089,13 +1227,20 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P }; } -export async function pauseSeededScheduledRoutines(connectionString: string): Promise { +export async function pauseSeededScheduledRoutines( + connectionString: string, +): Promise { const db = createDb(connectionString); try { const scheduledRoutineIds = await db .selectDistinct({ routineId: routineTriggers.routineId }) .from(routineTriggers) - .where(and(eq(routineTriggers.kind, "schedule"), eq(routineTriggers.enabled, true))); + .where( + and( + eq(routineTriggers.kind, "schedule"), + eq(routineTriggers.enabled, true), + ), + ); const idsToPause = scheduledRoutineIds .map((row) => row.routineId) .filter((value): value is string => Boolean(value)); @@ -1110,7 +1255,13 @@ export async function pauseSeededScheduledRoutines(connectionString: string): Pr status: "paused", updatedAt: new Date(), }) - .where(and(inArray(routines.id, idsToPause), sql`${routines.status} <> 'paused'`, sql`${routines.status} <> 'archived'`)) + .where( + and( + inArray(routines.id, idsToPause), + sql`${routines.status} <> 'paused'`, + sql`${routines.status} <> 'archived'`, + ), + ) .returning({ id: routines.id }); return paused.length; @@ -1204,7 +1355,9 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { ); const seedMode = opts.seedMode ?? "minimal"; if (!isWorktreeSeedMode(seedMode)) { - throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + throw new Error( + `Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`, + ); } const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); const paths = resolveWorktreeLocalPaths({ @@ -1217,9 +1370,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { color: opts.color ?? generateWorktreeColor(), }; const sourceConfigPath = resolveSourceConfigPath(opts); - const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; - - if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) { + const sourceConfig = existsSync(sourceConfigPath) + ? readConfig(sourceConfigPath) + : null; + + if ( + (existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && + !opts.force + ) { throw new Error( `Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`, ); @@ -1230,10 +1388,19 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { rmSync(paths.instanceRoot, { recursive: true, force: true }); } - const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd); - const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); - const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); - const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); + const claimedPorts = collectClaimedWorktreePorts( + paths.homeDir, + paths.instanceId, + paths.cwd, + ); + const preferredServerPort = + opts.serverPort ?? (sourceConfig?.server.port ?? 3100) + 1; + const serverPort = await findAvailablePort( + preferredServerPort, + claimedPorts.serverPorts, + ); + const preferredDbPort = + opts.dbPort ?? (sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1; const databasePort = await findAvailablePort( preferredDbPort, new Set([...claimedPorts.databasePorts, serverPort]), @@ -1246,14 +1413,18 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { }); writeConfig(targetConfig, paths.configPath); - const sourceEnvEntries = readTaskcoreEnvEntries(resolveTaskcoreEnvFile(sourceConfigPath)); + const sourceEnvEntries = readTaskcoreEnvEntries( + resolveTaskcoreEnvFile(sourceConfigPath), + ); const existingAgentJwtSecret = nonEmpty(sourceEnvEntries.TASKCORE_AGENT_JWT_SECRET) ?? nonEmpty(process.env.TASKCORE_AGENT_JWT_SECRET); mergeTaskcoreEnvEntries( { ...buildWorktreeEnvEntries(paths, branding), - ...(existingAgentJwtSecret ? { TASKCORE_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), + ...(existingAgentJwtSecret + ? { TASKCORE_AGENT_JWT_SECRET: existingAgentJwtSecret } + : {}), }, paths.envPath, ); @@ -1262,7 +1433,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); let seedSummary: string | null = null; - let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; + let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = + []; if (opts.seed !== false) { if (!sourceConfig) { throw new Error( @@ -1270,7 +1442,9 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { ); } const spinner = p.spinner(); - spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); + spinner.start( + `Seeding isolated worktree database from source instance (${seedMode})...`, + ); try { const seeded = await seedWorktreeDatabase({ sourceConfigPath, @@ -1294,10 +1468,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); - p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); + p.log.message( + pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`), + ); if (copiedGitHooks?.copied) { p.log.message( - pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`), + pc.dim( + `Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`, + ), ); } if (seedSummary) { @@ -1305,7 +1483,9 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); for (const rebound of reboundWorkspaceSummary) { p.log.message( - pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), + pc.dim( + `Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`, + ), ); } } @@ -1316,13 +1496,18 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { ); } -export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { +export async function worktreeInitCommand( + opts: WorktreeInitOptions, +): Promise { printTaskcoreCliBanner(); p.intro(pc.bgCyan(pc.black(" taskcore worktree init "))); await runWorktreeInit(opts); } -export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { +export async function worktreeMakeCommand( + nameArg: string, + opts: WorktreeMakeOptions, +): Promise { printTaskcoreCliBanner(); p.intro(pc.bgCyan(pc.black(" taskcore worktree:make "))); @@ -1397,7 +1582,9 @@ function installDependenciesBestEffort(targetPath: string): void { }); installSpinner.stop("Installed dependencies."); } catch (error) { - installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway).")); + installSpinner.stop( + pc.yellow("Failed to install dependencies (continuing anyway)."), + ); p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); } } @@ -1484,13 +1671,16 @@ function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { function toMergeSourceChoices(cwd: string): MergeSourceChoice[] { const currentCwd = path.resolve(cwd); return parseGitWorktreeList(cwd).map((entry) => { - const branchLabel = entry.branch?.replace(/^refs\/heads\//, "") ?? "(detached)"; + const branchLabel = + entry.branch?.replace(/^refs\/heads\//, "") ?? "(detached)"; const worktreePath = path.resolve(entry.worktree); return { worktree: worktreePath, branch: entry.branch, branchLabel, - hasTaskcoreConfig: existsSync(path.resolve(worktreePath, ".taskcore", "config.json")), + hasTaskcoreConfig: existsSync( + path.resolve(worktreePath, ".taskcore", "config.json"), + ), isCurrent: worktreePath === currentCwd, }; }); @@ -1500,7 +1690,16 @@ function branchHasUniqueCommits(cwd: string, branchName: string): boolean { try { const output = execFileSync( "git", - ["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"], + [ + "log", + "--oneline", + branchName, + "--not", + "--remotes", + "--exclude", + `refs/heads/${branchName}`, + "--branches", + ], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ).trim(); return output.length > 0; @@ -1524,18 +1723,21 @@ function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean { function worktreePathHasUncommittedChanges(worktreePath: string): boolean { try { - const output = execFileSync( - "git", - ["status", "--porcelain"], - { cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ).trim(); + const output = execFileSync("git", ["status", "--porcelain"], { + cwd: worktreePath, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); return output.length > 0; } catch { return false; } } -export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise { +export async function worktreeCleanupCommand( + nameArg: string, + opts: WorktreeCleanupOptions, +): Promise { printTaskcoreCliBanner(); p.intro(pc.bgCyan(pc.black(" taskcore worktree:cleanup "))); @@ -1543,7 +1745,9 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea const sourceCwd = process.cwd(); const targetPath = resolveWorktreeMakeTargetPath(name); const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); - const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home))); + const homeDir = path.resolve( + expandHomePrefix(resolveWorktreeHome(opts.home)), + ); const instanceRoot = path.resolve(homeDir, "instances", instanceId); // ── 1. Assess current state ────────────────────────────────────────── @@ -1554,11 +1758,15 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea const worktrees = parseGitWorktreeList(sourceCwd); const linkedWorktree = worktrees.find( - (wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath), + (wt) => + wt.branch === `refs/heads/${name}` || + path.resolve(wt.worktree) === path.resolve(targetPath), ); if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) { - p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found."); + p.log.info( + "Nothing to clean up — no branch, worktree directory, or instance data found.", + ); p.outro(pc.green("Already clean.")); return; } @@ -1576,7 +1784,7 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea } else { problems.push( `Branch "${name}" has commits not found on any other branch or remote. ` + - `Deleting it will lose work. Push it first, or use --force.`, + `Deleting it will lose work. Push it first, or use --force.`, ); } } @@ -1591,7 +1799,9 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea for (const problem of problems) { p.log.error(problem); } - throw new Error("Safety checks failed. Resolve the issues above or re-run with --force."); + throw new Error( + "Safety checks failed. Resolve the issues above or re-run with --force.", + ); } if (problems.length > 0 && opts.force) { for (const problem of problems) { @@ -1616,7 +1826,9 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea }); spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`); } catch (error) { - spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`)); + spinner.stop( + pc.yellow(`Could not remove worktree cleanly, will prune instead.`), + ); p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); } } else { @@ -1671,15 +1883,23 @@ export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeClea p.outro(pc.green("Cleanup complete.")); } -export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { +export async function worktreeEnvCommand( + opts: WorktreeEnvOptions, +): Promise { const configPath = resolveConfigPath(opts.config); const envPath = resolveTaskcoreEnvFile(configPath); const envEntries = readTaskcoreEnvEntries(envPath); const out = { TASKCORE_CONFIG: configPath, - ...(envEntries.TASKCORE_HOME ? { TASKCORE_HOME: envEntries.TASKCORE_HOME } : {}), - ...(envEntries.TASKCORE_INSTANCE_ID ? { TASKCORE_INSTANCE_ID: envEntries.TASKCORE_INSTANCE_ID } : {}), - ...(envEntries.TASKCORE_CONTEXT ? { TASKCORE_CONTEXT: envEntries.TASKCORE_CONTEXT } : {}), + ...(envEntries.TASKCORE_HOME + ? { TASKCORE_HOME: envEntries.TASKCORE_HOME } + : {}), + ...(envEntries.TASKCORE_INSTANCE_ID + ? { TASKCORE_INSTANCE_ID: envEntries.TASKCORE_INSTANCE_ID } + : {}), + ...(envEntries.TASKCORE_CONTEXT + ? { TASKCORE_CONTEXT: envEntries.TASKCORE_CONTEXT } + : {}), ...envEntries, }; @@ -1729,7 +1949,9 @@ function resolveAttachmentLookupStorages(input: { input.targetEndpoint.configPath, ...toMergeSourceChoices(process.cwd()) .filter((choice) => choice.hasTaskcoreConfig) - .map((choice) => path.resolve(choice.worktree, ".taskcore", "config.json")), + .map((choice) => + path.resolve(choice.worktree, ".taskcore", "config.json"), + ), ]; const seen = new Set(); const storages: ConfiguredStorage[] = []; @@ -1757,7 +1979,11 @@ async function openConfiguredDb(configPath: string): Promise { config.database.embeddedPostgresPort, ); } - const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port); + const connectionString = resolveSourceConnectionString( + config, + envEntries, + embeddedHandle?.port, + ); const migrationState = await inspectMigrations(connectionString); if (migrationState.status !== "upToDate") { const pending = @@ -1808,15 +2034,23 @@ async function resolveMergeCompany(input: { .from(companies), ]); - const targetById = new Map(targetCompanies.map((company) => [company.id, company])); - const shared = sourceCompanies.filter((company) => targetById.has(company.id)); + const targetById = new Map( + targetCompanies.map((company) => [company.id, company]), + ); + const shared = sourceCompanies.filter((company) => + targetById.has(company.id), + ); const selector = nonEmpty(input.selector); if (selector) { const matched = shared.find( - (company) => company.id === selector || company.issuePrefix.toLowerCase() === selector.toLowerCase(), + (company) => + company.id === selector || + company.issuePrefix.toLowerCase() === selector.toLowerCase(), ); if (!matched) { - throw new Error(`Could not resolve company "${selector}" in both source and target databases.`); + throw new Error( + `Could not resolve company "${selector}" in both source and target databases.`, + ); } return matched; } @@ -1826,20 +2060,27 @@ async function resolveMergeCompany(input: { } if (shared.length === 0) { - throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match."); + throw new Error( + "Source and target databases do not share a company id. Pass --company explicitly once both sides match.", + ); } const options = shared .map((company) => `${company.issuePrefix} (${company.name})`) .join(", "); - throw new Error(`Multiple shared companies found. Re-run with --company . Options: ${options}`); + throw new Error( + `Multiple shared companies found. Re-run with --company . Options: ${options}`, + ); } -function renderMergePlan(plan: Awaited>["plan"], extras: { - sourcePath: string; - targetPath: string; - unsupportedRunCount: number; -}): string { +function renderMergePlan( + plan: Awaited>["plan"], + extras: { + sourcePath: string; + targetPath: string; + unsupportedRunCount: number; + }, +): string { const terminalWidth = Math.max(60, process.stdout.columns ?? 100); const oneLine = (value: string) => value.replace(/\s+/g, " ").trim(); const truncateToWidth = (value: string, maxWidth: number) => { @@ -1872,17 +2113,23 @@ function renderMergePlan(plan: Awaited>["pla } } - const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert"); + const issueInserts = plan.issuePlans.filter( + (item): item is PlannedIssueInsert => item.action === "insert", + ); if (issueInserts.length > 0) { lines.push(""); lines.push("Planned issue imports"); for (const issue of issueInserts) { const projectNote = - (issue.projectResolution === "mapped" || issue.projectResolution === "imported") - && issue.mappedProjectName + (issue.projectResolution === "mapped" || + issue.projectResolution === "imported") && + issue.mappedProjectName ? ` project->${issue.projectResolution === "imported" ? "import:" : ""}${issue.mappedProjectName}` : ""; - const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; + const adjustments = + issue.adjustments.length > 0 + ? ` [${issue.adjustments.join(", ")}]` + : ""; const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`; const title = oneLine(issue.source.title); const suffix = `${adjustments}${title ? ` ${title}` : ""}`; @@ -1897,7 +2144,9 @@ function renderMergePlan(plan: Awaited>["pla lines.push("Comments"); lines.push(`- insert: ${plan.counts.commentsToInsert}`); lines.push(`- already present: ${plan.counts.commentsExisting}`); - lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`); + lines.push( + `- skipped (missing parent): ${plan.counts.commentsMissingParent}`, + ); } lines.push(""); @@ -1905,42 +2154,68 @@ function renderMergePlan(plan: Awaited>["pla lines.push(`- insert: ${plan.counts.documentsToInsert}`); lines.push(`- merge existing: ${plan.counts.documentsToMerge}`); lines.push(`- already present: ${plan.counts.documentsExisting}`); - lines.push(`- skipped (conflicting key): ${plan.counts.documentsConflictingKey}`); - lines.push(`- skipped (missing parent): ${plan.counts.documentsMissingParent}`); + lines.push( + `- skipped (conflicting key): ${plan.counts.documentsConflictingKey}`, + ); + lines.push( + `- skipped (missing parent): ${plan.counts.documentsMissingParent}`, + ); lines.push(`- revisions insert: ${plan.counts.documentRevisionsToInsert}`); lines.push(""); lines.push("Attachments"); lines.push(`- insert: ${plan.counts.attachmentsToInsert}`); lines.push(`- already present: ${plan.counts.attachmentsExisting}`); - lines.push(`- skipped (missing parent): ${plan.counts.attachmentsMissingParent}`); + lines.push( + `- skipped (missing parent): ${plan.counts.attachmentsMissingParent}`, + ); lines.push(""); lines.push("Adjustments"); - lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`); + lines.push( + `- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`, + ); lines.push(`- cleared projects: ${plan.adjustments.clear_project}`); - lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`); + lines.push( + `- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`, + ); lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`); - lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`); - lines.push(`- cleared document agents: ${plan.adjustments.clear_document_agent}`); - lines.push(`- cleared document revision agents: ${plan.adjustments.clear_document_revision_agent}`); - lines.push(`- cleared attachment author agents: ${plan.adjustments.clear_attachment_agent}`); - lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`); + lines.push( + `- cleared comment author agents: ${plan.adjustments.clear_author_agent}`, + ); + lines.push( + `- cleared document agents: ${plan.adjustments.clear_document_agent}`, + ); + lines.push( + `- cleared document revision agents: ${plan.adjustments.clear_document_revision_agent}`, + ); + lines.push( + `- cleared attachment author agents: ${plan.adjustments.clear_attachment_agent}`, + ); + lines.push( + `- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`, + ); lines.push(""); lines.push("Not imported in this phase"); lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`); lines.push(""); - lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time."); + lines.push( + "Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.", + ); return lines.join("\n"); } -function resolveRunningEmbeddedPostgresPid(config: TaskcoreConfig): number | null { +function resolveRunningEmbeddedPostgresPid( + config: TaskcoreConfig, +): number | null { if (config.database.mode !== "embedded-postgres") { return null; } - return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid")); + return readRunningPostmasterPid( + path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid"), + ); } async function collectMergePlan(input: { @@ -1979,19 +2254,13 @@ async function collectMergePlan(input: { .from(companies) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null), - input.sourceDb - .select() - .from(issues) - .where(eq(issues.companyId, companyId)), - input.targetDb - .select() - .from(issues) - .where(eq(issues.companyId, companyId)), + input.sourceDb.select().from(issues).where(eq(issues.companyId, companyId)), + input.targetDb.select().from(issues).where(eq(issues.companyId, companyId)), input.scopes.includes("comments") ? input.sourceDb - .select() - .from(issueComments) - .where(eq(issueComments.companyId, companyId)) + .select() + .from(issueComments) + .where(eq(issueComments.companyId, companyId)) : Promise.resolve([]), input.targetDb .select() @@ -2060,7 +2329,10 @@ async function collectMergePlan(input: { createdAt: documentRevisions.createdAt, }) .from(documentRevisions) - .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .innerJoin( + issueDocuments, + eq(documentRevisions.documentId, issueDocuments.documentId), + ) .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) .where(eq(issues.companyId, companyId)), input.targetDb @@ -2076,7 +2348,10 @@ async function collectMergePlan(input: { createdAt: documentRevisions.createdAt, }) .from(documentRevisions) - .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .innerJoin( + issueDocuments, + eq(documentRevisions.documentId, issueDocuments.documentId), + ) .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) .where(eq(issues.companyId, companyId)), input.sourceDb @@ -2139,18 +2414,12 @@ async function collectMergePlan(input: { .select() .from(projects) .where(eq(projects.companyId, companyId)), - input.targetDb - .select() - .from(agents) - .where(eq(agents.companyId, companyId)), + input.targetDb.select().from(agents).where(eq(agents.companyId, companyId)), input.targetDb .select() .from(projectWorkspaces) .where(eq(projectWorkspaces.companyId, companyId)), - input.targetDb - .select() - .from(goals) - .where(eq(goals.companyId, companyId)), + input.targetDb.select().from(goals).where(eq(goals.companyId, companyId)), input.sourceDb .select({ count: sql`count(*)::int` }) .from(heartbeatRuns) @@ -2175,8 +2444,10 @@ async function collectMergePlan(input: { sourceProjectWorkspaces: sourceProjectWorkspaceRows, sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[], targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[], - sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[], - targetDocumentRevisions: targetDocumentRevisionRows as DocumentRevisionRow[], + sourceDocumentRevisions: + sourceDocumentRevisionRows as DocumentRevisionRow[], + targetDocumentRevisions: + targetDocumentRevisionRows as DocumentRevisionRow[], sourceAttachments: sourceAttachmentRows as IssueAttachmentRow[], targetAttachments: targetAttachmentRows as IssueAttachmentRow[], targetAgents: targetAgentsRows, @@ -2202,14 +2473,21 @@ type ProjectMappingSelections = { async function promptForProjectMappings(input: { plan: Awaited>["plan"]; - sourceProjects: Awaited>["sourceProjects"]; - targetProjects: Awaited>["targetProjects"]; + sourceProjects: Awaited< + ReturnType + >["sourceProjects"]; + targetProjects: Awaited< + ReturnType + >["targetProjects"]; }): Promise { const missingProjectIds = [ ...new Set( input.plan.issuePlans .filter((plan): plan is PlannedIssueInsert => plan.action === "insert") - .filter((plan) => !!plan.source.projectId && plan.projectResolution === "cleared") + .filter( + (plan) => + !!plan.source.projectId && plan.projectResolution === "cleared", + ) .map((plan) => plan.source.projectId as string), ), ]; @@ -2220,7 +2498,9 @@ async function promptForProjectMappings(input: { }; } - const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project])); + const sourceProjectsById = new Map( + input.sourceProjects.map((project) => [project.id, project]), + ); const targetChoices = [...input.targetProjects] .sort((left, right) => left.name.localeCompare(right.name)) .map((project) => ({ @@ -2235,7 +2515,9 @@ async function promptForProjectMappings(input: { const sourceProject = sourceProjectsById.get(sourceProjectId); if (!sourceProject) continue; const nameMatch = input.targetProjects.find( - (project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(), + (project) => + project.name.trim().toLowerCase() === + sourceProject.name.trim().toLowerCase(), ); const importSelectionValue = `__import__:${sourceProjectId}`; const selection = await p.select({ @@ -2247,11 +2529,13 @@ async function promptForProjectMappings(input: { hint: "Create the project and copy its workspace settings", }, ...(nameMatch - ? [{ - value: nameMatch.id, - label: `Map to ${nameMatch.name}`, - hint: "Recommended: exact name match", - }] + ? [ + { + value: nameMatch.id, + label: `Map to ${nameMatch.name}`, + hint: "Recommended: exact name match", + }, + ] : []), { value: null, @@ -2278,7 +2562,9 @@ async function promptForProjectMappings(input: { }; } -export async function worktreeListCommand(opts: WorktreeListOptions): Promise { +export async function worktreeListCommand( + opts: WorktreeListOptions, +): Promise { const choices = toMergeSourceChoices(process.cwd()); if (opts.json) { console.log(JSON.stringify(choices, null, 2)); @@ -2290,11 +2576,15 @@ export async function worktreeListCommand(opts: WorktreeListOptions): Promise value !== null); - p.log.message(`${choice.branchLabel} ${choice.worktree} [${flags.join(", ")}]`); + p.log.message( + `${choice.branchLabel} ${choice.worktree} [${flags.join(", ")}]`, + ); } } -function resolveEndpointFromChoice(choice: MergeSourceChoice): ResolvedWorktreeEndpoint { +function resolveEndpointFromChoice( + choice: MergeSourceChoice, +): ResolvedWorktreeEndpoint { if (choice.isCurrent) { return resolveCurrentEndpoint(); } @@ -2329,7 +2619,9 @@ function resolveWorktreeEndpointFromSelector( } const configPath = path.resolve(directPath, ".taskcore", "config.json"); if (!existsSync(configPath)) { - throw new Error(`Resolved worktree path ${directPath} does not contain .taskcore/config.json.`); + throw new Error( + `Resolved worktree path ${directPath} does not contain .taskcore/config.json.`, + ); } return { rootPath: directPath, @@ -2339,11 +2631,12 @@ function resolveWorktreeEndpointFromSelector( }; } - const matched = choices.find((choice) => - (allowCurrent || !choice.isCurrent) - && (choice.worktree === directPath - || path.basename(choice.worktree) === trimmed - || choice.branchLabel === trimmed), + const matched = choices.find( + (choice) => + (allowCurrent || !choice.isCurrent) && + (choice.worktree === directPath || + path.basename(choice.worktree) === trimmed || + choice.branchLabel === trimmed), ); if (!matched) { throw new Error( @@ -2351,13 +2644,19 @@ function resolveWorktreeEndpointFromSelector( ); } if (!matched.hasTaskcoreConfig && !matched.isCurrent) { - throw new Error(`Resolved worktree "${selector}" does not look like a Taskcore worktree.`); + throw new Error( + `Resolved worktree "${selector}" does not look like a Taskcore worktree.`, + ); } return resolveEndpointFromChoice(matched); } -async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise { - const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null; +async function promptForSourceEndpoint( + excludeWorktreePath?: string, +): Promise { + const excluded = excludeWorktreePath + ? path.resolve(excludeWorktreePath) + : null; const currentEndpoint = resolveCurrentEndpoint(); const choices = toMergeSourceChoices(process.cwd()) .filter((choice) => choice.hasTaskcoreConfig || choice.isCurrent) @@ -2368,7 +2667,9 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise({ message: "Choose the source worktree to import from", @@ -2393,27 +2694,37 @@ async function applyMergePlan(input: { const companyId = input.company.id; return await input.targetDb.transaction(async (tx) => { - const importedProjectIds = input.plan.projectImports.map((project) => project.source.id); - const existingImportedProjectIds = importedProjectIds.length > 0 - ? new Set( - (await tx - .select({ id: projects.id }) - .from(projects) - .where(inArray(projects.id, importedProjectIds))) - .map((row) => row.id), - ) - : new Set(); - const projectImports = input.plan.projectImports.filter((project) => !existingImportedProjectIds.has(project.source.id)); - const importedWorkspaceIds = projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)); - const existingImportedWorkspaceIds = importedWorkspaceIds.length > 0 - ? new Set( - (await tx - .select({ id: projectWorkspaces.id }) - .from(projectWorkspaces) - .where(inArray(projectWorkspaces.id, importedWorkspaceIds))) - .map((row) => row.id), - ) - : new Set(); + const importedProjectIds = input.plan.projectImports.map( + (project) => project.source.id, + ); + const existingImportedProjectIds = + importedProjectIds.length > 0 + ? new Set( + ( + await tx + .select({ id: projects.id }) + .from(projects) + .where(inArray(projects.id, importedProjectIds)) + ).map((row) => row.id), + ) + : new Set(); + const projectImports = input.plan.projectImports.filter( + (project) => !existingImportedProjectIds.has(project.source.id), + ); + const importedWorkspaceIds = projectImports.flatMap((project) => + project.workspaces.map((workspace) => workspace.id), + ); + const existingImportedWorkspaceIds = + importedWorkspaceIds.length > 0 + ? new Set( + ( + await tx + .select({ id: projectWorkspaces.id }) + .from(projectWorkspaces) + .where(inArray(projectWorkspaces.id, importedWorkspaceIds)) + ).map((row) => row.id), + ) + : new Set(); let insertedProjects = 0; let insertedProjectWorkspaces = 0; @@ -2468,22 +2779,28 @@ async function applyMergePlan(input: { (plan): plan is PlannedIssueInsert => plan.action === "insert", ); const issueCandidateIds = issueCandidates.map((issue) => issue.source.id); - const existingIssueIds = issueCandidateIds.length > 0 - ? new Set( - (await tx - .select({ id: issues.id }) - .from(issues) - .where(inArray(issues.id, issueCandidateIds))) - .map((row) => row.id), - ) - : new Set(); - const issueInserts = issueCandidates.filter((issue) => !existingIssueIds.has(issue.source.id)); + const existingIssueIds = + issueCandidateIds.length > 0 + ? new Set( + ( + await tx + .select({ id: issues.id }) + .from(issues) + .where(inArray(issues.id, issueCandidateIds)) + ).map((row) => row.id), + ) + : new Set(); + const issueInserts = issueCandidates.filter( + (issue) => !existingIssueIds.has(issue.source.id), + ); let nextIssueNumber = 0; if (issueInserts.length > 0) { const [companyRow] = await tx .update(companies) - .set({ issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}` }) + .set({ + issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}`, + }) .where(eq(companies.id, companyId)) .returning({ issueCounter: companies.issueCounter }); nextIssueNumber = companyRow.issueCounter - issueInserts.length + 1; @@ -2519,7 +2836,9 @@ async function applyMergePlan(input: { identifier, requestDepth: issue.source.requestDepth, billingCode: issue.source.billingCode, - assigneeAdapterOverrides: issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null, + assigneeAdapterOverrides: issue.targetAssigneeAgentId + ? issue.source.assigneeAdapterOverrides + : null, executionWorkspaceId: null, executionWorkspacePreference: null, executionWorkspaceSettings: null, @@ -2536,16 +2855,20 @@ async function applyMergePlan(input: { const commentCandidates = input.plan.commentPlans.filter( (plan): plan is PlannedCommentInsert => plan.action === "insert", ); - const commentCandidateIds = commentCandidates.map((comment) => comment.source.id); - const existingCommentIds = commentCandidateIds.length > 0 - ? new Set( - (await tx - .select({ id: issueComments.id }) - .from(issueComments) - .where(inArray(issueComments.id, commentCandidateIds))) - .map((row) => row.id), - ) - : new Set(); + const commentCandidateIds = commentCandidates.map( + (comment) => comment.source.id, + ); + const existingCommentIds = + commentCandidateIds.length > 0 + ? new Set( + ( + await tx + .select({ id: issueComments.id }) + .from(issueComments) + .where(inArray(issueComments.id, commentCandidateIds)) + ).map((row) => row.id), + ) + : new Set(); let insertedComments = 0; for (const comment of commentCandidates) { @@ -2553,7 +2876,12 @@ async function applyMergePlan(input: { const parentExists = await tx .select({ id: issues.id }) .from(issues) - .where(and(eq(issues.id, comment.source.issueId), eq(issues.companyId, companyId))) + .where( + and( + eq(issues.id, comment.source.issueId), + eq(issues.companyId, companyId), + ), + ) .then((rows) => rows[0] ?? null); if (!parentExists) continue; await tx.insert(issueComments).values({ @@ -2580,18 +2908,28 @@ async function applyMergePlan(input: { const parentExists = await tx .select({ id: issues.id }) .from(issues) - .where(and(eq(issues.id, documentPlan.source.issueId), eq(issues.companyId, companyId))) + .where( + and( + eq(issues.id, documentPlan.source.issueId), + eq(issues.companyId, companyId), + ), + ) .then((rows) => rows[0] ?? null); if (!parentExists) continue; const conflictingKeyDocument = await tx .select({ documentId: issueDocuments.documentId }) .from(issueDocuments) - .where(and(eq(issueDocuments.issueId, documentPlan.source.issueId), eq(issueDocuments.key, documentPlan.source.key))) + .where( + and( + eq(issueDocuments.issueId, documentPlan.source.issueId), + eq(issueDocuments.key, documentPlan.source.key), + ), + ) .then((rows) => rows[0] ?? null); if ( - conflictingKeyDocument - && conflictingKeyDocument.documentId !== documentPlan.source.documentId + conflictingKeyDocument && + conflictingKeyDocument.documentId !== documentPlan.source.documentId ) { continue; } @@ -2652,7 +2990,9 @@ async function applyMergePlan(input: { key: documentPlan.source.key, updatedAt: documentPlan.source.linkUpdatedAt, }) - .where(eq(issueDocuments.documentId, documentPlan.source.documentId)); + .where( + eq(issueDocuments.documentId, documentPlan.source.documentId), + ); } await tx @@ -2676,7 +3016,9 @@ async function applyMergePlan(input: { await tx .select({ id: documentRevisions.id }) .from(documentRevisions) - .where(eq(documentRevisions.documentId, documentPlan.source.documentId)) + .where( + eq(documentRevisions.documentId, documentPlan.source.documentId), + ) ).map((row) => row.id), ); for (const revisionPlan of documentPlan.revisionsToInsert) { @@ -2714,7 +3056,12 @@ async function applyMergePlan(input: { const parentExists = await tx .select({ id: issues.id }) .from(issues) - .where(and(eq(issues.id, attachment.source.issueId), eq(issues.companyId, companyId))) + .where( + and( + eq(issues.id, attachment.source.issueId), + eq(issues.companyId, companyId), + ), + ) .then((rows) => rows[0] ?? null); if (!parentExists) continue; @@ -2776,13 +3123,18 @@ async function applyMergePlan(input: { }); } -export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, opts: WorktreeMergeHistoryOptions): Promise { +export async function worktreeMergeHistoryCommand( + sourceArg: string | undefined, + opts: WorktreeMergeHistoryOptions, +): Promise { if (opts.apply && opts.dry) { throw new Error("Use either --apply or --dry, not both."); } if (sourceArg && opts.from) { - throw new Error("Use either the positional source argument or --from, not both."); + throw new Error( + "Use either the positional source argument or --from, not both.", + ); } const targetEndpoint = opts.to @@ -2794,8 +3146,13 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, ? resolveWorktreeEndpointFromSelector(sourceArg, { allowCurrent: true }) : await promptForSourceEndpoint(targetEndpoint.rootPath); - if (path.resolve(sourceEndpoint.configPath) === path.resolve(targetEndpoint.configPath)) { - throw new Error("Source and target Taskcore configs are the same. Choose different --from/--to worktrees."); + if ( + path.resolve(sourceEndpoint.configPath) === + path.resolve(targetEndpoint.configPath) + ) { + throw new Error( + "Source and target Taskcore configs are the same. Choose different --from/--to worktrees.", + ); } const scopes = parseWorktreeMergeScopes(opts.scope); @@ -2826,8 +3183,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, targetProjects: collected.targetProjects, }); if ( - projectSelections.importProjectIds.length > 0 - || Object.keys(projectSelections.projectIdOverrides).length > 0 + projectSelections.importProjectIds.length > 0 || + Object.keys(projectSelections.projectIdOverrides).length > 0 ) { collected = await collectMergePlan({ sourceDb: sourceHandle.db, @@ -2840,11 +3197,13 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, } } - console.log(renderMergePlan(collected.plan, { - sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`, - targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`, - unsupportedRunCount: collected.unsupportedRunCount, - })); + console.log( + renderMergePlan(collected.plan, { + sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`, + targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`, + unsupportedRunCount: collected.unsupportedRunCount, + }), + ); if (!opts.apply) { return; @@ -2853,9 +3212,9 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, const confirmed = opts.yes ? true : await p.confirm({ - message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${sourceEndpoint.label} into ${targetEndpoint.label}?`, - initialValue: false, - }); + message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${sourceEndpoint.label} into ${targetEndpoint.label}?`, + initialValue: false, + }); if (p.isCancel(confirmed) || !confirmed) { p.log.warn("Import cancelled."); return; @@ -2887,7 +3246,9 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { const seedMode = opts.seedMode ?? "full"; if (!isWorktreeSeedMode(seedMode)) { - throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + throw new Error( + `Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`, + ); } const targetEndpoint = opts.to @@ -2895,8 +3256,12 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { : resolveCurrentEndpoint(); const source = resolveWorktreeReseedSource(opts); - if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) { - throw new Error("Source and target Taskcore configs are the same. Choose different --from/--to values."); + if ( + path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath) + ) { + throw new Error( + "Source and target Taskcore configs are the same. Choose different --from/--to values.", + ); } if (!existsSync(source.configPath)) { throw new Error(`Source config not found at ${source.configPath}.`); @@ -2925,20 +3290,24 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { const confirmed = opts.yes ? true : await p.confirm({ - message: `Overwrite the isolated Taskcore DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`, - initialValue: false, - }); + message: `Overwrite the isolated Taskcore DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`, + initialValue: false, + }); if (p.isCancel(confirmed) || !confirmed) { p.log.warn("Reseed cancelled."); return; } if (runningTargetPid && opts.allowLiveTarget) { - p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`); + p.log.warning( + `Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`, + ); } const spinner = p.spinner(); - spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`); + spinner.start( + `Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`, + ); try { const seeded = await seedWorktreeDatabase({ sourceConfigPath: source.configPath, @@ -2954,7 +3323,9 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`)); for (const rebound of seeded.reboundWorkspaces) { p.log.message( - pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), + pc.dim( + `Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`, + ), ); } p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`)); @@ -2964,19 +3335,25 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { } } -export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise { +export async function worktreeReseedCommand( + opts: WorktreeReseedOptions, +): Promise { printTaskcoreCliBanner(); p.intro(pc.bgCyan(pc.black(" taskcore worktree reseed "))); await runWorktreeReseed(opts); } -export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise { +export async function worktreeRepairCommand( + opts: WorktreeRepairOptions, +): Promise { printTaskcoreCliBanner(); p.intro(pc.bgCyan(pc.black(" taskcore worktree repair "))); const seedMode = opts.seedMode ?? "minimal"; if (!isWorktreeSeedMode(seedMode)) { - throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + throw new Error( + `Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`, + ); } const target = await ensureRepairTargetWorktree({ @@ -2985,7 +3362,9 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis opts, }); if (!target) { - p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree."); + p.log.warn( + "Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.", + ); p.outro(pc.yellow("No worktree repaired.")); return; } @@ -2995,18 +3374,31 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis throw new Error(`Source config not found at ${source.configPath}.`); } if (path.resolve(source.configPath) === path.resolve(target.configPath)) { - throw new Error("Source and target Taskcore configs are the same. Use --from-config/--from-instance to point repair at a different source."); + throw new Error( + "Source and target Taskcore configs are the same. Use --from-config/--from-instance to point repair at a different source.", + ); } - const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null; - const targetEnvEntries = readTaskcoreEnvEntries(resolveTaskcoreEnvFile(target.configPath)); + const targetConfig = existsSync(target.configPath) + ? readConfig(target.configPath) + : null; + const targetEnvEntries = readTaskcoreEnvEntries( + resolveTaskcoreEnvFile(target.configPath), + ); const targetHasWorktreeEnv = Boolean( - nonEmpty(targetEnvEntries.TASKCORE_HOME) && nonEmpty(targetEnvEntries.TASKCORE_INSTANCE_ID), + nonEmpty(targetEnvEntries.TASKCORE_HOME) && + nonEmpty(targetEnvEntries.TASKCORE_INSTANCE_ID), ); if (targetConfig && targetHasWorktreeEnv && opts.noSeed) { - p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`)); - p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`)); + p.log.message( + pc.dim( + `Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`, + ), + ); + p.outro( + pc.green(`Worktree metadata already looks healthy for ${target.label}.`), + ); return; } @@ -3021,20 +3413,26 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis return; } - const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath)); + const repairInstanceId = sanitizeWorktreeInstanceId( + path.basename(target.rootPath), + ); const repairPaths = resolveWorktreeLocalPaths({ cwd: target.rootPath, homeDir: resolveWorktreeHome(opts.home), instanceId: repairInstanceId, }); - const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid")); + const runningTargetPid = readRunningPostmasterPid( + path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"), + ); if (runningTargetPid && !opts.allowLiveTarget) { throw new Error( `Target worktree database appears to be running (pid ${runningTargetPid}). Stop Taskcore in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`, ); } if (runningTargetPid && opts.allowLiveTarget) { - p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`); + p.log.warning( + `Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`, + ); } const originalCwd = process.cwd(); @@ -3055,99 +3453,244 @@ export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promis } export function registerWorktreeCommands(program: Command): void { - const worktree = program.command("worktree").description("Worktree-local Taskcore instance helpers"); + const worktree = program + .command("worktree") + .description("Worktree-local Taskcore instance helpers"); program .command("worktree:make") - .description("Create ~/NAME as a git worktree, then initialize an isolated Taskcore instance inside it") - .argument("", "Worktree name — auto-prefixed with taskcore- if needed (created at ~/taskcore-NAME)") - .option("--start-point ", "Remote ref to base the new branch on (env: TASKCORE_WORKTREE_START_POINT)") + .description( + "Create ~/NAME as a git worktree, then initialize an isolated Taskcore instance inside it", + ) + .argument( + "", + "Worktree name — auto-prefixed with taskcore- if needed (created at ~/taskcore-NAME)", + ) + .option( + "--start-point ", + "Remote ref to base the new branch on (env: TASKCORE_WORKTREE_START_POINT)", + ) .option("--instance ", "Explicit isolated instance id") - .option("--home ", `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option( + "--home ", + `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`, + ) .option("--from-config ", "Source config.json to seed from") - .option("--from-data-dir ", "Source TASKCORE_HOME used when deriving the source config") - .option("--from-instance ", "Source instance id when deriving the source config", "default") - .option("--server-port ", "Preferred server port", (value) => Number(value)) - .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) - .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option( + "--from-data-dir ", + "Source TASKCORE_HOME used when deriving the source config", + ) + .option( + "--from-instance ", + "Source instance id when deriving the source config", + "default", + ) + .option("--server-port ", "Preferred server port", (value) => + Number(value), + ) + .option("--db-port ", "Preferred embedded Postgres port", (value) => + Number(value), + ) + .option( + "--seed-mode ", + "Seed profile: minimal or full (default: minimal)", + "minimal", + ) .option("--no-seed", "Skip database seeding from the source instance") - .option("--force", "Replace existing repo-local config and isolated instance data", false) + .option( + "--force", + "Replace existing repo-local config and isolated instance data", + false, + ) .action(worktreeMakeCommand); worktree .command("init") - .description("Create repo-local config/env and an isolated instance for this worktree") + .description( + "Create repo-local config/env and an isolated instance for this worktree", + ) .option("--name ", "Display name used to derive the instance id") .option("--instance ", "Explicit isolated instance id") - .option("--home ", `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option( + "--home ", + `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`, + ) .option("--from-config ", "Source config.json to seed from") - .option("--from-data-dir ", "Source TASKCORE_HOME used when deriving the source config") - .option("--from-instance ", "Source instance id when deriving the source config", "default") - .option("--server-port ", "Preferred server port", (value) => Number(value)) - .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) - .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option( + "--from-data-dir ", + "Source TASKCORE_HOME used when deriving the source config", + ) + .option( + "--from-instance ", + "Source instance id when deriving the source config", + "default", + ) + .option("--server-port ", "Preferred server port", (value) => + Number(value), + ) + .option("--db-port ", "Preferred embedded Postgres port", (value) => + Number(value), + ) + .option( + "--seed-mode ", + "Seed profile: minimal or full (default: minimal)", + "minimal", + ) .option("--no-seed", "Skip database seeding from the source instance") - .option("--force", "Replace existing repo-local config and isolated instance data", false) + .option( + "--force", + "Replace existing repo-local config and isolated instance data", + false, + ) .action(worktreeInitCommand); worktree .command("env") - .description("Print shell exports for the current worktree-local Taskcore instance") + .description( + "Print shell exports for the current worktree-local Taskcore instance", + ) .option("-c, --config ", "Path to config file") .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); program .command("worktree:list") - .description("List git worktrees visible from this repo and whether they look like Taskcore worktrees") + .description( + "List git worktrees visible from this repo and whether they look like Taskcore worktrees", + ) .option("--json", "Print JSON instead of text output") .action(worktreeListCommand); program .command("worktree:merge-history") - .description("Preview or import issue/comment history from another worktree into the current instance") - .argument("[source]", "Optional source worktree path, directory name, or branch name (back-compat alias for --from)") - .option("--from ", "Source worktree path, directory name, branch name, or current") - .option("--to ", "Target worktree path, directory name, branch name, or current (defaults to current)") - .option("--company ", "Shared company id or issue prefix inside the chosen source/target instances") - .option("--scope ", "Comma-separated scopes to import (issues, comments)", "issues,comments") + .description( + "Preview or import issue/comment history from another worktree into the current instance", + ) + .argument( + "[source]", + "Optional source worktree path, directory name, or branch name (back-compat alias for --from)", + ) + .option( + "--from ", + "Source worktree path, directory name, branch name, or current", + ) + .option( + "--to ", + "Target worktree path, directory name, branch name, or current (defaults to current)", + ) + .option( + "--company ", + "Shared company id or issue prefix inside the chosen source/target instances", + ) + .option( + "--scope ", + "Comma-separated scopes to import (issues, comments)", + "issues,comments", + ) .option("--apply", "Apply the import after previewing the plan", false) .option("--dry", "Preview only and do not import anything", false) - .option("--yes", "Skip the interactive confirmation prompt when applying", false) + .option( + "--yes", + "Skip the interactive confirmation prompt when applying", + false, + ) .action(worktreeMergeHistoryCommand); worktree .command("reseed") - .description("Re-seed an existing worktree-local instance from another Taskcore instance or worktree") - .option("--from ", "Source worktree path, directory name, branch name, or current") - .option("--to ", "Target worktree path, directory name, branch name, or current (defaults to current)") + .description( + "Re-seed an existing worktree-local instance from another Taskcore instance or worktree", + ) + .option( + "--from ", + "Source worktree path, directory name, branch name, or current", + ) + .option( + "--to ", + "Target worktree path, directory name, branch name, or current (defaults to current)", + ) .option("--from-config ", "Source config.json to seed from") - .option("--from-data-dir ", "Source TASKCORE_HOME used when deriving the source config") - .option("--from-instance ", "Source instance id when deriving the source config") - .option("--seed-mode ", "Seed profile: minimal or full (default: full)", "full") + .option( + "--from-data-dir ", + "Source TASKCORE_HOME used when deriving the source config", + ) + .option( + "--from-instance ", + "Source instance id when deriving the source config", + ) + .option( + "--seed-mode ", + "Seed profile: minimal or full (default: full)", + "full", + ) .option("--yes", "Skip the destructive confirmation prompt", false) - .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) + .option( + "--allow-live-target", + "Override the guard that requires the target worktree DB to be stopped first", + false, + ) .action(worktreeReseedCommand); worktree .command("repair") - .description("Create or repair a linked worktree-local Taskcore instance without touching the primary checkout") - .option("--branch ", "Existing branch/worktree selector to repair, or a branch name to create under .taskcore/worktrees") - .option("--home ", `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .description( + "Create or repair a linked worktree-local Taskcore instance without touching the primary checkout", + ) + .option( + "--branch ", + "Existing branch/worktree selector to repair, or a branch name to create under .taskcore/worktrees", + ) + .option( + "--home ", + `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`, + ) .option("--from-config ", "Source config.json to seed from") - .option("--from-data-dir ", "Source TASKCORE_HOME used when deriving the source config") - .option("--from-instance ", "Source instance id when deriving the source config (default: default)") - .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") - .option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false) - .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) + .option( + "--from-data-dir ", + "Source TASKCORE_HOME used when deriving the source config", + ) + .option( + "--from-instance ", + "Source instance id when deriving the source config (default: default)", + ) + .option( + "--seed-mode ", + "Seed profile: minimal or full (default: minimal)", + "minimal", + ) + .option( + "--no-seed", + "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", + false, + ) + .option( + "--allow-live-target", + "Override the guard that requires the target worktree DB to be stopped first", + false, + ) .action(worktreeRepairCommand); program .command("worktree:cleanup") - .description("Safely remove a worktree, its branch, and its isolated instance data") - .argument("", "Worktree name — auto-prefixed with taskcore- if needed") - .option("--instance ", "Explicit instance id (if different from the worktree name)") - .option("--home ", `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) - .option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false) + .description( + "Safely remove a worktree, its branch, and its isolated instance data", + ) + .argument( + "", + "Worktree name — auto-prefixed with taskcore- if needed", + ) + .option( + "--instance ", + "Explicit instance id (if different from the worktree name)", + ) + .option( + "--home ", + `Home root for worktree instances (env: TASKCORE_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`, + ) + .option( + "--force", + "Bypass safety checks (uncommitted changes, unique commits)", + false, + ) .action(worktreeCleanupCommand); } diff --git a/cli/src/config/data-dir.ts b/cli/src/config/data-dir.ts index 2582577..56fc290 100644 --- a/cli/src/config/data-dir.ts +++ b/cli/src/config/data-dir.ts @@ -29,7 +29,9 @@ export function applyDataDirOverride( process.env.TASKCORE_HOME = resolvedDataDir; if (support.hasConfigOption) { - const hasConfigOverride = Boolean(options.config?.trim()) || Boolean(process.env.TASKCORE_CONFIG?.trim()); + const hasConfigOverride = + Boolean(options.config?.trim()) || + Boolean(process.env.TASKCORE_CONFIG?.trim()); if (!hasConfigOverride) { const instanceId = resolveTaskcoreInstanceId(options.instance); process.env.TASKCORE_INSTANCE_ID = instanceId; @@ -38,7 +40,9 @@ export function applyDataDirOverride( } if (support.hasContextOption) { - const hasContextOverride = Boolean(options.context?.trim()) || Boolean(process.env.TASKCORE_CONTEXT?.trim()); + const hasContextOverride = + Boolean(options.context?.trim()) || + Boolean(process.env.TASKCORE_CONTEXT?.trim()); if (!hasContextOverride) { process.env.TASKCORE_CONTEXT = resolveDefaultContextPath(); } diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index a5ba451..78ad554 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -33,7 +33,9 @@ function renderEnvFile(entries: Record) { const lines = [ "# Taskcore environment variables", "# Generated by Taskcore CLI commands", - ...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`), + ...Object.entries(entries).map( + ([key, value]) => `${key}=${formatEnvValue(value)}`, + ), "", ]; return lines.join("\n"); @@ -65,7 +67,9 @@ export function readAgentJwtSecretFromEnv(configPath?: string): string | null { return isNonEmpty(raw) ? raw!.trim() : null; } -export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null { +export function readAgentJwtSecretFromEnvFile( + filePath = resolveEnvFilePath(), +): string | null { if (!fs.existsSync(filePath)) return null; const raw = fs.readFileSync(filePath, "utf-8"); @@ -74,7 +78,10 @@ export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): return isNonEmpty(value) ? value!.trim() : null; } -export function ensureAgentJwtSecret(configPath?: string): { secret: string; created: boolean } { +export function ensureAgentJwtSecret(configPath?: string): { + secret: string; + created: boolean; +} { const existingEnv = readAgentJwtSecretFromEnv(configPath); if (existingEnv) { return { secret: existingEnv, created: false }; @@ -92,16 +99,24 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre return { secret, created }; } -export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { +export function writeAgentJwtEnv( + secret: string, + filePath = resolveEnvFilePath(), +): void { mergeTaskcoreEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath); } -export function readTaskcoreEnvEntries(filePath = resolveEnvFilePath()): Record { +export function readTaskcoreEnvEntries( + filePath = resolveEnvFilePath(), +): Record { if (!fs.existsSync(filePath)) return {}; return parseEnvFile(fs.readFileSync(filePath, "utf-8")); } -export function writeTaskcoreEnvEntries(entries: Record, filePath = resolveEnvFilePath()): void { +export function writeTaskcoreEnvEntries( + entries: Record, + filePath = resolveEnvFilePath(), +): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, renderEnvFile(entries), { @@ -117,7 +132,9 @@ export function mergeTaskcoreEnvEntries( const next = { ...current, ...Object.fromEntries( - Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0), + Object.entries(entries).filter( + ([, value]) => typeof value === "string" && value.trim().length > 0, + ), ), }; writeTaskcoreEnvEntries(next, filePath); diff --git a/cli/src/config/home.ts b/cli/src/config/home.ts index ae46ac5..81f7923 100644 --- a/cli/src/config/home.ts +++ b/cli/src/config/home.ts @@ -11,7 +11,10 @@ export function resolveTaskcoreHomeDir(): string { } export function resolveTaskcoreInstanceId(override?: string): string { - const raw = override?.trim() || process.env.TASKCORE_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; + const raw = + override?.trim() || + process.env.TASKCORE_INSTANCE_ID?.trim() || + DEFAULT_INSTANCE_ID; if (!INSTANCE_ID_RE.test(raw)) { throw new Error( `Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`, @@ -46,15 +49,27 @@ export function resolveDefaultLogsDir(instanceId?: string): string { } export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string { - return path.resolve(resolveTaskcoreInstanceRoot(instanceId), "secrets", "master.key"); + return path.resolve( + resolveTaskcoreInstanceRoot(instanceId), + "secrets", + "master.key", + ); } export function resolveDefaultStorageDir(instanceId?: string): string { - return path.resolve(resolveTaskcoreInstanceRoot(instanceId), "data", "storage"); + return path.resolve( + resolveTaskcoreInstanceRoot(instanceId), + "data", + "storage", + ); } export function resolveDefaultBackupDir(instanceId?: string): string { - return path.resolve(resolveTaskcoreInstanceRoot(instanceId), "data", "backups"); + return path.resolve( + resolveTaskcoreInstanceRoot(instanceId), + "data", + "backups", + ); } export function expandHomePrefix(value: string): string { @@ -71,7 +86,8 @@ export function describeLocalInstancePaths(instanceId?: string) { instanceId: resolvedInstanceId, instanceRoot, configPath: resolveDefaultConfigPath(resolvedInstanceId), - embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId), + embeddedPostgresDataDir: + resolveDefaultEmbeddedPostgresDir(resolvedInstanceId), backupDir: resolveDefaultBackupDir(resolvedInstanceId), logDir: resolveDefaultLogsDir(resolvedInstanceId), secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId), diff --git a/cli/src/config/hostnames.ts b/cli/src/config/hostnames.ts index b788cbb..76c7b31 100644 --- a/cli/src/config/hostnames.ts +++ b/cli/src/config/hostnames.ts @@ -5,7 +5,9 @@ export function normalizeHostnameInput(raw: string): string { } try { - const url = input.includes("://") ? new URL(input) : new URL(`http://${input}`); + const url = input.includes("://") + ? new URL(input) + : new URL(`http://${input}`); const hostname = url.hostname.trim().toLowerCase(); if (!hostname) throw new Error("Hostname is required"); return hostname; @@ -23,4 +25,3 @@ export function parseHostnameCsv(raw: string): string[] { } return Array.from(unique); } - diff --git a/cli/src/config/server-bind.ts b/cli/src/config/server-bind.ts index b00ae85..82bf155 100644 --- a/cli/src/config/server-bind.ts +++ b/cli/src/config/server-bind.ts @@ -72,12 +72,14 @@ export function buildPresetServerConfig( }; } -export function buildCustomServerConfig(input: BaseServerInput & { - deploymentMode: DeploymentMode; - exposure: DeploymentExposure; - host: string; - publicBaseUrl?: string; -}): { server: ServerConfig; auth: AuthConfig } { +export function buildCustomServerConfig( + input: BaseServerInput & { + deploymentMode: DeploymentMode; + exposure: DeploymentExposure; + host: string; + publicBaseUrl?: string; + }, +): { server: ServerConfig; auth: AuthConfig } { const normalizedHost = input.host.trim(); const bind = isLoopbackHost(normalizedHost) ? "loopback" @@ -88,7 +90,8 @@ export function buildCustomServerConfig(input: BaseServerInput & { return { server: { deploymentMode: input.deploymentMode, - exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure, + exposure: + input.deploymentMode === "local_trusted" ? "private" : input.exposure, bind, customBindHost: bind === "custom" ? normalizedHost : undefined, host: normalizedHost, @@ -99,14 +102,14 @@ export function buildCustomServerConfig(input: BaseServerInput & { auth: input.deploymentMode === "authenticated" && input.exposure === "public" ? { - baseUrlMode: "explicit", - disableSignUp: false, - publicBaseUrl: input.publicBaseUrl, - } + baseUrlMode: "explicit", + disableSignUp: false, + publicBaseUrl: input.publicBaseUrl, + } : { - baseUrlMode: "auto", - disableSignUp: false, - }, + baseUrlMode: "auto", + disableSignUp: false, + }, }; } @@ -123,7 +126,11 @@ export function resolveQuickstartServerConfig(input: { const trimmedHost = input.host?.trim(); const explicitBind = input.bind ?? null; - if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") { + if ( + explicitBind === "loopback" || + explicitBind === "lan" || + explicitBind === "tailnet" + ) { return buildPresetServerConfig(explicitBind, { port: input.port, allowedHostnames: input.allowedHostnames, @@ -145,7 +152,9 @@ export function resolveQuickstartServerConfig(input: { if (trimmedHost) { return buildCustomServerConfig({ - deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"), + deploymentMode: + input.deploymentMode ?? + (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"), exposure: input.exposure ?? "private", host: trimmedHost, port: input.port, diff --git a/cli/src/config/store.ts b/cli/src/config/store.ts index 2437da3..d661851 100644 --- a/cli/src/config/store.ts +++ b/cli/src/config/store.ts @@ -1,10 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { taskcoreConfigSchema, type TaskcoreConfig } from "./schema.js"; -import { - resolveDefaultConfigPath, - resolveTaskcoreInstanceId, -} from "./home.js"; +import { resolveDefaultConfigPath, resolveTaskcoreInstanceId } from "./home.js"; const DEFAULT_CONFIG_BASENAME = "config.json"; @@ -13,7 +10,11 @@ function findConfigFileFromAncestors(startDir: string): string | null { let currentDir = absoluteStartDir; while (true) { - const candidate = path.resolve(currentDir, ".taskcore", DEFAULT_CONFIG_BASENAME); + const candidate = path.resolve( + currentDir, + ".taskcore", + DEFAULT_CONFIG_BASENAME, + ); if (fs.existsSync(candidate)) { return candidate; } @@ -28,15 +29,21 @@ function findConfigFileFromAncestors(startDir: string): string | null { export function resolveConfigPath(overridePath?: string): string { if (overridePath) return path.resolve(overridePath); - if (process.env.TASKCORE_CONFIG) return path.resolve(process.env.TASKCORE_CONFIG); - return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolveTaskcoreInstanceId()); + if (process.env.TASKCORE_CONFIG) + return path.resolve(process.env.TASKCORE_CONFIG); + return ( + findConfigFileFromAncestors(process.cwd()) ?? + resolveDefaultConfigPath(resolveTaskcoreInstanceId()) + ); } function parseJson(filePath: string): unknown { try { return JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (err) { - throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -44,7 +51,11 @@ function migrateLegacyConfig(raw: unknown): unknown { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw; const config = { ...(raw as Record) }; const databaseRaw = config.database; - if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) { + if ( + typeof databaseRaw !== "object" || + databaseRaw === null || + Array.isArray(databaseRaw) + ) { return config; } @@ -52,7 +63,10 @@ function migrateLegacyConfig(raw: unknown): unknown { if (database.mode === "pglite") { database.mode = "embedded-postgres"; - if (typeof database.embeddedPostgresDataDir !== "string" && typeof database.pgliteDataDir === "string") { + if ( + typeof database.embeddedPostgresDataDir !== "string" && + typeof database.pgliteDataDir === "string" + ) { database.embeddedPostgresDataDir = database.pgliteDataDir; } if ( @@ -69,13 +83,18 @@ function migrateLegacyConfig(raw: unknown): unknown { } function formatValidationError(err: unknown): string { - const issues = (err as { issues?: Array<{ path?: unknown; message?: unknown }> })?.issues; + const issues = ( + err as { issues?: Array<{ path?: unknown; message?: unknown }> } + )?.issues; if (Array.isArray(issues) && issues.length > 0) { return issues .map((issue) => { - const pathParts = Array.isArray(issue.path) ? issue.path.map(String) : []; + const pathParts = Array.isArray(issue.path) + ? issue.path.map(String) + : []; const issuePath = pathParts.length > 0 ? pathParts.join(".") : "config"; - const message = typeof issue.message === "string" ? issue.message : "Invalid value"; + const message = + typeof issue.message === "string" ? issue.message : "Invalid value"; return `${issuePath}: ${message}`; }) .join("; "); @@ -90,15 +109,14 @@ export function readConfig(configPath?: string): TaskcoreConfig | null { const migrated = migrateLegacyConfig(raw); const parsed = taskcoreConfigSchema.safeParse(migrated); if (!parsed.success) { - throw new Error(`Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`); + throw new Error( + `Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`, + ); } return parsed.data; } -export function writeConfig( - config: TaskcoreConfig, - configPath?: string, -): void { +export function writeConfig(config: TaskcoreConfig, configPath?: string): void { const filePath = resolveConfigPath(configPath); const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); diff --git a/cli/src/index.ts b/cli/src/index.ts index b1e9f58..2e04d7a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -17,7 +17,10 @@ import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { registerRoutineCommands } from "./commands/routines.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js"; -import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; +import { + applyDataDirOverride, + type DataDirOptionLike, +} from "./config/data-dir.js"; import { loadTaskcoreEnvFile } from "./config/env.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; @@ -36,7 +39,9 @@ program program.hook("preAction", (_thisCommand, actionCommand) => { const options = actionCommand.optsWithGlobals() as DataDirOptionLike; - const optionNames = new Set(actionCommand.options.map((option) => option.attributeName())); + const optionNames = new Set( + actionCommand.options.map((option) => option.attributeName()), + ); applyDataDirOverride(options, { hasConfigOption: optionNames.has("config"), hasContextOption: optionNames.has("context"), @@ -50,8 +55,15 @@ program .description("Interactive first-run setup wizard") .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) - .option("--bind ", "Quickstart reachability preset (loopback, lan, tailnet)") - .option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false) + .option( + "--bind ", + "Quickstart reachability preset (loopback, lan, tailnet)", + ) + .option( + "-y, --yes", + "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", + false, + ) .option("--run", "Start Taskcore immediately after saving config", false) .action(onboard); @@ -79,7 +91,10 @@ program .description("Update configuration sections") .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) - .option("-s, --section
", "Section to configure (llm, database, logging, server, storage, secrets)") + .option( + "-s, --section
", + "Section to configure (llm, database, logging, server, storage, secrets)", + ) .action(configure); program @@ -88,7 +103,11 @@ program .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("--dir ", "Backup output directory (overrides config)") - .option("--retention-days ", "Retention window used for pruning", (value) => Number(value)) + .option( + "--retention-days ", + "Retention window used for pruning", + (value) => Number(value), + ) .option("--filename-prefix ", "Backup filename prefix", "taskcore") .option("--json", "Print backup metadata as JSON") .action(async (opts) => { @@ -109,12 +128,17 @@ program .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("-i, --instance ", "Local instance id (default: default)") - .option("--bind ", "On first run, use onboarding reachability preset (loopback, lan, tailnet)") + .option( + "--bind ", + "On first run, use onboarding reachability preset (loopback, lan, tailnet)", + ) .option("--repair", "Attempt automatic repairs during doctor", true) .option("--no-repair", "Disable automatic repairs during doctor") .action(runCommand); -const heartbeat = program.command("heartbeat").description("Heartbeat utilities"); +const heartbeat = program + .command("heartbeat") + .description("Heartbeat utilities"); heartbeat .command("run") @@ -131,7 +155,11 @@ heartbeat "Invocation source (timer | assignment | on_demand | automation)", "on_demand", ) - .option("--trigger ", "Trigger detail (manual | ping | callback | system)", "manual") + .option( + "--trigger ", + "Trigger detail (manual | ping | callback | system)", + "manual", + ) .option("--timeout-ms ", "Max time to wait before giving up", "0") .option("--json", "Output raw JSON where applicable") .option("--debug", "Show raw adapter stdout/stderr JSON chunks") @@ -149,15 +177,23 @@ registerFeedbackCommands(program); registerWorktreeCommands(program); registerPluginCommands(program); -const auth = program.command("auth").description("Authentication and bootstrap utilities"); +const auth = program + .command("auth") + .description("Authentication and bootstrap utilities"); auth .command("bootstrap-ceo") - .description("Create a one-time bootstrap invite URL for first instance admin") + .description( + "Create a one-time bootstrap invite URL for first instance admin", + ) .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("--force", "Create new invite even if admin already exists", false) - .option("--expires-hours ", "Invite expiration window in hours", (value) => Number(value)) + .option( + "--expires-hours ", + "Invite expiration window in hours", + (value) => Number(value), + ) .option("--base-url ", "Public base URL used to print invite link") .action(bootstrapCeoInvite); diff --git a/cli/src/prompts/database.ts b/cli/src/prompts/database.ts index 172234c..0da868b 100644 --- a/cli/src/prompts/database.ts +++ b/cli/src/prompts/database.ts @@ -6,7 +6,9 @@ import { resolveTaskcoreInstanceId, } from "../config/home.js"; -export async function promptDatabase(current?: DatabaseConfig): Promise { +export async function promptDatabase( + current?: DatabaseConfig, +): Promise { const instanceId = resolveTaskcoreInstanceId(); const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(instanceId); const defaultBackupDir = resolveDefaultBackupDir(instanceId); @@ -25,7 +27,11 @@ export async function promptDatabase(current?: DatabaseConfig): Promise { if (!val) return "Connection string is required for PostgreSQL mode"; - if (!val.startsWith("postgres")) return "Must be a postgres:// or postgresql:// URL"; + if (!val.startsWith("postgres")) + return "Must be a postgres:// or postgresql:// URL"; }, }); @@ -77,7 +85,8 @@ export async function promptDatabase(current?: DatabaseConfig): Promise { const n = Number(val); - if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535"; + if (!Number.isInteger(n) || n < 1 || n > 65535) + return "Port must be an integer between 1 and 65535"; }, }); @@ -103,7 +112,10 @@ export async function promptDatabase(current?: DatabaseConfig): Promise (!val || val.trim().length === 0 ? "Backup directory is required" : undefined), + validate: (val) => + !val || val.trim().length === 0 + ? "Backup directory is required" + : undefined, }); if (p.isCancel(backupDirInput)) { p.cancel("Setup cancelled."); @@ -116,7 +128,8 @@ export async function promptDatabase(current?: DatabaseConfig): Promise { const n = Number(val); - if (!Number.isInteger(n) || n < 1) return "Interval must be a positive integer"; + if (!Number.isInteger(n) || n < 1) + return "Interval must be a positive integer"; if (n > 10080) return "Interval must be 10080 minutes (7 days) or less"; return undefined; }, @@ -132,7 +145,8 @@ export async function promptDatabase(current?: DatabaseConfig): Promise { const n = Number(val); - if (!Number.isInteger(n) || n < 1) return "Retention must be a positive integer"; + if (!Number.isInteger(n) || n < 1) + return "Retention must be a positive integer"; if (n > 3650) return "Retention must be 3650 days or less"; return undefined; }, diff --git a/cli/src/prompts/logging.ts b/cli/src/prompts/logging.ts index d5508d9..888aabe 100644 --- a/cli/src/prompts/logging.ts +++ b/cli/src/prompts/logging.ts @@ -1,13 +1,20 @@ import * as p from "@clack/prompts"; import type { LoggingConfig } from "../config/schema.js"; -import { resolveDefaultLogsDir, resolveTaskcoreInstanceId } from "../config/home.js"; +import { + resolveDefaultLogsDir, + resolveTaskcoreInstanceId, +} from "../config/home.js"; export async function promptLogging(): Promise { const defaultLogDir = resolveDefaultLogsDir(resolveTaskcoreInstanceId()); const mode = await p.select({ message: "Logging mode", options: [ - { value: "file" as const, label: "File-based logging", hint: "recommended" }, + { + value: "file" as const, + label: "File-based logging", + hint: "recommended", + }, { value: "cloud" as const, label: "Cloud logging", hint: "coming soon" }, ], }); diff --git a/cli/src/prompts/secrets.ts b/cli/src/prompts/secrets.ts index 2f8622f..0a7d929 100644 --- a/cli/src/prompts/secrets.ts +++ b/cli/src/prompts/secrets.ts @@ -1,7 +1,10 @@ import * as p from "@clack/prompts"; import type { SecretProvider } from "@taskcore/shared"; import type { SecretsConfig } from "../config/schema.js"; -import { resolveDefaultSecretsKeyFilePath, resolveTaskcoreInstanceId } from "../config/home.js"; +import { + resolveDefaultSecretsKeyFilePath, + resolveTaskcoreInstanceId, +} from "../config/home.js"; function defaultKeyFilePath(): string { return resolveDefaultSecretsKeyFilePath(resolveTaskcoreInstanceId()); @@ -18,7 +21,9 @@ export function defaultSecretsConfig(): SecretsConfig { }; } -export async function promptSecrets(current?: SecretsConfig): Promise { +export async function promptSecrets( + current?: SecretsConfig, +): Promise { const base = current ?? defaultSecretsConfig(); const provider = await p.select({ @@ -71,7 +76,8 @@ export async function promptSecrets(current?: SecretsConfig): Promise { - if (!value || value.trim().length === 0) return "Key file path is required"; + if (!value || value.trim().length === 0) + return "Key file path is required"; }, }); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 9b4439e..7470014 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -2,7 +2,11 @@ import * as p from "@clack/prompts"; import { isLoopbackHost, type BindMode } from "@taskcore/shared"; import type { AuthConfig, ServerConfig } from "../config/schema.js"; import { parseHostnameCsv } from "../config/hostnames.js"; -import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js"; +import { + buildCustomServerConfig, + buildPresetServerConfig, + inferConfiguredBind, +} from "../config/server-bind.js"; const TAILNET_BIND_WARNING = "No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or TASKCORE_TAILNET_BIND_HOST is set."; @@ -123,7 +127,8 @@ export async function promptServer(opts?: { }); if (p.isCancel(deploymentModeSelection)) cancelled(); - const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"]; + const deploymentMode = + deploymentModeSelection as ServerConfig["deploymentMode"]; let exposure: ServerConfig["exposure"] = "private"; if (deploymentMode === "authenticated") { @@ -193,7 +198,8 @@ export async function promptServer(opts?: { placeholder: "https://taskcore.example.com", validate: (val) => { const candidate = val.trim(); - if (!candidate) return "Public base URL is required for public exposure"; + if (!candidate) + return "Public base URL is required for public exposure"; try { const url = new URL(candidate); if (url.protocol !== "http:" && url.protocol !== "https:") { diff --git a/cli/src/prompts/storage.ts b/cli/src/prompts/storage.ts index a91f422..cfabda5 100644 --- a/cli/src/prompts/storage.ts +++ b/cli/src/prompts/storage.ts @@ -1,6 +1,9 @@ import * as p from "@clack/prompts"; import type { StorageConfig } from "../config/schema.js"; -import { resolveDefaultStorageDir, resolveTaskcoreInstanceId } from "../config/home.js"; +import { + resolveDefaultStorageDir, + resolveTaskcoreInstanceId, +} from "../config/home.js"; function defaultStorageBaseDir(): string { return resolveDefaultStorageDir(resolveTaskcoreInstanceId()); @@ -22,7 +25,9 @@ export function defaultStorageConfig(): StorageConfig { }; } -export async function promptStorage(current?: StorageConfig): Promise { +export async function promptStorage( + current?: StorageConfig, +): Promise { const base = current ?? defaultStorageConfig(); const provider = await p.select({ @@ -53,7 +58,8 @@ export async function promptStorage(current?: StorageConfig): Promise { - if (!value || value.trim().length === 0) return "Storage base directory is required"; + if (!value || value.trim().length === 0) + return "Storage base directory is required"; }, }); @@ -143,4 +149,3 @@ export async function promptStorage(current?: StorageConfig): Promise loadOrCreateState(stateDir, cliVersion), cliVersion); + client = new TelemetryClient( + config, + () => loadOrCreateState(stateDir, cliVersion), + cliVersion, + ); return client; } -export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null { +export function initTelemetryFromConfigFile( + configPath?: string, +): TelemetryClient | null { try { return initTelemetry(readConfig(configPath)?.telemetry); } catch { @@ -42,8 +50,4 @@ export async function flushTelemetry(): Promise { } } -export { - trackInstallStarted, - trackInstallCompleted, - trackCompanyImported, -}; +export { trackInstallStarted, trackInstallCompleted, trackCompanyImported }; diff --git a/cli/src/utils/net.ts b/cli/src/utils/net.ts index 0b5597b..03d545e 100644 --- a/cli/src/utils/net.ts +++ b/cli/src/utils/net.ts @@ -1,6 +1,8 @@ import net from "node:net"; -export function checkPort(port: number): Promise<{ available: boolean; error?: string }> { +export function checkPort( + port: number, +): Promise<{ available: boolean; error?: string }> { return new Promise((resolve) => { const server = net.createServer(); server.once("error", (err: NodeJS.ErrnoException) => { diff --git a/cli/src/utils/path-resolver.ts b/cli/src/utils/path-resolver.ts index d8ebbd0..c6cfde2 100644 --- a/cli/src/utils/path-resolver.ts +++ b/cli/src/utils/path-resolver.ts @@ -6,7 +6,10 @@ function unique(items: string[]): string[] { return Array.from(new Set(items)); } -export function resolveRuntimeLikePath(value: string, configPath?: string): string { +export function resolveRuntimeLikePath( + value: string, + configPath?: string, +): string { const expanded = expandHomePrefix(value); if (path.isAbsolute(expanded)) return path.resolve(expanded); @@ -21,5 +24,7 @@ export function resolveRuntimeLikePath(value: string, configPath?: string): stri path.resolve(cwd, expanded), ]); - return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; + return ( + candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] + ); } diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index fa1e08a..e118bad 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -12,104 +12,104 @@ Use it when you need to: ## 1. Specification & Design Documents -| File | Role | -|---|---| -| `docs/companies/companies-spec.md` | **Normative spec** — defines the markdown-first package format (COMPANY.md, TEAM.md, AGENTS.md, PROJECT.md, TASK.md, SKILL.md), reserved files, frontmatter schemas, and vendor extension conventions (`.taskcore.yaml`). | -| `doc/plans/2026-03-13-company-import-export-v2.md` | Implementation plan for the markdown-first package model cutover — phases, API changes, UI plan, and rollout strategy. | -| `doc/SPEC-implementation.md` | V1 implementation contract; references the portability system and `.taskcore.yaml` sidecar format. | -| `docs/specs/cliphub-plan.md` | Earlier blueprint bundle plan; partially superseded by the markdown-first spec (noted in the v2 plan). | -| `doc/plans/2026-02-16-module-system.md` | Module system plan; JSON-only company template sections superseded by the markdown-first model. | -| `doc/plans/2026-03-14-skills-ui-product-plan.md` | Skills UI plan; references portable skill files and `.taskcore.yaml`. | -| `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` | Adapter skill sync rollout; companion to the v2 import/export plan. | +| File | Role | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/companies/companies-spec.md` | **Normative spec** — defines the markdown-first package format (COMPANY.md, TEAM.md, AGENTS.md, PROJECT.md, TASK.md, SKILL.md), reserved files, frontmatter schemas, and vendor extension conventions (`.taskcore.yaml`). | +| `doc/plans/2026-03-13-company-import-export-v2.md` | Implementation plan for the markdown-first package model cutover — phases, API changes, UI plan, and rollout strategy. | +| `doc/SPEC-implementation.md` | V1 implementation contract; references the portability system and `.taskcore.yaml` sidecar format. | +| `docs/specs/cliphub-plan.md` | Earlier blueprint bundle plan; partially superseded by the markdown-first spec (noted in the v2 plan). | +| `doc/plans/2026-02-16-module-system.md` | Module system plan; JSON-only company template sections superseded by the markdown-first model. | +| `doc/plans/2026-03-14-skills-ui-product-plan.md` | Skills UI plan; references portable skill files and `.taskcore.yaml`. | +| `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` | Adapter skill sync rollout; companion to the v2 import/export plan. | ## 2. Shared Types & Validators These define the contract between server, CLI, and UI. -| File | What it defines | -|---|---| -| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. | -| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. | -| `packages/shared/src/types/index.ts` | Re-exports portability types. | -| `packages/shared/src/validators/index.ts` | Re-exports portability validators. | +| File | What it defines | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. | +| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. | +| `packages/shared/src/types/index.ts` | Re-exports portability types. | +| `packages/shared/src/validators/index.ts` | Re-exports portability validators. | ## 3. Server — Services -| File | Responsibility | -|---|---| -| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.taskcore.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. | -| `server/src/services/routines.ts` | Taskcore routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. | -| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. | -| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. | +| File | Responsibility | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.taskcore.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. | +| `server/src/services/routines.ts` | Taskcore routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. | +| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. | +| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. | ## 4. Server — Routes -| File | Endpoints | -|---|---| +| File | Endpoints | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/src/routes/companies.ts` | `POST /api/companies/:companyId/export` — legacy export bundle
`POST /api/companies/:companyId/exports/preview` — export preview
`POST /api/companies/:companyId/exports` — export package
`POST /api/companies/import/preview` — import preview
`POST /api/companies/import` — perform import | Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`. ## 5. Server — Tests -| File | Coverage | -|---|---| -| `server/src/__tests__/company-portability.test.ts` | Unit tests for the portability service (export, import, preview, manifest shape, `agentcompanies/v1` version). | -| `server/src/__tests__/company-portability-routes.test.ts` | Integration tests for the portability HTTP endpoints. | +| File | Coverage | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `server/src/__tests__/company-portability.test.ts` | Unit tests for the portability service (export, import, preview, manifest shape, `agentcompanies/v1` version). | +| `server/src/__tests__/company-portability-routes.test.ts` | Integration tests for the portability HTTP endpoints. | ## 6. CLI -| File | Commands | -|---|---| +| File | Commands | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).
Reads/writes portable file entries and handles `.taskcore.yaml` filtering. | ## 7. UI — Pages -| File | Role | -|---|---| -| `ui/src/pages/CompanyExport.tsx` | Export UI: preview, manifest display, file tree visualization, ZIP archive creation and download. Filters `.taskcore.yaml` based on selection. Shows manifest and README in editor. | +| File | Role | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ui/src/pages/CompanyExport.tsx` | Export UI: preview, manifest display, file tree visualization, ZIP archive creation and download. Filters `.taskcore.yaml` based on selection. Shows manifest and README in editor. | | `ui/src/pages/CompanyImport.tsx` | Import UI: source input (upload/folder/GitHub URL/generic URL), ZIP reading, preview pane with dependency tree, entity selection checkboxes, trust/licensing warnings, secrets requirements, collision strategy, adapter config. | ## 8. UI — Components -| File | Role | -|---|---| +| File | Role | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ui/src/components/PackageFileTree.tsx` | Reusable file tree component for both import and export. Builds tree from `CompanyPortabilityFileEntry` items, parses frontmatter, shows action indicators (create/update/skip), and maps frontmatter field labels. | ## 9. UI — Libraries -| File | Role | -|---|---| -| `ui/src/lib/portable-files.ts` | Helpers for portable file entries: `getPortableFileText`, `getPortableFileDataUrl`, `getPortableFileContentType`, `isPortableImageFile`. | -| `ui/src/lib/zip.ts` | ZIP archive creation (`createZipArchive`) and reading (`readZipArchive`) — implements ZIP format from scratch for company packages. CRC32, DOS date/time encoding. | -| `ui/src/lib/zip.test.ts` | Tests for ZIP utilities; exercises round-trip with portability file entries and `.taskcore.yaml` content. | +| File | Role | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ui/src/lib/portable-files.ts` | Helpers for portable file entries: `getPortableFileText`, `getPortableFileDataUrl`, `getPortableFileContentType`, `isPortableImageFile`. | +| `ui/src/lib/zip.ts` | ZIP archive creation (`createZipArchive`) and reading (`readZipArchive`) — implements ZIP format from scratch for company packages. CRC32, DOS date/time encoding. | +| `ui/src/lib/zip.test.ts` | Tests for ZIP utilities; exercises round-trip with portability file entries and `.taskcore.yaml` content. | ## 10. UI — API Client -| File | Functions | -|---|---| +| File | Functions | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ui/src/api/companies.ts` | `companiesApi.exportBundle`, `companiesApi.exportPreview`, `companiesApi.exportPackage`, `companiesApi.importPreview`, `companiesApi.importBundle` — typed fetch wrappers for the portability endpoints. | ## 11. Skills & Agent Instructions -| File | Relevance | -|---|---| +| File | Relevance | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `skills/taskcore/references/company-skills.md` | Reference doc for company skill library workflow — install, inspect, update, assign. Skill packages are a subset of the agent companies spec. | -| `server/src/services/company-skills.ts` | Company skill management service — handles SKILL.md-based imports and company-level skill library. | -| `server/src/services/agent-instructions.ts` | Agent instructions service — resolves AGENTS.md paths for agent instruction loading. | +| `server/src/services/company-skills.ts` | Company skill management service — handles SKILL.md-based imports and company-level skill library. | +| `server/src/services/agent-instructions.ts` | Agent instructions service — resolves AGENTS.md paths for agent instruction loading. | ## 12. Quick Cross-Reference by Spec Concept -| Spec concept | Primary implementation files | -|---|---| -| `COMPANY.md` frontmatter & body | `company-portability.ts` (export emitter + import parser) | -| `AGENTS.md` frontmatter & body | `company-portability.ts`, `agent-instructions.ts` | -| `PROJECT.md` frontmatter & body | `company-portability.ts` | -| `TASK.md` frontmatter & body | `company-portability.ts` | -| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` | +| Spec concept | Primary implementation files | +| ------------------------------- | -------------------------------------------------------------------------------- | +| `COMPANY.md` frontmatter & body | `company-portability.ts` (export emitter + import parser) | +| `AGENTS.md` frontmatter & body | `company-portability.ts`, `agent-instructions.ts` | +| `PROJECT.md` frontmatter & body | `company-portability.ts` | +| `TASK.md` frontmatter & body | `company-portability.ts` | +| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` | | `.taskcore.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) | -| `manifest.json` | `company-portability.ts` (generation), shared types (schema) | -| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) | -| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) | -| Env/secrets declarations | shared types (`CompanyPortabilityEnvInput`), `CompanyImport.tsx` (UI) | -| README + org chart | `company-export-readme.ts` | +| `manifest.json` | `company-portability.ts` (generation), shared types (schema) | +| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) | +| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) | +| Env/secrets declarations | shared types (`CompanyPortabilityEnvInput`), `CompanyImport.tsx` (UI) | +| README + org chart | `company-export-readme.ts` | diff --git a/doc/CLIPHUB.md b/doc/CLIPHUB.md index 4cb5a67..75c6749 100644 --- a/doc/CLIPHUB.md +++ b/doc/CLIPHUB.md @@ -20,14 +20,14 @@ The tagline: **you can literally download a company.** A ClipHub package is a **company template export** — the portable artifact format defined in the Taskcore spec. It contains: -| Component | Description | -|---|---| -| **Company metadata** | Name, description, intended use case, category | -| **Org chart** | Full reporting hierarchy — who reports to whom | -| **Agent definitions** | Every agent: name, role, title, capabilities description | -| **Adapter configs** | Per-agent adapter type and configuration (SOUL.md, HEARTBEAT.md, CLAUDE.md, process commands, webhook URLs — whatever the adapter needs) | -| **Seed tasks** | Optional starter tasks and initiatives to bootstrap the company's first run | -| **Budget defaults** | Suggested token/cost budgets per agent and per company | +| Component | Description | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Company metadata** | Name, description, intended use case, category | +| **Org chart** | Full reporting hierarchy — who reports to whom | +| **Agent definitions** | Every agent: name, role, title, capabilities description | +| **Adapter configs** | Per-agent adapter type and configuration (SOUL.md, HEARTBEAT.md, CLAUDE.md, process commands, webhook URLs — whatever the adapter needs) | +| **Seed tasks** | Optional starter tasks and initiatives to bootstrap the company's first run | +| **Budget defaults** | Suggested token/cost budgets per agent and per company | Templates are **structure, not state.** No in-progress tasks, no historical cost data, no runtime artifacts. Just the blueprint. @@ -84,9 +84,11 @@ Clicking into a company template shows: Two ways to use a template: **Install (fresh start):** + ``` taskcore install cliphub:/ ``` + Downloads the template and creates a new company in your local Taskcore instance. You add your own API keys, set budgets, customize agents, and hit go. **Fork:** @@ -131,17 +133,17 @@ Or use the web UI to upload a template export directly. When publishing, you specify: -| Field | Required | Description | -|---|---|---| -| `slug` | yes | URL-safe identifier (e.g. `lean-dev-shop`) | -| `name` | yes | Display name | -| `description` | yes | What this company does and who it's for | -| `category` | yes | Primary category (see below) | -| `tags` | no | Additional tags for discovery | -| `version` | yes | Semver (e.g. `1.0.0`) | -| `changelog` | no | What changed in this version | -| `readme` | no | Extended documentation (markdown) | -| `license` | no | Usage terms | +| Field | Required | Description | +| ------------- | -------- | ------------------------------------------ | +| `slug` | yes | URL-safe identifier (e.g. `lean-dev-shop`) | +| `name` | yes | Display name | +| `description` | yes | What this company does and who it's for | +| `category` | yes | Primary category (see below) | +| `tags` | no | Additional tags for discovery | +| `version` | yes | Semver (e.g. `1.0.0`) | +| `changelog` | no | What changed in this version | +| `readme` | no | Extended documentation (markdown) | +| `license` | no | Usage terms | ### Versioning @@ -163,17 +165,17 @@ Scans your local exported templates and publishes any that are new or updated. U Company templates are organized by use case: -| Category | Examples | -|---|---| -| **Software Development** | Full-stack dev shop, API development team, mobile app studio | -| **Marketing & Growth** | Performance marketing agency, content marketing team, SEO shop | -| **Content & Media** | Content studio, podcast production, newsletter operation | -| **Research & Analysis** | Market research firm, competitive intelligence, data analysis team | -| **Operations** | Customer support org, internal ops team, QA/testing shop | -| **Sales** | Outbound sales team, lead generation, account management | -| **Finance & Legal** | Bookkeeping service, compliance monitoring, financial analysis | -| **Creative** | Design agency, copywriting studio, brand development | -| **General Purpose** | Starter templates, minimal orgs, single-agent setups | +| Category | Examples | +| ------------------------ | ------------------------------------------------------------------ | +| **Software Development** | Full-stack dev shop, API development team, mobile app studio | +| **Marketing & Growth** | Performance marketing agency, content marketing team, SEO shop | +| **Content & Media** | Content studio, podcast production, newsletter operation | +| **Research & Analysis** | Market research firm, competitive intelligence, data analysis team | +| **Operations** | Customer support org, internal ops team, QA/testing shop | +| **Sales** | Outbound sales team, lead generation, account management | +| **Finance & Legal** | Bookkeeping service, compliance monitoring, financial analysis | +| **Creative** | Design agency, copywriting studio, brand development | +| **General Purpose** | Starter templates, minimal orgs, single-agent setups | Categories are not exclusive — a template can have one primary category plus tags for cross-cutting concerns. @@ -205,23 +207,23 @@ ClipHub is a **separate service** from Taskcore itself. Taskcore is self-hosted; ### Integration Points -| Layer | Role | -|---|---| -| **ClipHub Web** | Browse, search, discover, comment, star — the website | -| **ClipHub API** | Registry API for publishing, downloading, searching programmatically | -| **Taskcore CLI** | `taskcore install`, `taskcore publish`, `taskcore cliphub sync` — built into Taskcore | -| **Taskcore UI** | "Browse ClipHub" panel in the Taskcore web UI for discovering templates without leaving the app | +| Layer | Role | +| ---------------- | ----------------------------------------------------------------------------------------------- | +| **ClipHub Web** | Browse, search, discover, comment, star — the website | +| **ClipHub API** | Registry API for publishing, downloading, searching programmatically | +| **Taskcore CLI** | `taskcore install`, `taskcore publish`, `taskcore cliphub sync` — built into Taskcore | +| **Taskcore UI** | "Browse ClipHub" panel in the Taskcore web UI for discovering templates without leaving the app | ### Tech Stack -| Layer | Technology | -|---|---| -| Frontend | React + Vite (consistent with Taskcore) | -| Backend | TypeScript + Hono (consistent with Taskcore) | -| Database | PostgreSQL | -| Search | Vector embeddings for semantic search | -| Auth | GitHub OAuth | -| Storage | Template zips stored in object storage (S3 or equivalent) | +| Layer | Technology | +| -------- | --------------------------------------------------------- | +| Frontend | React + Vite (consistent with Taskcore) | +| Backend | TypeScript + Hono (consistent with Taskcore) | +| Database | PostgreSQL | +| Search | Vector embeddings for semantic search | +| Auth | GitHub OAuth | +| Storage | Template zips stored in object storage (S3 or equivalent) | ### Data Model (Sketch) diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2ab7995..d37ae2d 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -123,11 +123,11 @@ See [Supabase pricing](https://supabase.com/pricing) for current details. The database mode is controlled by `DATABASE_URL`: -| `DATABASE_URL` | Mode | -|---|---| -| Not set | Embedded PostgreSQL (`~/.taskcore/instances/default/db/`) | -| `postgres://...localhost...` | Local Docker PostgreSQL | -| `postgres://...supabase.com...` | Hosted Supabase | +| `DATABASE_URL` | Mode | +| ------------------------------- | --------------------------------------------------------- | +| Not set | Embedded PostgreSQL (`~/.taskcore/instances/default/db/`) | +| `postgres://...localhost...` | Local Docker PostgreSQL | +| `postgres://...supabase.com...` | Hosted Supabase | Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode. diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index ada4973..7ea3f1b 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -24,20 +24,20 @@ Taskcore now treats **bind** as a separate concern from auth: ## 2. Canonical Model -| Runtime Mode | Exposure | Human auth | Primary use | -|---|---|---|---| -| `local_trusted` | n/a | No login required | Single-operator local machine workflow | -| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) | -| `authenticated` | `public` | Login required | Internet-facing/cloud deployment | +| Runtime Mode | Exposure | Human auth | Primary use | +| --------------- | --------- | ----------------- | ------------------------------------------------------ | +| `local_trusted` | n/a | No login required | Single-operator local machine workflow | +| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) | +| `authenticated` | `public` | Login required | Internet-facing/cloud deployment | ## Reachability Model -| Bind | Meaning | Typical use | -|---|---|---| -| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments | -| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access | -| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access | -| `custom` | Listen on an explicit host/IP | advanced interface-specific setups | +| Bind | Meaning | Typical use | +| ---------- | ------------------------------------ | ---------------------------------------------- | +| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments | +| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access | +| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access | +| `custom` | Listen on an explicit host/IP | advanced interface-specific setups | ## 3. Security Policy @@ -73,10 +73,12 @@ Server prompt behavior: 1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private` 2. advanced server setup asks reachability first: + - `Trusted local` → `bind=loopback`, `local_trusted/private` - `Private network` → `bind=lan`, `authenticated/private` - `Tailnet` → `bind=tailnet`, `authenticated/private` - `Custom` → manual mode/exposure/host entry + 3. raw host entry is only required for the `Custom` path 4. explicit public URL is only required for `authenticated + public` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f375949..4efba8f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -235,19 +235,19 @@ eval "$(taskcore worktree env)" **`pnpm taskcore worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree. -| Option | Description | -|---|---| -| `--name ` | Display name used to derive the instance id | -| `--instance ` | Explicit isolated instance id | -| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | -| `--from-config ` | Source config.json to seed from | -| `--from-data-dir ` | Source TASKCORE_HOME used when deriving the source config | -| `--from-instance ` | Source instance id (default: `default`) | -| `--server-port ` | Preferred server port | -| `--db-port ` | Preferred embedded Postgres port | -| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | -| `--no-seed` | Skip database seeding from the source instance | -| `--force` | Replace existing repo-local config and isolated instance data | +| Option | Description | +| ------------------------ | ------------------------------------------------------------------- | +| `--name ` | Display name used to derive the instance id | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source TASKCORE_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | Examples: @@ -274,16 +274,16 @@ For an already-created worktree where you want the CLI to decide whether to rebu **`pnpm taskcore worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.taskcore/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`. -| Option | Description | -|---|---| -| `--branch ` | Existing branch/worktree selector to repair, or a branch name to create under `.taskcore/worktrees` | -| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | -| `--from-config ` | Source config.json to seed from | -| `--from-data-dir ` | Source `TASKCORE_HOME` used when deriving the source config | -| `--from-instance ` | Source instance id when deriving the source config (default: `default`) | -| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | -| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config | -| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `--branch ` | Existing branch/worktree selector to repair, or a branch name to create under `.taskcore/worktrees` | +| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source `TASKCORE_HOME` used when deriving the source config | +| `--from-instance ` | Source instance id when deriving the source config (default: `default`) | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config | +| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | Examples: @@ -301,16 +301,16 @@ For an already-created worktree where you want to keep the existing repo-local c **`pnpm taskcore worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Taskcore instance or worktree while preserving the target worktree's current config, ports, and instance identity. -| Option | Description | -|---|---| -| `--from ` | Source worktree path, directory name, branch name, or `current` | -| `--to ` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) | -| `--from-config ` | Source config.json to seed from | -| `--from-data-dir ` | Source `TASKCORE_HOME` used when deriving the source config | -| `--from-instance ` | Source instance id when deriving the source config | -| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `full`) | -| `--yes` | Skip the destructive confirmation prompt | -| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------- | +| `--from ` | Source worktree path, directory name, branch name, or `current` | +| `--to ` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source `TASKCORE_HOME` used when deriving the source config | +| `--from-instance ` | Source instance id when deriving the source config | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `full`) | +| `--yes` | Skip the destructive confirmation prompt | +| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | Examples: @@ -332,19 +332,19 @@ pnpm taskcore worktree reseed \ **`pnpm taskcore worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Taskcore instance inside it. This combines `git worktree add` with `worktree init` in a single step. -| Option | Description | -|---|---| -| `--start-point ` | Remote ref to base the new branch on (e.g. `origin/main`) | -| `--instance ` | Explicit isolated instance id | -| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | -| `--from-config ` | Source config.json to seed from | -| `--from-data-dir ` | Source TASKCORE_HOME used when deriving the source config | -| `--from-instance ` | Source instance id (default: `default`) | -| `--server-port ` | Preferred server port | -| `--db-port ` | Preferred embedded Postgres port | -| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | -| `--no-seed` | Skip database seeding from the source instance | -| `--force` | Replace existing repo-local config and isolated instance data | +| Option | Description | +| ------------------------ | ------------------------------------------------------------------- | +| `--start-point ` | Remote ref to base the new branch on (e.g. `origin/main`) | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.taskcore-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source TASKCORE_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | Examples: @@ -356,10 +356,10 @@ pnpm taskcore worktree:make experiment --no-seed **`pnpm taskcore worktree env [options]`** — Print shell exports for the current worktree-local Taskcore instance. -| Option | Description | -|---|---| -| `-c, --config ` | Path to config file | -| `--json` | Print JSON instead of shell exports | +| Option | Description | +| --------------------- | ----------------------------------- | +| `-c, --config ` | Path to config file | +| `--json` | Print JSON instead of shell exports | Examples: diff --git a/doc/DOCKER.md b/doc/DOCKER.md index d471d4f..64f6157 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -14,10 +14,10 @@ The Dockerfile installs common agent tools (`git`, `gh`, `curl`, `wget`, `ripgre Build arguments: -| Arg | Default | Purpose | -|-----|---------|---------| -| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) | -| `USER_GID` | `1000` | GID for the container `node` group | +| Arg | Default | Purpose | +| ---------- | ------- | ------------------------------------------------------------------------------------------------- | +| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) | +| `USER_GID` | `1000` | GID for the container `node` group | ```sh docker build -t taskcore-local \ @@ -150,11 +150,11 @@ Notes: The `docker/quadlet/` directory contains unit files to run Taskcore + PostgreSQL as systemd services via Podman Quadlet. -| File | Purpose | -|------|---------| -| `docker/quadlet/taskcore.pod` | Pod definition — groups containers into a shared network namespace | -| `docker/quadlet/taskcore.container` | Taskcore server — joins the pod, connects to Postgres at `127.0.0.1` | -| `docker/quadlet/taskcore-db.container` | PostgreSQL 17 — joins the pod, health-checked | +| File | Purpose | +| -------------------------------------- | -------------------------------------------------------------------- | +| `docker/quadlet/taskcore.pod` | Pod definition — groups containers into a shared network namespace | +| `docker/quadlet/taskcore.container` | Taskcore server — joins the pod, connects to Postgres at `127.0.0.1` | +| `docker/quadlet/taskcore-db.container` | PostgreSQL 17 — joins the pod, health-checked | ### Setup @@ -206,7 +206,7 @@ systemctl --user stop taskcore-pod # Stop all ### Quadlet notes -- **First boot**: Unlike Docker Compose's `condition: service_healthy`, Quadlet's `After=` only waits for the DB unit to *start*, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in `journalctl --user -u taskcore` while PostgreSQL initialises — this is expected and resolves automatically via `Restart=on-failure`. +- **First boot**: Unlike Docker Compose's `condition: service_healthy`, Quadlet's `After=` only waits for the DB unit to _start_, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in `journalctl --user -u taskcore` while PostgreSQL initialises — this is expected and resolves automatically via `Restart=on-failure`. - Containers in a pod share `localhost`, so Taskcore reaches Postgres at `127.0.0.1:5432`. - PostgreSQL data persists in the `taskcore-pgdata` named volume. - Taskcore data persists at `~/.local/share/taskcore`. diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 3c01371..19a4e8d 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -1,30 +1,37 @@ Use this exact checklist. 1. Start Taskcore in auth mode. + ```bash cd pnpm dev --bind lan ``` + Then verify: + ```bash curl -sS http://127.0.0.1:3100/api/health | jq ``` 2. Start a clean/stock OpenClaw Docker. + ```bash OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh ``` + Open the printed `Dashboard URL` (includes `#token=...`) in your browser. 3. In Taskcore UI, go to `http://127.0.0.1:3100/CLA/company/settings`. 4. Use the OpenClaw invite prompt flow. + - In the Invites section, click `Generate OpenClaw Invite Prompt`. - Copy the generated prompt from `OpenClaw Invite Prompt`. - Paste it into OpenClaw main chat as one message. - If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` Security/control note: + - The OpenClaw invite prompt is created from a controlled endpoint: - `POST /api/companies/{companyId}/openclaw/invite-prompt` - board users with invite permission can call it @@ -33,6 +40,7 @@ Security/control note: 5. Approve the join request in Taskcore UI, then confirm the OpenClaw agent appears in CLA agents. 6. Gateway preflight (required before task tests). + - Confirm the created agent uses `openclaw_gateway` (not `openclaw`). - Confirm gateway URL is `ws://...` or `wss://...`. - Confirm gateway token is non-trivial (not empty / not 1-char placeholder). @@ -41,33 +49,41 @@ Security/control note: - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` - do not rely on `disableDeviceAuth` for normal onboarding - If you can run API checks with board auth: + ```bash AGENT_ID="" curl -sS -H "Cookie: $TASKCORE_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}' ``` + - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: + - Clean run expectation: first task should succeed without manual pairing commands. - The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). - If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. - This is a separate approval from Taskcore invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: + ```bash docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' ``` + - You can inspect pending vs paired devices: + ```bash docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' ``` 7. Case A (manual issue test). + - Create an issue assigned to the OpenClaw agent. - Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” - Verify in UI: issue status becomes `done` and comment exists. 8. Case B (message tool test). + - Create another issue assigned to OpenClaw. - Instructions: “send `OPENCLAW_CASE_B_OK_` to main webchat via message tool, then comment same marker on issue, then mark done.” - Verify both: @@ -75,16 +91,19 @@ docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs - marker text appears in OpenClaw main chat 9. Case C (new session memory/skills test). + - In OpenClaw, start `/new` session. - Ask it to create a new CLA issue in Taskcore with unique title `OPENCLAW_CASE_C_CREATED_`. - Verify in Taskcore UI that new issue exists. 10. Watch logs during test (optional but helpful): + ```bash docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.taskcore-openclaw.override.yml logs -f openclaw-gateway ``` 11. Expected pass criteria. + - Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`). - Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path). - Case A: `done` + marker comment. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 741a946..7184d41 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -28,21 +28,21 @@ Success means one operator can run a small AI-native company end-to-end with cle These decisions close open questions from `SPEC.md` for V1. -| Topic | V1 Decision | -|---|---| -| Tenancy | Single-tenant deployment, multi-company data model | -| Company model | Company is first-order; all business entities are company-scoped | -| Board | Single human board operator per deployment | -| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Full visibility to board and all agents in same company | -| Communication | Tasks + comments only (no separate chat system) | -| Task ownership | Single assignee; atomic checkout required for `in_progress` transition | -| Recovery | No automatic reassignment; work recovery stays manual/explicit | -| Agent adapters | Built-in `process` and `http` adapters | -| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | -| Budget period | Monthly UTC calendar window | -| Budget enforcement | Soft alerts + hard limit auto-pause | -| Deployment modes | Canonical model is `local_trusted` + `authenticated` with `private/public` exposure policy (see `doc/DEPLOYMENT-MODES.md`) | +| Topic | V1 Decision | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Tenancy | Single-tenant deployment, multi-company data model | +| Company model | Company is first-order; all business entities are company-scoped | +| Board | Single human board operator per deployment | +| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | +| Visibility | Full visibility to board and all agents in same company | +| Communication | Tasks + comments only (no separate chat system) | +| Task ownership | Single assignee; atomic checkout required for `in_progress` transition | +| Recovery | No automatic reassignment; work recovery stays manual/explicit | +| Agent adapters | Built-in `process` and `http` adapters | +| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | +| Budget period | Monthly UTC calendar window | +| Budget enforcement | Soft alerts + hard limit auto-pause | +| Deployment modes | Canonical model is `local_trusted` + `authenticated` with `private/public` exposure policy (see `doc/DEPLOYMENT-MODES.md`) | ## 4. Current Baseline (Repo Snapshot) @@ -426,17 +426,17 @@ Detailed ownership, execution, blocker, and crash-recovery semantics are documen ## 9.3 Permission Matrix (V1) -| Action | Board | Agent | -|---|---|---| -| Create company | yes | no | -| Hire/create agent | yes (direct) | request via approval | -| Pause/resume agent | yes | no | -| Create/update task | yes | yes | -| Force reassign task | yes | limited | -| Approve strategy/hire requests | yes | no | -| Report cost | yes | yes | -| Set company budget | yes | no | -| Set subordinate budget | yes | yes (manager subtree only) | +| Action | Board | Agent | +| ------------------------------ | ------------ | -------------------------- | +| Create company | yes | no | +| Hire/create agent | yes (direct) | request via approval | +| Pause/resume agent | yes | no | +| Create/update task | yes | yes | +| Force reassign task | yes | limited | +| Approve strategy/hire requests | yes | no | +| Report cost | yes | yes | +| Set company budget | yes | no | +| Set subordinate budget | yes | yes (manager subtree only) | ## 10. API Contract (REST) @@ -574,7 +574,7 @@ Config shape: "command": "string", "args": ["string"], "cwd": "string", - "env": {"KEY": "VALUE"}, + "env": { "KEY": "VALUE" }, "timeoutSec": 900, "graceSec": 15 } @@ -595,9 +595,9 @@ Config shape: { "url": "https://...", "method": "POST", - "headers": {"Authorization": "Bearer ..."}, + "headers": { "Authorization": "Bearer ..." }, "timeoutMs": 15000, - "payloadTemplate": {"agentId": "{{agent.id}}", "runId": "{{run.id}}"} + "payloadTemplate": { "agentId": "{{agent.id}}", "runId": "{{run.id}}" } } ``` diff --git a/doc/SPEC.md b/doc/SPEC.md index b830313..9d58221 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -10,12 +10,12 @@ A Company is a first-order object. One Taskcore instance runs multiple Companies ### Fields (Draft) -| Field | Type | Notes | -| ----------- | ------------- | --------------------------------- | -| `id` | uuid | Primary key | -| `name` | string | Company name | -| `createdAt` | timestamp | | -| `updatedAt` | timestamp | | +| Field | Type | Notes | +| ----------- | --------- | ------------ | +| `id` | uuid | Primary key | +| `name` | string | Company name | +| `createdAt` | timestamp | | +| `updatedAt` | timestamp | | ### Board Governance [DRAFT] @@ -188,17 +188,17 @@ The heartbeat is a protocol, not a runtime. Taskcore defines how to initiate an Agent configuration includes an **adapter** that defines how Taskcore invokes the agent. Built-in adapters include: -| Adapter | Mechanism | Example | -| ---------------- | -------------------------- | -------------------------------------------------- | -| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | -| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | -| `claude_local` | Local Claude Code process | Claude Code heartbeat worker | -| `codex_local` | Local Codex process | Codex CLI heartbeat worker | -| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker | -| `pi_local` | Local Pi process | Pi CLI heartbeat worker | -| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker | -| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | -| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker | +| Adapter | Mechanism | Example | +| ------------------ | ------------------------- | --------------------------------------------- | +| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | +| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| `claude_local` | Local Claude Code process | Claude Code heartbeat worker | +| `codex_local` | Local Codex process | Codex CLI heartbeat worker | +| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker | +| `pi_local` | Local Pi process | Pi CLI heartbeat worker | +| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker | +| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | +| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker | The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). @@ -377,12 +377,12 @@ Flow: ### Tech Stack -| Layer | Technology | -| -------- | ------------------------------------------------------------ | -| Frontend | React + Vite | -| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) | +| Layer | Technology | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Frontend | React + Vite | +| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) | | Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) | -| Auth | [Better Auth](https://www.better-auth.com/) | +| Auth | [Better Auth](https://www.better-auth.com/) | ### Concurrency Model: Atomic Task Checkout diff --git a/doc/TASKS-mcp.md b/doc/TASKS-mcp.md index 48590a8..1476998 100644 --- a/doc/TASKS-mcp.md +++ b/doc/TASKS-mcp.md @@ -20,7 +20,7 @@ List and filter issues in the workspace. | ----------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- | | `query` | string | no | Free-text search across title and description | | `teamId` | string | no | Filter by team | -| `status` | string | no | Filter by specific workflow state | +| `status` | string | no | Filter by specific workflow state | | `stateType` | string | no | Filter by state category: `triage`, `backlog`, `unstarted`, `started`, `completed`, `cancelled` | | `assigneeId` | string | no | Filter by assignee (agent id) | | `projectId` | string | no | Filter by project | @@ -66,7 +66,7 @@ Create a new issue. | `title` | string | yes | | | `teamId` | string | yes | Team the issue belongs to | | `description` | string | no | Markdown | -| `status` | string | no | Workflow state. Default: team's default state | +| `status` | string | no | Workflow state. Default: team's default state | | `priority` | number | no | 0-4. Default: 0 (none) | | `estimate` | number | no | Point estimate | | `dueDate` | string | no | ISO date | @@ -96,7 +96,7 @@ Update an existing issue. | `id` | string | yes | UUID or identifier | | `title` | string | no | | | `description` | string | no | | -| `status` | string | no | Transition to a new workflow state | +| `status` | string | no | Transition to a new workflow state | | `priority` | number | no | 0-4 | | `estimate` | number | no | | | `dueDate` | string | no | ISO date, or `null` to clear | @@ -303,7 +303,7 @@ Get a milestone by ID. ### `create_milestone` | Parameter | Type | Required | -| ------------- | ------ | -------- | +| ------------- | ------ | -------- | --------------------------- | | `projectId` | string | yes | | `name` | string | yes | | `description` | string | no | @@ -317,7 +317,7 @@ Get a milestone by ID. ### `update_milestone` | Parameter | Type | Required | -| ------------- | ------ | -------- | +| ------------- | ------ | -------- | --------------------------- | | `id` | string | yes | | `name` | string | no | | `description` | string | no | diff --git a/doc/assets/footer.jpg b/doc/assets/footer.jpg deleted file mode 100644 index b1e3586..0000000 Binary files a/doc/assets/footer.jpg and /dev/null differ diff --git a/doc/assets/header.png b/doc/assets/header.png deleted file mode 100644 index 7d4cdf3..0000000 Binary files a/doc/assets/header.png and /dev/null differ diff --git a/doc/memory-landscape.md b/doc/memory-landscape.md index f3a80c8..5406d6b 100644 --- a/doc/memory-landscape.md +++ b/doc/memory-landscape.md @@ -46,18 +46,18 @@ These emphasize local persistence, inspectability, and low operational overhead. ## Per-Project Notes -| Project | Shape | Notable API / model | Strong fit for Taskcore | Main mismatch | -|---|---|---|---|---| -| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service | -| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Taskcore should not assume every backend behaves like mem0 | -| [AWS Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) | AWS-managed memory service | explicit short-term and long-term memories, actor/session/event APIs, memory strategies, namespace templates, optional self-managed extraction pipeline | strong example of provider-managed memory with clear scoped ids, retention controls, and standalone API access outside a single agent framework | AWS-hosted and IAM-centric; Taskcore would still need its own company/run/comment provenance, cost rollups, and likely a plugin wrapper instead of baking AWS semantics into core | -| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Taskcore should standardize first | -| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow | -| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Taskcore's task-centric control plane | -| [Memori](https://github.com/MemoriLabs/Memori) | hosted memory fabric + SDK wrappers | registers against LLM SDKs, attribution via `entity_id` + `process_id`, sessions, cloud + BYODB | strong example of automatic capture around model clients | wrapper-centric design does not map 1:1 to Taskcore's run / issue / comment lifecycle | -| [EverMemOS](https://github.com/EverMind-AI/EverMemOS) | conversational long-term memory system | MemCell extraction, structured narratives, user profiles, hybrid retrieval / reranking | useful model for provenance-rich structured memories and evolving profiles | focused on conversational memory rather than generalized control-plane events | -| [memsearch](https://github.com/zilliztech/memsearch) | markdown-first local memory index | markdown as source of truth, `index`, `search`, `watch`, transcript parsing, plugin hooks | excellent baseline for a local built-in provider and inspectable provenance | intentionally simple; no hosted service semantics or rich correction workflow | -| [OpenViking](https://github.com/volcengine/OpenViking) | context database | filesystem-style organization of memories/resources/skills, tiered loading, visualized retrieval trajectories | strong source for browse/inspect UX and context provenance | treats "context database" as a larger product surface than Taskcore should own | +| Project | Shape | Notable API / model | Strong fit for Taskcore | Main mismatch | +| --------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service | +| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Taskcore should not assume every backend behaves like mem0 | +| [AWS Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) | AWS-managed memory service | explicit short-term and long-term memories, actor/session/event APIs, memory strategies, namespace templates, optional self-managed extraction pipeline | strong example of provider-managed memory with clear scoped ids, retention controls, and standalone API access outside a single agent framework | AWS-hosted and IAM-centric; Taskcore would still need its own company/run/comment provenance, cost rollups, and likely a plugin wrapper instead of baking AWS semantics into core | +| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Taskcore should standardize first | +| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow | +| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Taskcore's task-centric control plane | +| [Memori](https://github.com/MemoriLabs/Memori) | hosted memory fabric + SDK wrappers | registers against LLM SDKs, attribution via `entity_id` + `process_id`, sessions, cloud + BYODB | strong example of automatic capture around model clients | wrapper-centric design does not map 1:1 to Taskcore's run / issue / comment lifecycle | +| [EverMemOS](https://github.com/EverMind-AI/EverMemOS) | conversational long-term memory system | MemCell extraction, structured narratives, user profiles, hybrid retrieval / reranking | useful model for provenance-rich structured memories and evolving profiles | focused on conversational memory rather than generalized control-plane events | +| [memsearch](https://github.com/zilliztech/memsearch) | markdown-first local memory index | markdown as source of truth, `index`, `search`, `watch`, transcript parsing, plugin hooks | excellent baseline for a local built-in provider and inspectable provenance | intentionally simple; no hosted service semantics or rich correction workflow | +| [OpenViking](https://github.com/volcengine/OpenViking) | context database | filesystem-style organization of memories/resources/skills, tiered loading, visualized retrieval trajectories | strong source for browse/inspect UX and context provenance | treats "context database" as a larger product surface than Taskcore should own | ## Common Primitives Across The Landscape diff --git a/doc/plans/2026-02-16-module-system.md b/doc/plans/2026-02-16-module-system.md index 99285bd..93db20a 100644 --- a/doc/plans/2026-02-16-module-system.md +++ b/doc/plans/2026-02-16-module-system.md @@ -14,13 +14,13 @@ Both are discoverable through the **Company Store**. ## Concepts -| Concept | What it is | Contains code? | -|---------|-----------|----------------| -| **Module** | A package that extends Taskcore's API, UI, and data model | Yes | -| **Company Template** | A data snapshot — agents, projects, goals, org structure | No (JSON only) | -| **Company Store** | Registry for browsing/installing modules and templates | — | -| **Hook** | A named event in the core that modules can subscribe to | — | -| **Slot** | An exclusive category where only one module can be active (e.g., `observability`) | — | +| Concept | What it is | Contains code? | +| -------------------- | --------------------------------------------------------------------------------- | -------------- | +| **Module** | A package that extends Taskcore's API, UI, and data model | Yes | +| **Company Template** | A data snapshot — agents, projects, goals, org structure | No (JSON only) | +| **Company Store** | Registry for browsing/installing modules and templates | — | +| **Hook** | A named event in the core that modules can subscribe to | — | +| **Slot** | An exclusive category where only one module can be active (e.g., `observability`) | — | --- @@ -149,10 +149,10 @@ export default function register(api: ModuleAPI) { interface ModuleAPI { // Identity moduleId: string; - config: Record; // validated against configSchema + config: Record; // validated against configSchema // Database - db: Db; // shared Drizzle client + db: Db; // shared Drizzle client // Routes registerRoutes(router: Router): void; @@ -187,21 +187,21 @@ Modules get a scoped logger, access to the shared database, and read access to c Hooks are the primary integration point. The core emits events at well-defined moments. Modules subscribe in their `register` function. -| Hook | Payload | When | -|------|---------|------| -| `server:started` | `{ port }` | After the Express server begins listening | -| `agent:created` | `{ agent }` | After a new agent is inserted | -| `agent:updated` | `{ agent, changes }` | After an agent record is modified | -| `agent:deleted` | `{ agent }` | After an agent is removed | -| `agent:heartbeat` | `{ agentId, timestamp, meta }` | When an agent checks in. `meta` carries tokens_used, cost, latency, etc. | -| `agent:status_changed` | `{ agent, from, to }` | When agent status transitions (idle→active, active→error, etc.) | -| `issue:created` | `{ issue }` | After a new issue is inserted | -| `issue:status_changed` | `{ issue, from, to }` | When issue moves between statuses | -| `issue:assigned` | `{ issue, agent }` | When an issue is assigned to an agent | -| `goal:created` | `{ goal }` | After a new goal is inserted | -| `goal:completed` | `{ goal }` | When a goal's status becomes complete | -| `budget:spend_recorded` | `{ agentId, amount, total }` | After spend is incremented | -| `budget:threshold_crossed` | `{ agentId, budget, spent, percent }` | When an agent crosses 80%, 90%, or 100% of budget | +| Hook | Payload | When | +| -------------------------- | ------------------------------------- | ------------------------------------------------------------------------ | +| `server:started` | `{ port }` | After the Express server begins listening | +| `agent:created` | `{ agent }` | After a new agent is inserted | +| `agent:updated` | `{ agent, changes }` | After an agent record is modified | +| `agent:deleted` | `{ agent }` | After an agent is removed | +| `agent:heartbeat` | `{ agentId, timestamp, meta }` | When an agent checks in. `meta` carries tokens_used, cost, latency, etc. | +| `agent:status_changed` | `{ agent, from, to }` | When agent status transitions (idle→active, active→error, etc.) | +| `issue:created` | `{ issue }` | After a new issue is inserted | +| `issue:status_changed` | `{ issue, from, to }` | When issue moves between statuses | +| `issue:assigned` | `{ issue, agent }` | When an issue is assigned to an agent | +| `goal:created` | `{ goal }` | After a new goal is inserted | +| `goal:completed` | `{ goal }` | When a goal's status becomes complete | +| `budget:spend_recorded` | `{ agentId, amount, total }` | After spend is incremented | +| `budget:threshold_crossed` | `{ agentId, budget, spent, percent }` | When an agent crosses 80%, 90%, or 100% of budget | ### Hook Execution Model @@ -219,14 +219,13 @@ class HookBus { async emit(event: string, payload: unknown) { const handlers = this.handlers.get(event) ?? []; // Run all handlers concurrently. Failures are logged, never block core. - await Promise.allSettled( - handlers.map(h => h(payload)) - ); + await Promise.allSettled(handlers.map((h) => h(payload))); } } ``` Design rules: + - **Hooks are fire-and-forget.** A failing hook handler never crashes or blocks the core operation. - **Hooks are concurrent.** All handlers for an event run in parallel via `Promise.allSettled`. - **Hooks are post-commit.** They fire after the database write succeeds, not before. No vetoing. @@ -282,7 +281,9 @@ export const tokenMetrics = pgTable("mod_observability_token_metrics", { tokensUsed: integer("tokens_used").notNull(), costCents: integer("cost_cents").notNull().default(0), model: text("model").notNull(), - recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull().defaultNow(), + recordedAt: timestamp("recorded_at", { withTimezone: true }) + .notNull() + .defaultNow(), }); export const alertRules = pgTable("mod_observability_alert_rules", { @@ -291,7 +292,9 @@ export const alertRules = pgTable("mod_observability_alert_rules", { metricName: text("metric_name").notNull(), threshold: integer("threshold").notNull(), enabled: boolean("enabled").notNull().default(true), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), }); ``` @@ -340,13 +343,13 @@ Module config lives in the server's environment or a config file: "config": { "observability": { "retentionDays": 90, - "enablePrometheus": true + "enablePrometheus": true, }, "revenue": { - "stripeSecretKey": "$STRIPE_SECRET_KEY" - } - } - } + "stripeSecretKey": "$STRIPE_SECRET_KEY", + }, + }, + }, } ``` @@ -355,6 +358,7 @@ Module config lives in the server's environment or a config file: ### Disabling a Module Setting a module's enabled state to false: + 1. Stops its background services 2. Unmounts its routes (returns 404) 3. Unsubscribes its hook handlers @@ -367,6 +371,7 @@ Setting a module's enabled state to false: ### How Module UI Works The core UI shell provides: + - A sidebar with slots for module-contributed nav items - A dashboard with widget mount points - A module settings page @@ -393,7 +398,11 @@ export const dashboardWidgets = [ id: "token-burn-rate", label: "Token Burn Rate", placement: "dashboard", - component: lazy(() => import("@taskcore/mod-observability/ui").then(m => ({ default: m.TokenBurnRateWidget }))), + component: lazy(() => + import("@taskcore/mod-observability/ui").then((m) => ({ + default: m.TokenBurnRateWidget, + })), + ), }, ]; ``` @@ -601,27 +610,27 @@ pnpm taskcore store export # export current company as template ### Tier 1 — Build first (core extensions) -| Module | What it does | Key hooks | -|--------|-------------|-----------| -| **Observability** | Token usage tracking, cost metrics, agent performance dashboards, Prometheus export | `agent:heartbeat`, `budget:spend_recorded` | -| **Revenue Tracking** | Connect Stripe/crypto wallets, track income, show P&L against agent costs | `budget:spend_recorded` | -| **Notifications** | Slack/Discord/email alerts on configurable triggers | All hooks (configurable) | +| Module | What it does | Key hooks | +| -------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------ | +| **Observability** | Token usage tracking, cost metrics, agent performance dashboards, Prometheus export | `agent:heartbeat`, `budget:spend_recorded` | +| **Revenue Tracking** | Connect Stripe/crypto wallets, track income, show P&L against agent costs | `budget:spend_recorded` | +| **Notifications** | Slack/Discord/email alerts on configurable triggers | All hooks (configurable) | ### Tier 2 — High value -| Module | What it does | Key hooks | -|--------|-------------|-----------| -| **Analytics Dashboard** | Burn rate trends, agent utilization over time, goal velocity charts | `agent:heartbeat`, `issue:status_changed`, `goal:completed` | -| **Workflow Automation** | If/then rules: "when issue is done, create follow-up", "when budget at 90%, pause agent" | `issue:status_changed`, `budget:threshold_crossed` | -| **Knowledge Base** | Shared document store, vector search, agents read/write organizational knowledge | `agent:heartbeat` (for context injection) | +| Module | What it does | Key hooks | +| ----------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **Analytics Dashboard** | Burn rate trends, agent utilization over time, goal velocity charts | `agent:heartbeat`, `issue:status_changed`, `goal:completed` | +| **Workflow Automation** | If/then rules: "when issue is done, create follow-up", "when budget at 90%, pause agent" | `issue:status_changed`, `budget:threshold_crossed` | +| **Knowledge Base** | Shared document store, vector search, agents read/write organizational knowledge | `agent:heartbeat` (for context injection) | ### Tier 3 — Nice to have -| Module | What it does | Key hooks | -|--------|-------------|-----------| -| **Audit & Compliance** | Immutable audit trail, approval workflows, spend authorization | All write hooks | -| **Agent Logs / Replay** | Full execution traces per agent, token-by-token replay | `agent:heartbeat` | -| **Multi-tenant** | Separate companies/orgs within one Taskcore instance | `server:started` | +| Module | What it does | Key hooks | +| ----------------------- | -------------------------------------------------------------- | ----------------- | +| **Audit & Compliance** | Immutable audit trail, approval workflows, spend authorization | All write hooks | +| **Agent Logs / Replay** | Full execution traces per agent, token-by-token replay | `agent:heartbeat` | +| **Multi-tenant** | Separate companies/orgs within one Taskcore instance | `server:started` | --- diff --git a/doc/plans/2026-02-18-agent-authentication.md b/doc/plans/2026-02-18-agent-authentication.md index e03010f..9eb78aa 100644 --- a/doc/plans/2026-02-18-agent-authentication.md +++ b/doc/plans/2026-02-18-agent-authentication.md @@ -199,11 +199,11 @@ On approval, the approver sets: | Priority | Item | Notes | | -------- | --------------------------------- | ------------------------------------------------------------------------------------------------ | -| **P0** | Local adapter JWT injection | Unblocks zero-config local auth. Mint a JWT per heartbeat, pass as `TASKCORE_API_KEY`. | +| **P0** | Local adapter JWT injection | Unblocks zero-config local auth. Mint a JWT per heartbeat, pass as `TASKCORE_API_KEY`. | | **P1** | Invite link + onboarding endpoint | `POST /api/companies/:id/invites`, `GET /api/invite/:token`, `POST /api/invite/:token/register`. | | **P1** | Approval flow | UI + API for reviewing and approving pending agent registrations. | | **P2** | OpenClaw integration | First real external agent onboarding via invite link. | -| **P3** | CLI auth flow | `taskcore auth login` for developer-managed remote agents. | +| **P3** | CLI auth flow | `taskcore auth login` for developer-managed remote agents. | ## P0 Implementation Plan diff --git a/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md b/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md index d01ec79..a82911e 100644 --- a/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md +++ b/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md @@ -380,10 +380,10 @@ Repo verification before merge: ## 13. Open Decisions (Default Recommendation) 1. Should board direct-create bypass approval setting? -Recommendation: yes, board is explicit governance override. + Recommendation: yes, board is explicit governance override. 2. Should non-authorized agents still see basic agent metadata? -Recommendation: yes (name/role/status), but configuration fields stay restricted. + Recommendation: yes (name/role/status), but configuration fields stay restricted. 3. On rejection, should limbo agent remain `pending_approval` or move to `terminated`? -Recommendation: move to `terminated` on final reject; keep optional hard delete action for cleanup. + Recommendation: move to `terminated` on final reject; keep optional hard delete action for cleanup. diff --git a/doc/plans/2026-02-20-storage-system-implementation.md b/doc/plans/2026-02-20-storage-system-implementation.md index 8fe96dd..06f5a5f 100644 --- a/doc/plans/2026-02-20-storage-system-implementation.md +++ b/doc/plans/2026-02-20-storage-system-implementation.md @@ -203,4 +203,3 @@ If any command is skipped, document exactly what was skipped and why. 3. Phase 4 (API) 4. Phase 5 (UI consumer) 5. Phase 6 (doctor/docs hardening) - diff --git a/doc/plans/2026-02-21-humans-and-permissions-implementation.md b/doc/plans/2026-02-21-humans-and-permissions-implementation.md index a7bccba..d34763a 100644 --- a/doc/plans/2026-02-21-humans-and-permissions-implementation.md +++ b/doc/plans/2026-02-21-humans-and-permissions-implementation.md @@ -15,42 +15,50 @@ If this document conflicts with prior exploratory notes, this document wins for ## 2. Locked V1 decisions 1. Two deployment modes remain: + - `local_trusted` - `cloud_hosted` 2. `local_trusted`: + - no login UX - implicit local instance admin actor - loopback-only server binding - full admin/settings/invite/approval capabilities available locally 3. `cloud_hosted`: + - Better Auth for humans - email/password only - no email verification requirement in V1 4. Permissions: + - one shared authorization system for humans and agents - normalized grants table (`principal_permission_grants`) - no separate “agent permissions engine” 5. Invites: + - copy-link only (no outbound email sending in V1) - unified `company_join` link that supports human or agent path - acceptance creates `pending_approval` join request - no access until admin approval 6. Join review metadata: + - source IP required - no GeoIP/country lookup in V1 7. Agent API keys: + - indefinite by default - hash at rest - display once on claim - revoke/regenerate supported 8. Local ingress: + - public/untrusted ingress is out of scope for V1 - no `--dangerous-agent-ingress` in V1 @@ -235,8 +243,10 @@ Migration ordering: 1. add new tables/columns/indexes 2. backfill minimum memberships/grants for existing data: + - create local implicit admin membership context in local mode at runtime (not persisted as Better Auth user) - for cloud, bootstrap creates first admin user role on acceptance + 3. switch authz reads to new tables 4. remove legacy board-only checks @@ -255,15 +265,18 @@ All under `/api`. ## 6.2 Invites 1. `POST /api/companies/:companyId/invites` + - create `company_join` invite - copy-link value returned once 2. `GET /api/invites/:token` + - validate token - return invite landing payload - includes `allowedJoinTypes` 3. `POST /api/invites/:token/accept` + - body: - `requestType: human | agent` - human path: no extra payload beyond authenticated user @@ -272,6 +285,7 @@ All under `/api`. - creates `join_requests(status=pending_approval)` 4. `POST /api/invites/:inviteId/revoke` + - revokes non-consumed invite ## 6.3 Join requests @@ -279,6 +293,7 @@ All under `/api`. 1. `GET /api/companies/:companyId/join-requests?status=pending_approval&requestType=...` 2. `POST /api/companies/:companyId/join-requests/:requestId/approve` + - human: - create/activate `company_memberships` - apply default grants @@ -291,6 +306,7 @@ All under `/api`. 3. `POST /api/companies/:companyId/join-requests/:requestId/reject` 4. `POST /api/join-requests/:requestId/claim-api-key` + - approved agent request only - returns plaintext key once - stores hash in `agent_api_keys` @@ -298,12 +314,15 @@ All under `/api`. ## 6.4 Membership and grants 1. `GET /api/companies/:companyId/members` + - returns both principal types 2. `PATCH /api/companies/:companyId/members/:memberId/permissions` + - upsert/remove grants 3. `PUT /api/admin/users/:userId/company-access` + - instance admin only 4. `GET /api/admin/users/:userId/company-access` @@ -456,10 +475,12 @@ Files: Commands: 1. `taskcore auth bootstrap-ceo` + - create bootstrap invite - print one-time URL 2. `taskcore onboard` + - in cloud mode with `bootstrap_pending`, print bootstrap URL and next steps - in local mode, skip bootstrap requirement @@ -485,21 +506,26 @@ Files: Required UX: 1. Cloud unauthenticated user: + - redirect to login/signup 2. Cloud bootstrap pending: + - block app with setup command guidance 3. Invite landing: + - choose human vs agent path (respect `allowedJoinTypes`) - submit join request - show pending approval confirmation 4. Inbox: + - show join approval cards with approve/reject actions - include source IP and human email snapshot when applicable 5. Local mode: + - no login prompts - full settings/invite/approval UI available diff --git a/doc/plans/2026-02-23-cursor-cloud-adapter.md b/doc/plans/2026-02-23-cursor-cloud-adapter.md index ce57b89..23ec33c 100644 --- a/doc/plans/2026-02-23-cursor-cloud-adapter.md +++ b/doc/plans/2026-02-23-cursor-cloud-adapter.md @@ -41,16 +41,16 @@ Authentication header: Core endpoints: -| Endpoint | Method | Purpose | -|---|---|---| -| `/v0/agents` | POST | Launch agent | -| `/v0/agents/{id}` | GET | Agent status | -| `/v0/agents/{id}/conversation` | GET | Conversation history | -| `/v0/agents/{id}/followup` | POST | Follow-up prompt | -| `/v0/agents/{id}/stop` | POST | Stop/pause running agent | -| `/v0/models` | GET | Recommended model list | -| `/v0/me` | GET | API key metadata | -| `/v0/repositories` | GET | Accessible repos (strictly rate-limited) | +| Endpoint | Method | Purpose | +| ------------------------------ | ------ | ---------------------------------------- | +| `/v0/agents` | POST | Launch agent | +| `/v0/agents/{id}` | GET | Agent status | +| `/v0/agents/{id}/conversation` | GET | Conversation history | +| `/v0/agents/{id}/followup` | POST | Follow-up prompt | +| `/v0/agents/{id}/stop` | POST | Stop/pause running agent | +| `/v0/models` | GET | Recommended model list | +| `/v0/me` | GET | API key metadata | +| `/v0/repositories` | GET | Accessible repos (strictly rate-limited) | Status handling policy for adapter: @@ -401,15 +401,15 @@ Current process-only cancellation maps are insufficient by themselves for Cursor ## Comparison with `claude_local` -| Aspect | `claude_local` | `cursor_cloud` | -|---|---|---| -| Execution model | local subprocess | remote API | -| Updates | stream-json stdout | webhook + polling + synthesized stdout | -| Session id | Claude session id | Cursor agent id | -| Skill delivery | local skill dir injection | authenticated fetch from Taskcore skill endpoints | -| Taskcore auth | injected local run JWT env var | bootstrap token exchange -> run JWT | -| Cancellation | OS signals | abort polling + Cursor stop endpoint | -| Usage/cost | rich | not exposed by Cursor API | +| Aspect | `claude_local` | `cursor_cloud` | +| --------------- | ------------------------------ | ------------------------------------------------- | +| Execution model | local subprocess | remote API | +| Updates | stream-json stdout | webhook + polling + synthesized stdout | +| Session id | Claude session id | Cursor agent id | +| Skill delivery | local skill dir injection | authenticated fetch from Taskcore skill endpoints | +| Taskcore auth | injected local run JWT env var | bootstrap token exchange -> run JWT | +| Cancellation | OS signals | abort polling + Cursor stop endpoint | +| Usage/cost | rich | not exposed by Cursor API | --- diff --git a/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md b/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md index b923dd9..2eb0c76 100644 --- a/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md +++ b/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md @@ -47,22 +47,26 @@ Keep Taskcore low-friction while making the mode model simpler and safer: ## Modes 1. `local_trusted` + - no login required - localhost/loopback only - optimized for single-operator local setup 2. `authenticated` + - login required for human actions - same auth stack for both private and public deployments ## Exposure Policy (Within `authenticated`) 1. `private` + - private-network deployments (LAN, VPN, Tailscale) - low-friction URL handling (`auto` base URL) - strict host allow policy for private targets 2. `public` + - internet-facing deployments - explicit public base URL required - stricter deployment checks in doctor @@ -83,11 +87,15 @@ Interactive server step: 1. ask mode with default selection `local_trusted` 2. copy for options: + - `local_trusted`: "Easiest for local setup (no login, localhost-only)" - `authenticated`: "Login required; use for private network or public hosting" + 3. if `authenticated`, ask exposure: + - `private`: "Private network access (for example Tailscale), lower setup friction" - `public`: "Internet-facing deployment, stricter security requirements" + 4. only if `authenticated + public`, ask for explicit public URL Flags are optional power-user overrides, not required for normal setup. @@ -122,6 +130,7 @@ Board must be a real DB user principal so user-centric features (task assignment ## Target Behavior 1. `local_trusted` + - seed/ensure a deterministic local board user row in `authUsers` during setup/startup. - actor middleware uses that real user id instead of synthetic-only identity. - ensure: @@ -129,6 +138,7 @@ Board must be a real DB user principal so user-centric features (task assignment - company membership can be created/maintained for this user where needed. 2. `authenticated` + - Better Auth sign-up creates user row. - bootstrap/admin flow promotes that real user to `instance_admin`. - first company creation flow should ensure creator membership is active. diff --git a/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md b/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md index 4a33ba8..bb7dff0 100644 --- a/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md +++ b/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md @@ -380,7 +380,10 @@ Add a project-owned execution workspace policy object. Suggested shared shape: ```ts type ProjectExecutionWorkspacePolicy = { enabled: boolean; - defaultMode: "inherit_project_default" | "shared_project_workspace" | "isolated_issue_checkout"; + defaultMode: + | "inherit_project_default" + | "shared_project_workspace" + | "isolated_issue_checkout"; implementation: "git_worktree" | "adapter_managed"; branchPolicy: { baseBranch: string | null; @@ -422,9 +425,17 @@ Add issue-owned opt-in/override fields. Suggested shape: ```ts type IssueExecutionWorkspaceSettings = { - mode?: "inherit_project_default" | "shared_project_workspace" | "isolated_issue_checkout"; + mode?: + | "inherit_project_default" + | "shared_project_workspace" + | "isolated_issue_checkout"; branchOverride?: string | null; - pullRequestModeOverride?: "inherit" | "none" | "agent_may_open" | "agent_auto_open" | "approval_required"; + pullRequestModeOverride?: + | "inherit" + | "none" + | "agent_may_open" + | "agent_auto_open" + | "approval_required"; }; ``` diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index 6c510e9..dc1ac60 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -4,6 +4,7 @@ Status: Proposed implementation plan Date: 2026-03-13 Audience: Product and engineering Supersedes for package-format direction: + - `doc/plans/2026-02-16-module-system.md` sections that describe company templates as JSON-only - `docs/specs/cliphub-plan.md` assumptions about blueprint bundle shape where they conflict with the markdown-first package model @@ -152,6 +153,7 @@ Resolution model: - if the skill is external or referenced, the skill package owns that complexity - exporters should prefer shortname-based associations in `AGENTS.md` - importers should resolve the shortname against local package skills first, then referenced or installed company skills + ### 5.4 Base Package Vs Taskcore Extension The repo format should have two layers: diff --git a/doc/plans/2026-03-13-features.md b/doc/plans/2026-03-13-features.md index f57227d..6e5e7a7 100644 --- a/doc/plans/2026-03-13-features.md +++ b/doc/plans/2026-03-13-features.md @@ -50,7 +50,6 @@ Then Taskcore should: - generate a local `onboarding.txt` / LLM handoff prompt - offer a button: **“Open this in Claude / copy setup prompt”** - create starter objects: - - company - company goal - CEO @@ -339,7 +338,6 @@ First remote driver: `remote_sandbox` for e2b-style execution. ### Deliverables - canonical deploy recipes: - - local solo - shared private (Tailscale/private auth) - public cloud (managed Postgres + object storage + public URL) @@ -641,7 +639,6 @@ Issue page shows: - PR URL if created - “Reopen preview” button with TTL - lifecycle: - - `todo` - `in_progress` - `in_review` diff --git a/doc/plans/2026-03-14-adapter-skill-sync-rollout.md b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md index 0c0f937..1e90a73 100644 --- a/doc/plans/2026-03-14-adapter-skill-sync-rollout.md +++ b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md @@ -4,6 +4,7 @@ Status: Proposed Date: 2026-03-14 Audience: Product and engineering Related: + - `doc/plans/2026-03-14-skills-ui-product-plan.md` - `doc/plans/2026-03-13-company-import-export-v2.md` - `docs/companies/companies-spec.md` diff --git a/doc/plans/2026-03-14-skills-ui-product-plan.md b/doc/plans/2026-03-14-skills-ui-product-plan.md index 23be47e..a37c6de 100644 --- a/doc/plans/2026-03-14-skills-ui-product-plan.md +++ b/doc/plans/2026-03-14-skills-ui-product-plan.md @@ -4,6 +4,7 @@ Status: Proposed Date: 2026-03-14 Audience: Product and engineering Related: + - `doc/plans/2026-03-13-company-import-export-v2.md` - `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` - `docs/companies/companies-spec.md` diff --git a/doc/plans/2026-03-17-memory-service-surface-api.md b/doc/plans/2026-03-17-memory-service-surface-api.md index d7e379b..3530e5c 100644 --- a/doc/plans/2026-03-17-memory-service-surface-api.md +++ b/doc/plans/2026-03-17-memory-service-surface-api.md @@ -237,8 +237,16 @@ export interface MemoryUsage { provider: string; biller?: string; model?: string; - billingType?: "metered_api" | "subscription_included" | "subscription_overage" | "unknown"; - attributionMode?: "billed_directly" | "included_in_run" | "external_invoice" | "untracked"; + billingType?: + | "metered_api" + | "subscription_included" + | "subscription_overage" + | "unknown"; + attributionMode?: + | "billed_directly" + | "included_in_run" + | "external_invoice" + | "untracked"; inputTokens?: number; cachedInputTokens?: number; outputTokens?: number; @@ -337,8 +345,14 @@ export interface MemoryAdapter { }>; query(req: MemoryQueryRequest): Promise; list(req: MemoryListRequest): Promise; - get(handle: MemoryRecordHandle, scope: MemoryScope): Promise; - forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>; + get( + handle: MemoryRecordHandle, + scope: MemoryScope, + ): Promise; + forget( + handles: MemoryRecordHandle[], + scope: MemoryScope, + ): Promise<{ usage?: MemoryUsage[] }>; } ``` diff --git a/doc/plans/2026-04-06-smart-model-routing.md b/doc/plans/2026-04-06-smart-model-routing.md index 961abe1..1c02dfa 100644 --- a/doc/plans/2026-04-06-smart-model-routing.md +++ b/doc/plans/2026-04-06-smart-model-routing.md @@ -4,6 +4,7 @@ Status: Proposed Date: 2026-04-06 Audience: Product and engineering Related: + - `doc/SPEC-implementation.md` - `doc/PRODUCT.md` - `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` diff --git a/doc/plans/2026-04-06-subissue-creation-on-issue-detail.md b/doc/plans/2026-04-06-subissue-creation-on-issue-detail.md index 1d89464..a8ca0f8 100644 --- a/doc/plans/2026-04-06-subissue-creation-on-issue-detail.md +++ b/doc/plans/2026-04-06-subissue-creation-on-issue-detail.md @@ -4,6 +4,7 @@ Status: Proposed Date: 2026-04-06 Audience: Product and engineering Related: + - `ui/src/pages/IssueDetail.tsx` - `ui/src/components/IssueProperties.tsx` - `ui/src/components/NewIssueDialog.tsx` diff --git a/doc/plans/2026-04-07-issue-detail-speed-and-optimistic-inventory.md b/doc/plans/2026-04-07-issue-detail-speed-and-optimistic-inventory.md index 79ab3da..635c881 100644 --- a/doc/plans/2026-04-07-issue-detail-speed-and-optimistic-inventory.md +++ b/doc/plans/2026-04-07-issue-detail-speed-and-optimistic-inventory.md @@ -4,6 +4,7 @@ Status: Proposed Date: 2026-04-07 Audience: Product and engineering Related: + - `ui/src/pages/IssueDetail.tsx` - `ui/src/components/IssueProperties.tsx` - `ui/src/api/issues.ts` diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 4ca9d2b..5b55cd7 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -334,7 +334,12 @@ export interface TaskcorePluginManifestV1 { }>; ui?: { slots: Array<{ - type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage"; + type: + | "page" + | "detailTab" + | "dashboardWidget" + | "sidebar" + | "settingsPage"; id: string; displayName: string; /** Which export name in the UI bundle provides this component */ @@ -640,11 +645,19 @@ export interface PluginContext { }; events: { on(name: string, fn: (event: unknown) => Promise): void; - on(name: string, filter: EventFilter, fn: (event: unknown) => Promise): void; + on( + name: string, + filter: EventFilter, + fn: (event: unknown) => Promise, + ): void; emit(name: string, payload: unknown): Promise; }; jobs: { - register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise): void; + register( + key: string, + input: { cron: string }, + fn: (job: PluginJobContext) => Promise, + ): void; }; state: { get(input: ScopeKey): Promise; @@ -656,13 +669,23 @@ export interface PluginContext { list(input: PluginEntityQuery): Promise; }; data: { - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record) => Promise, + ): void; }; actions: { - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record) => Promise, + ): void; }; tools: { - register(name: string, input: PluginToolDeclaration, fn: (params: unknown, runCtx: ToolRunContext) => Promise): void; + register( + name: string, + input: PluginToolDeclaration, + fn: (params: unknown, runCtx: ToolRunContext) => Promise, + ): void; }; logger: { info(message: string, meta?: Record): void; @@ -873,21 +896,34 @@ The plugin's UI bundle exports: ```tsx // dist/ui/index.tsx -import { usePluginData, usePluginAction, MetricCard, StatusBadge } from "@taskcore/plugin-sdk/ui"; +import { + usePluginData, + usePluginAction, + MetricCard, + StatusBadge, +} from "@taskcore/plugin-sdk/ui"; export function DashboardWidget({ context }: PluginWidgetProps) { - const { data, loading } = usePluginData("sync-health", { companyId: context.companyId }); + const { data, loading } = usePluginData("sync-health", { + companyId: context.companyId, + }); const resync = usePluginAction("resync"); if (loading) return ; return (
- - {data.mappings.map(m => ( + + {data.mappings.map((m) => ( ))} - +
); } @@ -991,18 +1027,18 @@ Plugins may add sidebar links to: The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement. -| Component | What it renders | Typical use | -|---|---|---| -| `MetricCard` | Single number with label, optional trend/sparkline | KPIs, counts, rates | -| `StatusBadge` | Inline status indicator (ok/warning/error/info) | Sync health, connection status | -| `DataTable` | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists | -| `TimeseriesChart` | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates | -| `MarkdownBlock` | Rendered markdown text | Descriptions, help text, notes | -| `KeyValueList` | Label/value pairs in a definition-list layout | Entity metadata, config summary | -| `ActionBar` | Row of buttons wired to `usePluginAction` | Resync, create branch, restart process | -| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | -| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | -| `Spinner` | Loading indicator | Data fetch states | +| Component | What it renders | Typical use | +| ----------------- | ----------------------------------------------------- | -------------------------------------------- | +| `MetricCard` | Single number with label, optional trend/sparkline | KPIs, counts, rates | +| `StatusBadge` | Inline status indicator (ok/warning/error/info) | Sync health, connection status | +| `DataTable` | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists | +| `TimeseriesChart` | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates | +| `MarkdownBlock` | Rendered markdown text | Descriptions, help text, notes | +| `KeyValueList` | Label/value pairs in a definition-list layout | Entity metadata, config summary | +| `ActionBar` | Row of buttons wired to `usePluginAction` | Resync, create branch, restart process | +| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | +| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | +| `Spinner` | Loading indicator | Data fetch states | Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render. @@ -1026,7 +1062,12 @@ The bridge hooks must return structured errors so plugin UI can handle failures ```ts interface PluginBridgeError { - code: "WORKER_UNAVAILABLE" | "CAPABILITY_DENIED" | "WORKER_ERROR" | "TIMEOUT" | "UNKNOWN"; + code: + | "WORKER_UNAVAILABLE" + | "CAPABILITY_DENIED" + | "WORKER_ERROR" + | "TIMEOUT" + | "UNKNOWN"; message: string; /** Original error details from the worker, if available */ details?: unknown; @@ -1462,14 +1503,23 @@ import { createTestHarness } from "@taskcore/plugin-test-harness"; import manifest from "../dist/manifest.js"; import { register } from "../dist/worker.js"; -const harness = createTestHarness({ manifest, capabilities: manifest.capabilities }); +const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, +}); await register(harness.ctx); // Simulate an event await harness.emit("issue.created", { issueId: "iss-1", projectId: "proj-1" }); // Verify state was written -const state = await harness.state.get({ pluginId: manifest.id, scopeKind: "issue", scopeId: "iss-1", namespace: "sync", stateKey: "external-id" }); +const state = await harness.state.get({ + pluginId: manifest.id, + scopeKind: "issue", + scopeId: "iss-1", + namespace: "sync", + stateKey: "external-id", +}); expect(state).toBeDefined(); // Simulate a UI data request @@ -1553,10 +1603,10 @@ Versioning rules: The host should publish a compatibility matrix: | Host Version | Supported API Versions | SDK Range | -|---|---|---| -| 1.0 | 1 | 1.x | -| 2.0 | 1, 2 | 1.x, 2.x | -| 3.0 | 2, 3 | 2.x, 3.x | +| ------------ | ---------------------- | --------- | +| 1.0 | 1 | 1.x | +| 2.0 | 1, 2 | 1.x, 2.x | +| 3.0 | 2, 3 | 2.x, 3.x | This matrix is published in the host docs and queryable via `GET /api/plugins/compatibility`. diff --git a/doc/plugins/ideas-from-opencode.md b/doc/plugins/ideas-from-opencode.md index dfc0ed9..4f6bc3d 100644 --- a/doc/plugins/ideas-from-opencode.md +++ b/doc/plugins/ideas-from-opencode.md @@ -365,14 +365,14 @@ Taskcore should require an explicit operator install step. The products are solving different problems. -| Topic | OpenCode | Taskcore | -|---|---|---| -| Primary unit | local project/worktree | single-tenant operator instance with company objects | -| Trust assumption | local power user on own machine | operator managing one trusted Taskcore instance | -| Failure blast radius | local session/runtime | entire company control plane | -| Extension style | mutate runtime behavior freely | preserve governance and auditability | -| UI model | local app can load local behavior | board UI must stay coherent and safe | -| Security model | host-trusted local plugins | needs capability boundaries and auditability | +| Topic | OpenCode | Taskcore | +| -------------------- | --------------------------------- | ---------------------------------------------------- | +| Primary unit | local project/worktree | single-tenant operator instance with company objects | +| Trust assumption | local power user on own machine | operator managing one trusted Taskcore instance | +| Failure blast radius | local session/runtime | entire company control plane | +| Extension style | mutate runtime behavior freely | preserve governance and auditability | +| UI model | local app can load local behavior | board UI must stay coherent and safe | +| Security model | host-trusted local plugins | needs capability boundaries and auditability | That means Taskcore should borrow the good ideas from `opencode` but use a stricter architecture. @@ -399,13 +399,13 @@ Do not create one giant `hooks` object for everything. Use distinct plugin classes with different trust models. -| Extension class | Examples | Runtime model | Trust level | Why | -|---|---|---|---|---| -| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | -| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | -| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | out-of-process, direct OS access | medium | resolves workspace paths from host, owns filesystem/git/PTY/process logic directly | -| UI contribution | dashboard widgets, settings forms, company panels | plugin-shipped React bundles in host extension slots via bridge | medium | plugins own their rendering; host controls slot placement and bridge access | -| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | +| Extension class | Examples | Runtime model | Trust level | Why | +| ----------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------- | +| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | +| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | +| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | out-of-process, direct OS access | medium | resolves workspace paths from host, owns filesystem/git/PTY/process logic directly | +| UI contribution | dashboard widgets, settings forms, company panels | plugin-shipped React bundles in host extension slots via bridge | medium | plugins own their rendering; host controls slot placement and bridge access | +| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | This split is the most important design recommendation in this report. @@ -679,19 +679,37 @@ The plugin's UI bundle (separate from the worker) might look like: ```tsx // dist/ui/index.tsx -import { usePluginData, usePluginAction, MetricCard, ErrorBoundary } from "@taskcore/plugin-sdk/ui"; +import { + usePluginData, + usePluginAction, + MetricCard, + ErrorBoundary, +} from "@taskcore/plugin-sdk/ui"; export function DashboardWidget({ context }: PluginWidgetProps) { - const { data, loading, error } = usePluginData("sync-health", { companyId: context.companyId }); + const { data, loading, error } = usePluginData("sync-health", { + companyId: context.companyId, + }); const resync = usePluginAction("resync"); if (loading) return ; - if (error) return
Plugin error: {error.message} ({error.code})
; + if (error) + return ( +
+ Plugin error: {error.message} ({error.code}) +
+ ); return ( Widget failed to render
}> - - + + ); } @@ -1002,16 +1020,16 @@ This is a useful middle ground: ## How The Requested Examples Map To This Model -| Use case | Best fit | Host primitives needed | Notes | -|---|---|---|---| -| File browser | workspace plugin | project workspace metadata | plugin owns filesystem ops directly | -| Terminal | workspace plugin | project workspace metadata | plugin spawns PTY sessions directly | -| Git workflow | workspace plugin | project workspace metadata | plugin shells out to git directly | -| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | -| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | -| Grafana metrics | connector plugin + dashboard widget | outbound HTTP | probably read-only first | -| Child process/server tracking | workspace plugin | project workspace metadata | plugin manages processes directly | -| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate | +| Use case | Best fit | Host primitives needed | Notes | +| ----------------------------- | ----------------------------------- | ------------------------------------------------ | ----------------------------------- | +| File browser | workspace plugin | project workspace metadata | plugin owns filesystem ops directly | +| Terminal | workspace plugin | project workspace metadata | plugin spawns PTY sessions directly | +| Git workflow | workspace plugin | project workspace metadata | plugin shells out to git directly | +| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | +| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | +| Grafana metrics | connector plugin + dashboard widget | outbound HTTP | probably read-only first | +| Child process/server tracking | workspace plugin | project workspace metadata | plugin manages processes directly | +| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate | # Plugin Examples diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index b3040a2..4ddece5 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -29,9 +29,10 @@ The following intentions are explicitly preserved in this spec: 10. CLI errors must be visible in full (or as much as possible) in the UI. 11. Status changes must live-update across task and agent views via server push. 12. Wakeup triggers should be centralized by a heartbeat/wakeup service with at least: - - timer interval - - wake on task assignment - - explicit ping/request + +- timer interval +- wake on task assignment +- explicit ping/request ## 3. Goals and Non-Goals @@ -144,9 +145,15 @@ interface AdapterInvokeInput { interface AdapterHooks { status?: (update: { message: string; color?: StatusColor }) => Promise; - log?: (event: { stream: "stdout" | "stderr" | "system"; chunk: string }) => Promise; + log?: (event: { + stream: "stdout" | "stderr" | "system"; + chunk: string; + }) => Promise; usage?: (usage: TokenUsage) => Promise; - event?: (eventType: string, payload: Record) => Promise; + event?: ( + eventType: string, + payload: Record, + ) => Promise; } interface AdapterInvokeResult { @@ -172,8 +179,14 @@ interface AgentRunAdapter { logStreaming: boolean; tokenUsage: boolean; }; - validateConfig(config: unknown): { ok: true } | { ok: false; errors: string[] }; - invoke(input: AdapterInvokeInput, hooks: AdapterHooks, signal: AbortSignal): Promise; + validateConfig( + config: unknown, + ): { ok: true } | { ok: false; errors: string[] }; + invoke( + input: AdapterInvokeInput, + hooks: AdapterHooks, + signal: AbortSignal, + ): Promise; } ``` @@ -202,10 +215,18 @@ interface RunLogHandle { } interface RunLogStore { - begin(input: { companyId: string; agentId: string; runId: string }): Promise; + begin(input: { + companyId: string; + agentId: string; + runId: string; + }): Promise; append( handle: RunLogHandle, - event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string }, + event: { + stream: "stdout" | "stderr" | "system"; + chunk: string; + ts: string; + }, ): Promise; finalize( handle: RunLogHandle, @@ -251,7 +272,7 @@ Runs local `claude` CLI directly. "model": "optional-model-id", "maxTurnsPerRun": 1000, "dangerouslySkipPermissions": true, - "env": {"KEY": "VALUE"}, + "env": { "KEY": "VALUE" }, "extraArgs": [], "timeoutSec": 1800, "graceSec": 20 @@ -288,7 +309,7 @@ Runs local `codex` CLI directly. "model": "optional-model-id", "search": false, "dangerouslyBypassApprovalsAndSandbox": true, - "env": {"KEY": "VALUE"}, + "env": { "KEY": "VALUE" }, "extraArgs": [], "timeoutSec": 1800, "graceSec": 20 diff --git a/doc/spec/ui.md b/doc/spec/ui.md index 8e8be45..ce1afbc 100644 --- a/doc/spec/ui.md +++ b/doc/spec/ui.md @@ -24,6 +24,7 @@ Design principles: - **Accent (interactive):** `hsl(220, 80%, 60%)` (muted blue) Status colors (consistent across all entities): + - **Backlog:** gray `hsl(220, 10%, 45%)` - **Todo:** gray-blue `hsl(220, 20%, 55%)` - **In Progress:** yellow `hsl(45, 90%, 55%)` @@ -33,6 +34,7 @@ Status colors (consistent across all entities): - **Blocked:** amber `hsl(25, 90%, 55%)` Priority indicators: + - **Critical:** red circle, filled - **High:** orange circle, half-filled - **Medium:** yellow circle, outline @@ -91,17 +93,20 @@ Top of sidebar. Always visible. ``` **Company switcher** is a dropdown button that occupies the full width of the sidebar header. It shows: + - Company icon (first letter avatar with company color, or uploaded icon) - Company name (truncated with ellipsis if long) - Chevron-down icon Clicking opens a dropdown with: + - List of all companies (with status dot: green=active, yellow=paused, gray=archived) - Search field at top of dropdown (for users with many companies) - Divider - `+ Create company` action at the bottom Below the company name, a row of icon buttons: + - **Search** (magnifying glass icon) — opens Cmd+K search modal - **New Issue** (pencil/square-pen icon) — opens new issue modal in the current company context @@ -168,19 +173,19 @@ Note: Approvals do not have a top-level sidebar entry. They are surfaced through Each nav item has a distinctive icon (lucide-react): -| Item | Icon | -|------|------| -| Inbox | `Inbox` | -| My Issues | `CircleUser` | -| Issues | `CircleDot` | -| Projects | `Hexagon` | -| Goals | `Target` | -| Views | `LayoutList` | +| Item | Icon | +| --------- | ----------------- | +| Inbox | `Inbox` | +| My Issues | `CircleUser` | +| Issues | `CircleDot` | +| Projects | `Hexagon` | +| Goals | `Target` | +| Views | `LayoutList` | | Dashboard | `LayoutDashboard` | -| Org Chart | `GitBranch` | -| Agents | `Bot` | -| Costs | `DollarSign` | -| Activity | `History` | +| Org Chart | `GitBranch` | +| Agents | `Bot` | +| Costs | `DollarSign` | +| Activity | `History` | --- @@ -197,6 +202,7 @@ The breadcrumb bar sits above the main content and properties panel. It serves a ``` **Left side:** + - Breadcrumb segments, separated by `›` chevrons. - Each segment is clickable to navigate to that level. - Current segment is non-clickable, slightly bolder text. @@ -204,6 +210,7 @@ The breadcrumb bar sits above the main content and properties panel. It serves a - Three-dot menu for entity actions (delete, archive, duplicate, copy link, etc.) **Right side:** + - Notification bell (if in a detail view — subscribe to changes on this entity) - Panel toggle (show/hide the right properties panel) @@ -212,11 +219,13 @@ The breadcrumb bar sits above the main content and properties panel. It serves a On certain detail pages, the breadcrumb bar also contains a tab row below the breadcrumbs: **Project detail:** + ``` Overview Updates Issues Settings ``` **Agent detail:** + ``` Overview Heartbeats Issues Costs ``` @@ -234,6 +243,7 @@ Issues are the core work unit. This section details the full issue experience. The issue list is the default view when clicking "Issues" in the sidebar. **Layout:** + ``` ┌─────────────────────────────────────────────────────────────────┐ │ [All Issues] [Active] [Backlog] [⚙ Settings] [≡ Filter] [Display ▼] │ @@ -254,18 +264,21 @@ The issue list is the default view when clicking "Issues" in the sidebar. ``` **Top toolbar:** + - **Status tabs:** `All Issues`, `Active` (todo + in_progress + in_review + blocked), `Backlog`. Each tab shows a status icon and count. Active tab is filled, others outlined. - **Settings gear:** Configure issue display defaults, custom fields. - **Filter button:** Opens a filter bar below the toolbar. - **Display dropdown:** Toggle between grouping modes (by status, by priority, by assignee, by project, none) and layout modes (list, board/kanban). **Grouping:** + - Issues are grouped by status by default (matching the reference screenshots). - Each group header shows: collapse chevron, status icon, status name, count, and a `+` button to create a new issue in that status. - Groups are collapsible. Collapsed groups show just the header with count. **Issue rows:** Each row contains, left to right: + 1. **Checkbox** — for bulk selection. Hidden by default, appears on hover (left of priority). 2. **Priority indicator** — icon representing critical/high/medium/low (see Color System above). Always visible. 3. **Issue key** — e.g., `CLIP-5`. Monospace, muted color. The prefix is derived from the project (or company if no project). @@ -275,6 +288,7 @@ Each row contains, left to right: 7. **Date** — creation date or target date, muted text, far right. **Row interactions:** + - Click row → navigate to issue detail view. - Click status circle → opens inline status dropdown (Backlog, Todo, In Progress, In Review, Done, Cancelled) with keyboard numbers as shortcuts (1-6). - Click checkbox → selects for bulk actions. When any checkbox is selected, a bulk action bar appears at the bottom of the list. @@ -283,6 +297,7 @@ Each row contains, left to right: **Bulk action bar:** When one or more issues are selected, a floating bar appears at the bottom: + ``` ┌─────────────────────────────────────────────────────────┐ │ 3 selected [Status ▼] [Priority ▼] [Assignee ▼] [Project ▼] [🗑 Delete] [✕ Cancel] │ @@ -347,21 +362,25 @@ Clicking an issue opens the detail view. The main content area splits into two z #### Middle Pane (Main Content) **Header area:** + - Issue title, large (18px, semi-bold), editable on click (inline editing). - Subtitle: issue key `CLIP-42` in muted text. - Below the title: inline properties bar showing key properties as clickable chips (same pattern as reference screenshots): `[○ In Progress] [!!! High] [👤 CTO] [📅 Target date] [📁 Auth] [···]`. Each chip is clickable to change that property inline. **Description:** + - Markdown-rendered description. - Click to edit — opens a markdown editor in-place. - Support for headings, lists, code blocks, links, images. **Subtasks (if any):** + - Listed below description as a collapsible section. - Each subtask is a mini issue row (status circle + title + assignee). - `+ Add subtask` button at the bottom. **Comments:** + - Chronological list of comments. - Each comment shows: author avatar/icon, author name, timestamp, body (markdown rendered). - Comment input at the bottom — a text area with markdown support and a "Comment" button. @@ -373,25 +392,26 @@ Clicking an issue opens the detail view. The main content area splits into two z **Property list:** Each property is a row with label on the left and editable value on the right. -| Property | Control | -|----------|---------| -| Status | Dropdown with status options + colored dot | -| Priority | Dropdown with priority options + icon | -| Assignee | Agent picker dropdown with search | -| Project | Project picker dropdown | -| Goal | Goal picker dropdown | -| Labels | Multi-select tag input | -| Lead | Agent picker | -| Members | Multi-select agent picker | -| Start date | Date picker | -| Target date | Date picker | -| Created by | Read-only text | -| Created | Read-only timestamp | -| Billing code | Text input | +| Property | Control | +| ------------ | ------------------------------------------ | +| Status | Dropdown with status options + colored dot | +| Priority | Dropdown with priority options + icon | +| Assignee | Agent picker dropdown with search | +| Project | Project picker dropdown | +| Goal | Goal picker dropdown | +| Labels | Multi-select tag input | +| Lead | Agent picker | +| Members | Multi-select agent picker | +| Start date | Date picker | +| Target date | Date picker | +| Created by | Read-only text | +| Created | Read-only timestamp | +| Billing code | Text input | Below properties, a divider, then: **Activity section:** + - "Activity" header with "See all" link. - Compact timeline of recent events: status changes, assignment changes, comments, etc. - Each entry: icon + description + relative timestamp. @@ -422,26 +442,31 @@ Triggered by the sidebar pencil icon, keyboard shortcut `C`, or the `+` buttons ``` **Top bar:** + - Breadcrumb showing context: project key (or company key) `›` "New issue". - "Save as draft" button. - Expand icon (open as full page instead of modal). - Close `×`. **Body:** + - Title field: large input, placeholder "Issue title". Auto-focused on open. - Description: markdown editor below, placeholder "Add a description...". Expandable. **Property chips (bottom bar):** + - Compact row of property buttons. Each opens a dropdown to set that property. - Default chips shown: Status (defaults to Todo), Priority, Assignee, Project, Labels. - `···` more button reveals: Goal, Start date, Target date, Billing code, Parent issue. **Footer:** + - Attachment button (taskcore icon). - "Create more" toggle — when on, creating an issue clears the form and stays open for rapid entry. - "Create issue" primary button. **Behavior:** + - `Cmd+Enter` submits the form. - If opened from within a project context, the project is pre-filled. - If opened from a specific status group's `+` button, that status is pre-filled. @@ -454,6 +479,7 @@ Accessible via Display dropdown → Board layout. Columns represent statuses: Backlog | Todo | In Progress | In Review | Done Each card shows: + - Issue key (muted) - Title (primary text) - Priority icon (bottom-left) @@ -490,6 +516,7 @@ Uses the same three-pane layout as issue detail. **Breadcrumb tabs:** Overview | Updates | Issues | Settings **Overview tab (middle pane):** + - Project icon + name (editable) - Description (markdown, editable) - Inline properties bar: `[◌ Backlog] [--- No priority] [👤 Lead] [📅 Target date] [🏢 Team] [···]` @@ -562,14 +589,13 @@ The dashboard is the company health overview. Shown when clicking "Dashboard" in ``` **Top row: Metric cards** (4 across) + 1. **Agents** — total, active, running, paused, error counts. Each with colored dots. 2. **Tasks** — open, in progress, blocked, done counts. 3. **Costs** — month-to-date spend in dollars, budget utilization percentage with a mini progress bar. 4. **Approvals** — pending count (clickable to navigate to Inbox, which is the primary approval interaction point). -**Bottom row: Detail panels** (2 across) -5. **Recent Activity** — last ~10 activity log entries, compact timeline format. -6. **Stale Tasks** — tasks that have been in progress for too long without updates. Each shows issue key, title, assignee, time since last activity. +**Bottom row: Detail panels** (2 across) 5. **Recent Activity** — last ~10 activity log entries, compact timeline format. 6. **Stale Tasks** — tasks that have been in progress for too long without updates. Each shows issue key, title, assignee, time since last activity. All cards and panels are clickable to navigate to their respective full pages. @@ -598,6 +624,7 @@ Interactive visualization of the agent reporting hierarchy. ``` Each node shows: + - Agent name - Role/title (smaller text) - Status dot (colored by agent status) @@ -639,6 +666,7 @@ Clicking a row navigates to agent detail. **Breadcrumb tabs:** Overview | Heartbeats | Issues | Costs **Overview (middle pane):** + - Agent name + role - Capabilities description - Adapter type + config summary @@ -669,6 +697,7 @@ Approvals are governance gates — decisions the board must make (hire an agent, **2. Dashboard metric card.** The "Pending Approvals" card shows the count and links to the full approvals list. **3. Inline on entity pages.** When an entity was created via an approval, the detail page shows a contextual banner: + - Agent detail page: `"Hired via approval — requested by CEO on Feb 15"` with a link to the approval record. - An agent in `pending` status (not yet created) could show: `"Pending approval — requested by CEO"` with approve/reject actions inline. @@ -699,6 +728,7 @@ Three-pane layout. Middle pane renders the approval payload nicely based on type **`approve_ceo_strategy` type:** Shows the strategy text, proposed goal breakdown, initial task structure. For pending approvals, prominent action buttons at the top of the middle pane: + ``` ┌─────────────────────────────────────────────────────────┐ │ ┌─────────────────────────────────────────────────────┐ │ @@ -816,6 +846,7 @@ The inbox is the board operator's primary action center. It aggregates everythin Items are grouped by category, with the most actionable items first: **Approvals pending** (top priority). Each approval item shows: + - Shield icon + approval type + title - Requester + relative timestamp - Key payload summary (1 line — agent name/role for hires, plan title for strategies) @@ -868,20 +899,20 @@ Global search accessible via `Cmd+K` or the sidebar search icon. ## 16. Keyboard Shortcuts -| Shortcut | Action | -|----------|--------| -| `Cmd+K` | Open search | -| `C` | Create new issue | -| `Cmd+Enter` | Submit form (in modals) | -| `Escape` | Close modal / deselect | -| `[` | Toggle sidebar collapsed | -| `]` | Toggle properties panel | -| `J` / `K` | Navigate up/down in lists | -| `Enter` | Open selected item | -| `Backspace` | Go back | -| `S` | Toggle status on selected issue | -| `X` | Toggle checkbox selection | -| `Cmd+A` | Select all (in list context) | +| Shortcut | Action | +| ----------- | ------------------------------- | +| `Cmd+K` | Open search | +| `C` | Create new issue | +| `Cmd+Enter` | Submit form (in modals) | +| `Escape` | Close modal / deselect | +| `[` | Toggle sidebar collapsed | +| `]` | Toggle properties panel | +| `J` / `K` | Navigate up/down in lists | +| `Enter` | Open selected item | +| `Backspace` | Go back | +| `S` | Toggle status on selected issue | +| `X` | Toggle checkbox selection | +| `Cmd+A` | Select all (in list context) | --- @@ -920,17 +951,17 @@ Empty states should use a muted illustration (simple line art, not cartoons) and Build on top of shadcn/ui components with these customizations: -| Component | Base | Customization | -|-----------|------|---------------| -| StatusBadge | Badge | Colored dot + label, entity-specific palettes | -| PriorityIcon | custom | SVG circles with fills matching priority | -| EntityRow | custom | Standardized list row with hover/select states | -| PropertyEditor | custom | Label + inline-editable value with dropdown | -| CommentThread | custom | Avatar + author + timestamp + markdown body | -| BreadcrumbBar | Breadcrumb | Integrated with router, tabs, and entity actions | -| CommandPalette | Dialog | Cmd+K search with type-ahead and actions | -| FilterBar | custom | Composable filter chips with add/remove | -| SidebarNav | custom | Grouped, collapsible, badge-supporting nav | +| Component | Base | Customization | +| -------------- | ---------- | ------------------------------------------------ | +| StatusBadge | Badge | Colored dot + label, entity-specific palettes | +| PriorityIcon | custom | SVG circles with fills matching priority | +| EntityRow | custom | Standardized list row with hover/select states | +| PropertyEditor | custom | Label + inline-editable value with dropdown | +| CommentThread | custom | Avatar + author + timestamp + markdown body | +| BreadcrumbBar | Breadcrumb | Integrated with router, tabs, and entity actions | +| CommandPalette | Dialog | Cmd+K search with type-ahead and actions | +| FilterBar | custom | Composable filter chips with add/remove | +| SidebarNav | custom | Grouped, collapsible, badge-supporting nav | --- @@ -966,6 +997,7 @@ All routes are company-scoped after company selection (company context stored in ## 22. Implementation Priority ### Phase 1: Shell and Navigation + 1. Sidebar redesign (grouped sections, icons, company switcher, badges) 2. Breadcrumb bar component 3. Three-pane layout system @@ -973,6 +1005,7 @@ All routes are company-scoped after company selection (company context stored in 5. Install `lucide-react` ### Phase 2: Issue Management (Core) + 6. Issue list view with grouping, filtering, status circles 7. Issue detail view (three-pane with properties panel) 8. New issue modal @@ -981,11 +1014,13 @@ All routes are company-scoped after company selection (company context stored in 11. Kanban board view ### Phase 3: Entity Detail Views + 12. Project list + detail view 13. Goal hierarchy view 14. Agent list + detail view ### Phase 4: Company-Level Views + 15. Inbox with inline approval actions (primary approval UX) 16. Dashboard redesign with metric cards 17. Org chart interactive visualization @@ -994,6 +1029,7 @@ All routes are company-scoped after company selection (company context stored in 20. Approvals list page (accessed via Inbox "See all", not sidebar) ### Phase 5: Polish + 21. Keyboard shortcuts 22. Responsive behavior 23. Empty states and loading skeletons diff --git a/docs/adapters/adapter-ui-parser.md b/docs/adapters/adapter-ui-parser.md index 5aaa32c..976fdb3 100644 --- a/docs/adapters/adapter-ui-parser.md +++ b/docs/adapters/adapter-ui-parser.md @@ -71,11 +71,11 @@ With a parser, the UI renders: The Taskcore host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code. -| Host expects | Adapter declares | Result | -|---|---|---| -| `1.x` | `1.0.0` | Parser loaded | -| `1.x` | `2.0.0` | Warning logged, generic parser used | -| `1.x` | (missing) | Parser loaded (grace period — future versions may require it) | +| Host expects | Adapter declares | Result | +| ------------ | ---------------- | ------------------------------------------------------------- | +| `1.x` | `1.0.0` | Parser loaded | +| `1.x` | `2.0.0` | Warning logged, generic parser used | +| `1.x` | (missing) | Parser loaded (grace period — future versions may require it) | ### 2. `exports["./ui-parser"]` — file path @@ -132,7 +132,13 @@ export function createStdoutParser() { suppressContinuation = true; return [ { kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id }, - { kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false }, + { + kind: "tool_result", + ts, + toolUseId: id, + content: trimmed, + isError: false, + }, ]; } @@ -186,8 +192,20 @@ Use `toolUseId` to pair `tool_call` and `tool_result` entries. The UI renders th ```ts const id = `my-tool-${++counter}`; return [ - { kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id }, - { kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false }, + { + kind: "tool_call", + ts, + name: "read", + input: { path: "/src/main.ts" }, + toolUseId: id, + }, + { + kind: "tool_result", + ts, + toolUseId: id, + content: "const main = () => {...}", + isError: false, + }, ]; ``` @@ -215,24 +233,24 @@ Set `isError: true` on tool results to show a red indicator: ## Lifecycle -| Event | What happens | -|---|---| -| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory | -| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` | -| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background | -| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser | -| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries | -| Server restart | In-memory cache is repopulated from adapter packages | +| Event | What happens | +| ------------------------------ | ---------------------------------------------------------------------------------------------------- | +| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory | +| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` | +| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background | +| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser | +| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries | +| Server restart | In-memory cache is repopulated from adapter packages | ## Error Behavior -| Failure | What happens | -|---|---| -| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. | -| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. | -| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. | -| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. | -| Contract version mismatch | Server logs warning, skips loading. Generic parser used. | +| Failure | What happens | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | +| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. | +| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. | +| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. | +| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. | +| Contract version mismatch | Server logs warning, skips loading. Generic parser used. | ## Building diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index cdc1eec..c5a41df 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -12,28 +12,28 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports ## Configuration Fields -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | -| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) | -| `promptTemplate` | string | No | Prompt used for all runs | -| `env` | object | No | Environment variables (supports secret refs) | -| `timeoutSec` | number | No | Process timeout (0 = no timeout) | -| `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | -| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | +| Field | Type | Required | Description | +| ---------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) | +| `promptTemplate` | string | No | Prompt used for all runs | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | +| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | ## Prompt Templates Templates support `{{variable}}` substitution: -| Variable | Value | -|----------|-------| -| `{{agentId}}` | Agent's ID | -| `{{companyId}}` | Company ID | -| `{{runId}}` | Current run ID | -| `{{agent.name}}` | Agent's name | -| `{{company.name}}` | Company name | +| Variable | Value | +| ------------------ | -------------- | +| `{{agentId}}` | Agent's ID | +| `{{companyId}}` | Company ID | +| `{{runId}}` | Current run ID | +| `{{agent.name}}` | Agent's name | +| `{{company.name}}` | Company name | ## Session Persistence diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index 2e3edf6..85d80ef 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -12,16 +12,16 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p ## Configuration Fields -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | -| `model` | string | No | Model to use | -| `promptTemplate` | string | No | Prompt used for all runs | -| `env` | object | No | Environment variables (supports secret refs) | -| `timeoutSec` | number | No | Process timeout (0 = no timeout) | -| `graceSec` | number | No | Grace period before force-kill | -| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster | -| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) | +| Field | Type | Required | Description | +| -------------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Model to use | +| `promptTemplate` | string | No | Prompt used for all runs | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster | +| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) | ## Session Persistence diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index cdd43b2..a31f49d 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -11,13 +11,13 @@ If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can ## Two Paths -| | Built-in | External Plugin | -|---|---|---| -| Source | Inside `taskcore-fork` | Separate npm package | -| Distribution | Ships with Taskcore | Independent npm publish | -| UI parser | Static import | Dynamic load from API | -| Registration | Edit 3 registries | Auto-loaded at startup | -| Best for | Core adapters, contributors | Third-party adapters, internal tools | +| | Built-in | External Plugin | +| ------------ | --------------------------- | ------------------------------------ | +| Source | Inside `taskcore-fork` | Separate npm package | +| Distribution | Ships with Taskcore | Independent npm publish | +| UI parser | Static import | Dynamic load from API | +| Registration | Edit 3 registries | Auto-loaded at startup | +| Best for | Core adapters, contributors | Third-party adapters, internal tools | For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Taskcore's source. See [External Adapters](/adapters/external-adapters) for the full guide. @@ -53,11 +53,9 @@ my-adapter/ # external plugin `src/index.ts` is imported by all three consumers. Keep it dependency-free. ```ts -export const type = "my_agent"; // snake_case, globally unique +export const type = "my_agent"; // snake_case, globally unique export const label = "My Agent (local)"; -export const models = [ - { id: "model-a", label: "Model A" }, -]; +export const models = [{ id: "model-a", label: "Model A" }]; export const agentConfigurationDoc = `# my_agent configuration Use when: ... Don't use when: ... @@ -84,23 +82,31 @@ Key responsibilities: ### Available Helpers -| Helper | Source | Purpose | -|--------|--------|---------| +| Helper | Source | Purpose | +| ---------------------------- | -------------------------------------- | ------------------------------------ | | `runChildProcess(cmd, opts)` | `@taskcore/adapter-utils/server-utils` | Spawn with timeout, grace, streaming | -| `buildTaskcoreEnv(agent)` | `@taskcore/adapter-utils/server-utils` | Inject `TASKCORE_*` env vars | -| `renderTemplate(tpl, data)` | `@taskcore/adapter-utils/server-utils` | `{{variable}}` substitution | -| `asString(v)` | `@taskcore/adapter-utils` | Safe config value extraction | -| `asNumber(v)` | `@taskcore/adapter-utils` | Safe number extraction | +| `buildTaskcoreEnv(agent)` | `@taskcore/adapter-utils/server-utils` | Inject `TASKCORE_*` env vars | +| `renderTemplate(tpl, data)` | `@taskcore/adapter-utils/server-utils` | `{{variable}}` substitution | +| `asString(v)` | `@taskcore/adapter-utils` | Safe config value extraction | +| `asNumber(v)` | `@taskcore/adapter-utils` | Safe number extraction | ### AdapterExecutionContext ```ts interface AdapterExecutionContext { runId: string; - agent: { id: string; companyId: string; name: string; adapterConfig: unknown }; - runtime: { sessionId: string | null; sessionParams: Record | null }; - config: Record; // agent's adapterConfig - context: Record; // task, wake reason, etc. + agent: { + id: string; + companyId: string; + name: string; + adapterConfig: unknown; + }; + runtime: { + sessionId: string | null; + sessionParams: Record | null; + }; + config: Record; // agent's adapterConfig + context: Record; // task, wake reason, etc. onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; @@ -116,12 +122,12 @@ interface AdapterExecutionResult { timedOut: boolean; errorMessage?: string | null; usage?: { inputTokens: number; outputTokens: number }; - sessionParams?: Record | null; // persist across heartbeats + sessionParams?: Record | null; // persist across heartbeats sessionDisplayId?: string | null; provider?: string | null; model?: string | null; costUsd?: number | null; - clearSession?: boolean; // set true to force fresh session on next wake + clearSession?: boolean; // set true to force fresh session on next wake } ``` @@ -131,11 +137,11 @@ interface AdapterExecutionResult { Return structured diagnostics: -| Level | Meaning | Effect | -|-------|---------|--------| -| `error` | Invalid or unusable setup | Blocks execution | -| `warn` | Non-blocking issue | Shown with yellow indicator | -| `info` | Successful check | Shown in test results | +| Level | Meaning | Effect | +| ------- | ------------------------- | --------------------------- | +| `error` | Invalid or unusable setup | Blocks execution | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `info` | Successful check | Shown in test results | ```ts export async function testEnvironment( @@ -143,10 +149,15 @@ export async function testEnvironment( ): Promise { return { adapterType: ctx.adapterType, - status: "pass", // "pass" | "warn" | "fail" + status: "pass", // "pass" | "warn" | "fail" checks: [ { level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" }, - { level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" }, + { + level: "warn", + message: "No API key found", + hint: "Set ANTHROPIC_API_KEY", + code: "no_key", + }, ], testedAt: new Date().toISOString(), }; @@ -197,9 +208,15 @@ If your agent runtime supports conversation continuity across heartbeats: ```ts export const sessionCodec: AdapterSessionCodec = { - deserialize(raw) { /* validate raw session data */ }, - serialize(params) { /* serialize for storage */ }, - getDisplayId(params) { /* human-readable session label */ }, + deserialize(raw) { + /* validate raw session data */ + }, + serialize(params) { + /* serialize for storage */ + }, + getDisplayId(params) { + /* human-readable session label */ + }, }; ``` diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md index f34e6af..7045c8c 100644 --- a/docs/adapters/external-adapters.md +++ b/docs/adapters/external-adapters.md @@ -7,13 +7,13 @@ Taskcore supports external adapter plugins that can be installed from npm packag ## Built-in vs External -| | Built-in | External | -|---|---|---| -| Source location | Inside `taskcore-fork/packages/adapters/` | Separate npm package or local directory | -| Registration | Hardcoded in three registries | Loaded at startup via plugin system | -| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) | -| Distribution | Ships with Taskcore | Published to npm or linked via `file:` | -| Updates | Requires Taskcore release | Independent versioning | +| | Built-in | External | +| --------------- | ----------------------------------------- | -------------------------------------------------------------------------- | +| Source location | Inside `taskcore-fork/packages/adapters/` | Separate npm package or local directory | +| Registration | Hardcoded in three registries | Loaded at startup via plugin system | +| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) | +| Distribution | Ships with Taskcore | Published to npm or linked via `file:` | +| Updates | Requires Taskcore release | Independent versioning | ## Quick Start @@ -66,12 +66,12 @@ my-adapter/ Key fields: -| Field | Purpose | -|-------|---------| -| `exports["."]` | Entry point — must export `createServerAdapter` | -| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) | -| `taskcore.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) | -| `files` | Limits what gets published — only `dist/` | +| Field | Purpose | +| -------------------------- | ---------------------------------------------------------- | +| `exports["."]` | Entry point — must export `createServerAdapter` | +| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) | +| `taskcore.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) | +| `files` | Limits what gets published — only `dist/` | ### tsconfig.json @@ -99,12 +99,10 @@ The plugin loader calls `createServerAdapter()` from your package root. This fun ### src/index.ts ```ts -export const type = "my_adapter"; // snake_case, globally unique +export const type = "my_adapter"; // snake_case, globally unique export const label = "My Agent (local)"; -export const models = [ - { id: "model-a", label: "Model A" }, -]; +export const models = [{ id: "model-a", label: "Model A" }]; export const agentConfigurationDoc = `# my_adapter configuration Use when: ... @@ -191,19 +189,21 @@ export async function execute( exitCode: result.exitCode, timedOut: result.timedOut, // Include session state for persistence - sessionParams: { /* ... */ }, + sessionParams: { + /* ... */ + }, }; } ``` #### Available Helpers from `@taskcore/adapter-utils` -| Helper | Purpose | -|--------|---------| -| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks | -| `buildTaskcoreEnv(agent)` | Inject `TASKCORE_*` environment variables | -| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates | -| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction | +| Helper | Purpose | +| -------------------------------------------- | ------------------------------------------------------------------------- | +| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks | +| `buildTaskcoreEnv(agent)` | Inject `TASKCORE_*` environment variables | +| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates | +| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction | ### src/server/test.ts @@ -240,7 +240,7 @@ export async function testEnvironment( return { adapterType: ctx.adapterType, - status: checks.some(c => c.level === "error") ? "fail" : "pass", + status: checks.some((c) => c.level === "error") ? "fail" : "pass", checks, testedAt: new Date().toISOString(), }; @@ -249,11 +249,11 @@ export async function testEnvironment( Check levels: -| Level | Meaning | Effect | -|-------|---------|--------| -| `info` | Informational | Shown in test results | -| `warn` | Non-blocking issue | Shown with yellow indicator | -| `error` | Blocks execution | Prevents agent from running | +| Level | Meaning | Effect | +| ------- | ------------------ | --------------------------- | +| `info` | Informational | Shown in test results | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `error` | Blocks execution | Prevents agent from running | ## Installation @@ -321,7 +321,7 @@ export const sessionCodec: AdapterSessionCodec = { Include it in `createServerAdapter()`: ```ts -return { type, execute, testEnvironment, sessionCodec, /* ... */ }; +return { type, execute, testEnvironment, sessionCodec /* ... */ }; ``` ## Optional: Skills Sync @@ -345,7 +345,9 @@ return { }, async syncSkills(ctx, desiredSkills) { // Install desired skills into the runtime - return { /* same shape as listSkills */ }; + return { + /* same shape as listSkills */ + }; }, }; ``` diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md index 772202e..a8ee7a9 100644 --- a/docs/adapters/gemini-local.md +++ b/docs/adapters/gemini-local.md @@ -12,16 +12,16 @@ The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session ## Configuration Fields -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | -| `model` | string | No | Gemini model to use. Defaults to `auto`. | -| `promptTemplate` | string | No | Prompt used for all runs | -| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt | -| `env` | object | No | Environment variables (supports secret refs) | -| `timeoutSec` | number | No | Process timeout (0 = no timeout) | -| `graceSec` | number | No | Grace period before force-kill | -| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation | +| Field | Type | Required | Description | +| ---------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Gemini model to use. Defaults to `auto`. | +| `promptTemplate` | string | No | Prompt used for all runs | +| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation | ## Session Persistence diff --git a/docs/adapters/http.md b/docs/adapters/http.md index 7746605..861d576 100644 --- a/docs/adapters/http.md +++ b/docs/adapters/http.md @@ -18,11 +18,11 @@ The `http` adapter sends a webhook request to an external agent service. The age ## Configuration -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `url` | string | Yes | Webhook URL to POST to | -| `headers` | object | No | Additional HTTP headers | -| `timeoutSec` | number | No | Request timeout | +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ----------------------- | +| `url` | string | Yes | Webhook URL to POST to | +| `headers` | object | No | Additional HTTP headers | +| `timeoutSec` | number | No | Request timeout | ## How It Works diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 0e7c71d..84a98eb 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -16,25 +16,25 @@ When a heartbeat fires, Taskcore: ## Built-in Adapters -| Adapter | Type Key | Description | -|---------|----------|-------------| -| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | -| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | -| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | -| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | -| Cursor | `cursor` | Runs Cursor in background mode | -| Pi Local | `pi_local` | Runs an embedded Pi agent locally | -| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) | -| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | -| [Process](/adapters/process) | `process` | Executes arbitrary shell commands | -| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | +| Adapter | Type Key | Description | +| -------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------- | +| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | +| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | +| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | +| Cursor | `cursor` | Runs Cursor in background mode | +| Pi Local | `pi_local` | Runs an embedded Pi agent locally | +| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) | +| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | +| [Process](/adapters/process) | `process` | Executes arbitrary shell commands | +| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | ### External (plugin) adapters These adapters ship as standalone npm packages and are installed via the plugin system: -| Adapter | Package | Type Key | Description | -|---------|---------|----------|-------------| +| Adapter | Package | Type Key | Description | +| ----------- | -------------------------------- | ------------- | -------------------------- | | Droid Local | `@henkey/droid-taskcore-adapter` | `droid_local` | Runs Factory Droid locally | ## External Adapters @@ -70,11 +70,11 @@ my-adapter/ format-event.ts # Terminal output for `taskcore run --watch` ``` -| Registry | What it does | Source | -|----------|-------------|--------| -| **Server** | Executes agents, captures results | `createServerAdapter()` from package root | -| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) | -| **CLI** | Formats terminal output for live watching | Static import | +| Registry | What it does | Source | +| ---------- | ---------------------------------------------- | ---------------------------------------------------- | +| **Server** | Executes agents, captures results | `createServerAdapter()` from package root | +| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) | +| **CLI** | Formats terminal output for live watching | Static import | ## Choosing an Adapter diff --git a/docs/adapters/process.md b/docs/adapters/process.md index 465c53e..495b044 100644 --- a/docs/adapters/process.md +++ b/docs/adapters/process.md @@ -18,12 +18,12 @@ The `process` adapter executes arbitrary shell commands. Use it for simple scrip ## Configuration -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `command` | string | Yes | Shell command to execute | -| `cwd` | string | No | Working directory | -| `env` | object | No | Environment variables | -| `timeoutSec` | number | No | Process timeout | +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------ | +| `command` | string | Yes | Shell command to execute | +| `cwd` | string | No | Working directory | +| `env` | object | No | Environment variables | +| `timeoutSec` | number | No | Process timeout | ## How It Works diff --git a/docs/api/activity.md b/docs/api/activity.md index 6e43d6d..dbba5a7 100644 --- a/docs/api/activity.md +++ b/docs/api/activity.md @@ -13,24 +13,24 @@ GET /api/companies/{companyId}/activity Query parameters: -| Param | Description | -|-------|-------------| -| `agentId` | Filter by actor agent | +| Param | Description | +| ------------ | ---------------------------------------------------- | +| `agentId` | Filter by actor agent | | `entityType` | Filter by entity type (`issue`, `agent`, `approval`) | -| `entityId` | Filter by specific entity | +| `entityId` | Filter by specific entity | ## Activity Record Each entry includes: -| Field | Description | -|-------|-------------| -| `actor` | Agent or user who performed the action | -| `action` | What was done (created, updated, commented, etc.) | -| `entityType` | What type of entity was affected | -| `entityId` | ID of the affected entity | -| `details` | Specifics of the change | -| `createdAt` | When the action occurred | +| Field | Description | +| ------------ | ------------------------------------------------- | +| `actor` | Agent or user who performed the action | +| `action` | What was done (created, updated, commented, etc.) | +| `entityType` | What type of entity was affected | +| `entityId` | ID of the affected entity | +| `details` | Specifics of the change | +| `createdAt` | When the action occurred | ## What Gets Logged diff --git a/docs/api/approvals.md b/docs/api/approvals.md index e4139ec..128a47b 100644 --- a/docs/api/approvals.md +++ b/docs/api/approvals.md @@ -13,8 +13,8 @@ GET /api/companies/{companyId}/approvals Query parameters: -| Param | Description | -|-------|-------------| +| Param | Description | +| -------- | --------------------------------- | | `status` | Filter by status (e.g. `pending`) | ## Get Approval diff --git a/docs/api/companies.md b/docs/api/companies.md index 4883772..ab5f14c 100644 --- a/docs/api/companies.md +++ b/docs/api/companies.md @@ -75,14 +75,14 @@ Archives a company. Archived companies are hidden from default listings. ## Company Fields -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Unique identifier | -| `name` | string | Company name | -| `description` | string | Company description | -| `status` | string | `active`, `paused`, `archived` | -| `logoAssetId` | string | Optional asset id for the stored logo image | -| `logoUrl` | string | Optional Taskcore asset content path for the stored logo image | -| `budgetMonthlyCents` | number | Monthly budget limit | -| `createdAt` | string | ISO timestamp | -| `updatedAt` | string | ISO timestamp | +| Field | Type | Description | +| -------------------- | ------ | -------------------------------------------------------------- | +| `id` | string | Unique identifier | +| `name` | string | Company name | +| `description` | string | Company description | +| `status` | string | `active`, `paused`, `archived` | +| `logoAssetId` | string | Optional asset id for the stored logo image | +| `logoUrl` | string | Optional Taskcore asset content path for the stored logo image | +| `budgetMonthlyCents` | number | Monthly budget limit | +| `createdAt` | string | ISO timestamp | +| `updatedAt` | string | ISO timestamp | diff --git a/docs/api/costs.md b/docs/api/costs.md index 144eb3c..5d7d6b5 100644 --- a/docs/api/costs.md +++ b/docs/api/costs.md @@ -63,9 +63,9 @@ PATCH /api/agents/{agentId} ## Budget Enforcement -| Threshold | Effect | -|-----------|--------| -| 80% | Soft alert — agent should focus on critical tasks | -| 100% | Hard stop — agent is auto-paused | +| Threshold | Effect | +| --------- | ------------------------------------------------- | +| 80% | Soft alert — agent should focus on critical tasks | +| 100% | Hard stop — agent is auto-paused | Budget windows reset on the first of each month (UTC). diff --git a/docs/api/issues.md b/docs/api/issues.md index 21fc00c..0ec9b83 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -13,11 +13,11 @@ GET /api/companies/{companyId}/issues Query parameters: -| Param | Description | -|-------|-------------| -| `status` | Filter by status (comma-separated: `todo,in_progress`) | -| `assigneeAgentId` | Filter by assigned agent | -| `projectId` | Filter by project | +| Param | Description | +| ----------------- | ------------------------------------------------------ | +| `status` | Filter by status (comma-separated: `todo,in_progress`) | +| `assigneeAgentId` | Filter by assigned agent | +| `projectId` | Filter by project | Results sorted by priority. diff --git a/docs/api/overview.md b/docs/api/overview.md index e6f9229..f90a7e3 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -43,15 +43,15 @@ All responses return JSON. Successful responses return the entity directly. Erro ## Error Codes -| Code | Meaning | What to Do | -|------|---------|------------| -| `400` | Validation error | Check request body against expected fields | -| `401` | Unauthenticated | API key missing or invalid | -| `403` | Unauthorized | You don't have permission for this action | -| `404` | Not found | Entity doesn't exist or isn't in your company | -| `409` | Conflict | Another agent owns the task. Pick a different one. **Do not retry.** | -| `422` | Semantic violation | Invalid state transition (e.g. backlog -> done) | -| `500` | Server error | Transient failure. Comment on the task and move on. | +| Code | Meaning | What to Do | +| ----- | ------------------ | -------------------------------------------------------------------- | +| `400` | Validation error | Check request body against expected fields | +| `401` | Unauthenticated | API key missing or invalid | +| `403` | Unauthorized | You don't have permission for this action | +| `404` | Not found | Entity doesn't exist or isn't in your company | +| `409` | Conflict | Another agent owns the task. Pick a different one. **Do not retry.** | +| `422` | Semantic violation | Invalid state transition (e.g. backlog -> done) | +| `500` | Server error | Transient failure. Comment on the task and move on. | ## Pagination diff --git a/docs/api/routines.md b/docs/api/routines.md index 15c72af..aa71c32 100644 --- a/docs/api/routines.md +++ b/docs/api/routines.md @@ -42,32 +42,32 @@ POST /api/companies/{companyId}/routines Fields: -| Field | Required | Description | -|-------|----------|-------------| -| `title` | yes | Routine name | -| `description` | no | Human-readable description of the routine | -| `assigneeAgentId` | yes | Agent who receives each run | -| `projectId` | yes | Project this routine belongs to | -| `goalId` | no | Goal to link runs to | -| `parentIssueId` | no | Parent issue for created run issues | -| `priority` | no | `critical`, `high`, `medium` (default), `low` | -| `status` | no | `active` (default), `paused`, `archived` | -| `concurrencyPolicy` | no | Behaviour when a run fires while a previous one is still active | -| `catchUpPolicy` | no | Behaviour for missed scheduled runs | +| Field | Required | Description | +| ------------------- | -------- | --------------------------------------------------------------- | +| `title` | yes | Routine name | +| `description` | no | Human-readable description of the routine | +| `assigneeAgentId` | yes | Agent who receives each run | +| `projectId` | yes | Project this routine belongs to | +| `goalId` | no | Goal to link runs to | +| `parentIssueId` | no | Parent issue for created run issues | +| `priority` | no | `critical`, `high`, `medium` (default), `low` | +| `status` | no | `active` (default), `paused`, `archived` | +| `concurrencyPolicy` | no | Behaviour when a run fires while a previous one is still active | +| `catchUpPolicy` | no | Behaviour for missed scheduled runs | **Concurrency policies:** -| Value | Behaviour | -|-------|-----------| +| Value | Behaviour | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `coalesce_if_active` (default) | Incoming run is immediately finalised as `coalesced` and linked to the active run — no new issue is created | -| `skip_if_active` | Incoming run is immediately finalised as `skipped` and linked to the active run — no new issue is created | -| `always_enqueue` | Always create a new run regardless of active runs | +| `skip_if_active` | Incoming run is immediately finalised as `skipped` and linked to the active run — no new issue is created | +| `always_enqueue` | Always create a new run regardless of active runs | **Catch-up policies:** -| Value | Behaviour | -|-------|-----------| -| `skip_missed` (default) | Missed scheduled runs are dropped | +| Value | Behaviour | +| ------------------------- | ---------------------------------------------- | +| `skip_missed` (default) | Missed scheduled runs are dropped | | `enqueue_missed_with_cap` | Missed runs are enqueued up to an internal cap | ## Update Routine @@ -181,15 +181,15 @@ Returns recent run history for the routine. Defaults to 50 most recent runs. Agents can read all routines in their company but can only create and manage routines assigned to themselves: -| Operation | Agent | Board | -|-----------|-------|-------| -| List / Get | ✅ any routine | ✅ | -| Create | ✅ own only | ✅ | -| Update / activate | ✅ own only | ✅ | -| Add / update / delete triggers | ✅ own only | ✅ | -| Rotate trigger secret | ✅ own only | ✅ | -| Manual run | ✅ own only | ✅ | -| Reassign to another agent | ❌ | ✅ | +| Operation | Agent | Board | +| ------------------------------ | -------------- | ----- | +| List / Get | ✅ any routine | ✅ | +| Create | ✅ own only | ✅ | +| Update / activate | ✅ own only | ✅ | +| Add / update / delete triggers | ✅ own only | ✅ | +| Rotate trigger secret | ✅ own only | ✅ | +| Manual run | ✅ own only | ✅ | +| Reassign to another agent | ❌ | ✅ | ## Routine Lifecycle diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 4f1b58f..38bfa65 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -15,14 +15,14 @@ pnpm taskcore --help All commands support: -| Flag | Description | -|------|-------------| +| Flag | Description | +| ------------------- | ------------------------------------------------------ | | `--data-dir ` | Local Taskcore data root (isolates from `~/.taskcore`) | -| `--api-base ` | API base URL | -| `--api-key ` | API authentication token | -| `--context ` | Context file path | -| `--profile ` | Context profile name | -| `--json` | Output as JSON | +| `--api-base ` | API base URL | +| `--api-key ` | API authentication token | +| `--context ` | Context file path | +| `--profile ` | Context profile name | +| `--json` | Output as JSON | Company-scoped commands also accept `--company-id `. diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 242b34c..0205438 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -101,12 +101,12 @@ pnpm taskcore allowed-hostname my-tailscale-host ## Local Storage Paths -| Data | Default Path | -|------|-------------| -| Config | `~/.taskcore/instances/default/config.json` | -| Database | `~/.taskcore/instances/default/db` | -| Logs | `~/.taskcore/instances/default/logs` | -| Storage | `~/.taskcore/instances/default/data/storage` | +| Data | Default Path | +| ----------- | -------------------------------------------------- | +| Config | `~/.taskcore/instances/default/config.json` | +| Database | `~/.taskcore/instances/default/db` | +| Logs | `~/.taskcore/instances/default/logs` | +| Storage | `~/.taskcore/instances/default/data/storage` | | Secrets key | `~/.taskcore/instances/default/secrets/master.key` | Override with: diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index f1a245d..3544fb4 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -561,7 +561,7 @@ For Taskcore, this should be treated as a hard cutover in product direction rath ## 21. Minimal Example -```text +````text lean-dev-shop/ ├── COMPANY.md ├── agents/ @@ -584,7 +584,8 @@ Optional: ```text .taskcore.yaml -``` +```` + ``` **Recommendation** @@ -594,3 +595,4 @@ This is the direction I would take: - define `SKILL.md` compatibility as non-negotiable - treat this spec as an extension of Agent Skills, not a parallel format - make `companies.sh` a discovery layer for repos implementing this spec, not a publishing authority +``` diff --git a/docs/deploy/database.md b/docs/deploy/database.md index 21b3d16..412e77e 100644 --- a/docs/deploy/database.md +++ b/docs/deploy/database.md @@ -68,10 +68,10 @@ export function createDb(url: string) { ## Switching Between Modes -| `DATABASE_URL` | Mode | -|----------------|------| -| Not set | Embedded PostgreSQL | -| `postgres://...localhost...` | Local Docker PostgreSQL | -| `postgres://...supabase.com...` | Hosted Supabase | +| `DATABASE_URL` | Mode | +| ------------------------------- | ----------------------- | +| Not set | Embedded PostgreSQL | +| `postgres://...localhost...` | Local Docker PostgreSQL | +| `postgres://...supabase.com...` | Hosted Supabase | The Drizzle schema (`packages/db/src/schema/`) is the same regardless of mode. diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 64593d5..1f5b700 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -7,47 +7,47 @@ All environment variables that Taskcore uses for server configuration. ## Server Configuration -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `3100` | Server port | -| `TASKCORE_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` | -| `TASKCORE_BIND_HOST` | (unset) | Required when `TASKCORE_BIND=custom` | -| `HOST` | `127.0.0.1` | Legacy host override; prefer `TASKCORE_BIND` for new setups | -| `DATABASE_URL` | (embedded) | PostgreSQL connection string | -| `TASKCORE_HOME` | `~/.taskcore` | Base directory for all Taskcore data | -| `TASKCORE_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | -| `TASKCORE_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | -| `TASKCORE_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | +| Variable | Default | Description | +| ------------------------------ | --------------- | -------------------------------------------------------------- | +| `PORT` | `3100` | Server port | +| `TASKCORE_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` | +| `TASKCORE_BIND_HOST` | (unset) | Required when `TASKCORE_BIND=custom` | +| `HOST` | `127.0.0.1` | Legacy host override; prefer `TASKCORE_BIND` for new setups | +| `DATABASE_URL` | (embedded) | PostgreSQL connection string | +| `TASKCORE_HOME` | `~/.taskcore` | Base directory for all Taskcore data | +| `TASKCORE_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | +| `TASKCORE_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | +| `TASKCORE_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | ## Secrets -| Variable | Default | Description | -|----------|---------|-------------| -| `TASKCORE_SECRETS_MASTER_KEY` | (from file) | 32-byte encryption key (base64/hex/raw) | -| `TASKCORE_SECRETS_MASTER_KEY_FILE` | `~/.taskcore/.../secrets/master.key` | Path to key file | -| `TASKCORE_SECRETS_STRICT_MODE` | `false` | Require secret refs for sensitive env vars | +| Variable | Default | Description | +| ---------------------------------- | ------------------------------------ | ------------------------------------------ | +| `TASKCORE_SECRETS_MASTER_KEY` | (from file) | 32-byte encryption key (base64/hex/raw) | +| `TASKCORE_SECRETS_MASTER_KEY_FILE` | `~/.taskcore/.../secrets/master.key` | Path to key file | +| `TASKCORE_SECRETS_STRICT_MODE` | `false` | Require secret refs for sensitive env vars | ## Agent Runtime (Injected into agent processes) These are set automatically by the server when invoking agents: -| Variable | Description | -|----------|-------------| -| `TASKCORE_AGENT_ID` | Agent's unique ID | -| `TASKCORE_COMPANY_ID` | Company ID | -| `TASKCORE_API_URL` | Taskcore API base URL | -| `TASKCORE_API_KEY` | Short-lived JWT for API auth | -| `TASKCORE_RUN_ID` | Current heartbeat run ID | -| `TASKCORE_TASK_ID` | Issue that triggered this wake | -| `TASKCORE_WAKE_REASON` | Wake trigger reason | -| `TASKCORE_WAKE_COMMENT_ID` | Comment that triggered this wake | -| `TASKCORE_APPROVAL_ID` | Resolved approval ID | -| `TASKCORE_APPROVAL_STATUS` | Approval decision | +| Variable | Description | +| --------------------------- | -------------------------------- | +| `TASKCORE_AGENT_ID` | Agent's unique ID | +| `TASKCORE_COMPANY_ID` | Company ID | +| `TASKCORE_API_URL` | Taskcore API base URL | +| `TASKCORE_API_KEY` | Short-lived JWT for API auth | +| `TASKCORE_RUN_ID` | Current heartbeat run ID | +| `TASKCORE_TASK_ID` | Issue that triggered this wake | +| `TASKCORE_WAKE_REASON` | Wake trigger reason | +| `TASKCORE_WAKE_COMMENT_ID` | Comment that triggered this wake | +| `TASKCORE_APPROVAL_ID` | Resolved approval ID | +| `TASKCORE_APPROVAL_STATUS` | Approval decision | | `TASKCORE_LINKED_ISSUE_IDS` | Comma-separated linked issue IDs | ## LLM Provider Keys (for adapters) -| Variable | Description | -|----------|-------------| +| Variable | Description | +| ------------------- | -------------------------------------------- | | `ANTHROPIC_API_KEY` | Anthropic API key (for Claude Local adapter) | -| `OPENAI_API_KEY` | OpenAI API key (for Codex Local adapter) | +| `OPENAI_API_KEY` | OpenAI API key (for Codex Local adapter) | diff --git a/docs/deploy/local-development.md b/docs/deploy/local-development.md index b0482ca..8b400a3 100644 --- a/docs/deploy/local-development.md +++ b/docs/deploy/local-development.md @@ -90,13 +90,13 @@ pnpm dev ## Data Locations -| Data | Path | -|------|------| -| Config | `~/.taskcore/instances/default/config.json` | -| Database | `~/.taskcore/instances/default/db` | -| Storage | `~/.taskcore/instances/default/data/storage` | +| Data | Path | +| ----------- | -------------------------------------------------- | +| Config | `~/.taskcore/instances/default/config.json` | +| Database | `~/.taskcore/instances/default/db` | +| Storage | `~/.taskcore/instances/default/data/storage` | | Secrets key | `~/.taskcore/instances/default/secrets/master.key` | -| Logs | `~/.taskcore/instances/default/logs` | +| Logs | `~/.taskcore/instances/default/logs` | Override with environment variables: diff --git a/docs/deploy/overview.md b/docs/deploy/overview.md index 261d93c..ceba1c3 100644 --- a/docs/deploy/overview.md +++ b/docs/deploy/overview.md @@ -7,11 +7,11 @@ Taskcore supports three deployment configurations, from zero-friction local to i ## Deployment Modes -| Mode | Auth | Best For | -|------|------|----------| -| `local_trusted` | No login required | Single-operator local machine | -| `authenticated` + `private` | Login required | Private network (Tailscale, VPN, LAN) | -| `authenticated` + `public` | Login required | Internet-facing cloud deployment | +| Mode | Auth | Best For | +| --------------------------- | ----------------- | ------------------------------------- | +| `local_trusted` | No login required | Single-operator local machine | +| `authenticated` + `private` | Login required | Private network (Tailscale, VPN, LAN) | +| `authenticated` + `public` | Login required | Internet-facing cloud deployment | ## Quick Comparison diff --git a/docs/deploy/secrets.md b/docs/deploy/secrets.md index a045d5e..785fc29 100644 --- a/docs/deploy/secrets.md +++ b/docs/deploy/secrets.md @@ -39,11 +39,11 @@ pnpm taskcore doctor ### Environment Overrides -| Variable | Description | -|----------|-------------| -| `TASKCORE_SECRETS_MASTER_KEY` | 32-byte key as base64, hex, or raw string | -| `TASKCORE_SECRETS_MASTER_KEY_FILE` | Custom key file path | -| `TASKCORE_SECRETS_STRICT_MODE` | Set to `true` to enforce secret refs | +| Variable | Description | +| ---------------------------------- | ----------------------------------------- | +| `TASKCORE_SECRETS_MASTER_KEY` | 32-byte key as base64, hex, or raw string | +| `TASKCORE_SECRETS_MASTER_KEY_FILE` | Custom key file path | +| `TASKCORE_SECRETS_STRICT_MODE` | Set to `true` to enforce secret refs | ## Strict Mode diff --git a/docs/deploy/storage.md b/docs/deploy/storage.md index 3e020be..03c7503 100644 --- a/docs/deploy/storage.md +++ b/docs/deploy/storage.md @@ -27,10 +27,10 @@ pnpm taskcore configure --section storage ## Configuration -| Provider | Best For | -|----------|----------| +| Provider | Best For | +| ------------ | --------------------------------------------- | | `local_disk` | Local development, single-machine deployments | -| `s3` | Production, multi-node, cloud deployments | +| `s3` | Production, multi-node, cloud deployments | Storage configuration is stored in the instance config file: diff --git a/docs/deploy/tailscale-private-access.md b/docs/deploy/tailscale-private-access.md index 7bd958d..ce98b1c 100644 --- a/docs/deploy/tailscale-private-access.md +++ b/docs/deploy/tailscale-private-access.md @@ -27,7 +27,8 @@ Legacy aliases still map to `authenticated/private + bind=lan`: pnpm dev --authenticated-private pnpm dev --tailscale-auth -``` + +```` ## 2. Find your reachable Tailscale address @@ -35,7 +36,7 @@ From the machine running Taskcore: ```sh tailscale ip -4 -``` +```` You can also use your Tailscale MagicDNS hostname (for example `my-macbook.tailnet.ts.net`). @@ -72,7 +73,7 @@ curl http://:3100/api/health Expected result: ```json -{"status":"ok"} +{ "status": "ok" } ``` ## Troubleshooting diff --git a/docs/docs.json b/docs/docs.json index 35c354f..3eef778 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,4 +144,4 @@ "footerSocials": { "github": "https://github.com/taskcore-ai/taskcore" } -} \ No newline at end of file +} diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index 9f9a35c..125004e 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -12,9 +12,9 @@ When you rate an agent's response with **Helpful** (thumbs up) or **Needs work** Each vote creates two local records: -| Record | What it contains | -|--------|-----------------| -| **Vote** | Your vote (up/down), optional reason text, sharing preference, consent version, timestamp | +| Record | What it contains | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vote** | Your vote (up/down), optional reason text, sharing preference, consent version, timestamp | | **Trace bundle** | Full context snapshot: the voted-on comment/revision text, issue title, agent info, your vote, and reason — everything needed to understand the feedback in isolation | All data lives in your local Taskcore database. Nothing leaves your machine unless you explicitly choose to share. @@ -47,26 +47,31 @@ pnpm taskcore feedback report --payloads All endpoints require board-user access (automatic in local dev). **List votes for an issue:** + ```bash curl http://127.0.0.1:3102/api/issues//feedback-votes ``` **List trace bundles for an issue (with full payloads):** + ```bash curl 'http://127.0.0.1:3102/api/issues//feedback-traces?includePayload=true' ``` **List all traces company-wide:** + ```bash curl 'http://127.0.0.1:3102/api/companies//feedback-traces?includePayload=true' ``` **Get a single trace envelope record:** + ```bash curl http://127.0.0.1:3102/api/feedback-traces/ ``` **Get the full export bundle for a trace:** + ```bash curl http://127.0.0.1:3102/api/feedback-traces//bundle ``` @@ -75,14 +80,14 @@ curl http://127.0.0.1:3102/api/feedback-traces//bundle The trace endpoints accept query parameters: -| Parameter | Values | Description | -|-----------|--------|-------------| -| `vote` | `up`, `down` | Filter by vote direction | -| `status` | `local_only`, `pending`, `sent`, `failed` | Filter by export status | -| `targetType` | `issue_comment`, `issue_document_revision` | Filter by what was voted on | -| `sharedOnly` | `true` | Only show votes the user chose to share | -| `includePayload` | `true` | Include the full context snapshot | -| `from` / `to` | ISO date | Date range filter | +| Parameter | Values | Description | +| ---------------- | ------------------------------------------ | --------------------------------------- | +| `vote` | `up`, `down` | Filter by vote direction | +| `status` | `local_only`, `pending`, `sent`, `failed` | Filter by export status | +| `targetType` | `issue_comment`, `issue_document_revision` | Filter by what was voted on | +| `sharedOnly` | `true` | Only show votes the user chose to share | +| `includePayload` | `true` | Include the full context snapshot | +| `from` / `to` | ISO date | Date range filter | ## Exporting your data @@ -167,12 +172,12 @@ Your preference is saved per-company. You can change it any time via the feedbac ## Data lifecycle -| Status | Meaning | -|--------|---------| -| `local_only` | Vote stored locally, not marked for sharing | -| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt | -| `sent` | Successfully transmitted | -| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available | +| Status | Meaning | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `local_only` | Vote stored locally, not marked for sharing | +| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt | +| `sent` | Successfully transmitted | +| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available | Your local database always retains the full vote and trace data regardless of sharing status. diff --git a/docs/guides/agent-developer/handling-approvals.md b/docs/guides/agent-developer/handling-approvals.md index b91ed1c..241b3dd 100644 --- a/docs/guides/agent-developer/handling-approvals.md +++ b/docs/guides/agent-developer/handling-approvals.md @@ -53,6 +53,7 @@ GET /api/approvals/{approvalId}/issues ``` For each linked issue: + - Close it if the approval fully resolves the requested work - Comment on it explaining what happens next if it remains open diff --git a/docs/guides/agent-developer/how-agents-work.md b/docs/guides/agent-developer/how-agents-work.md index 521ff5c..4d9c500 100644 --- a/docs/guides/agent-developer/how-agents-work.md +++ b/docs/guides/agent-developer/how-agents-work.md @@ -18,23 +18,23 @@ Agents in Taskcore are AI employees that wake up, do work, and go back to sleep. Every agent has environment variables injected at runtime: -| Variable | Description | -|----------|-------------| -| `TASKCORE_AGENT_ID` | The agent's unique ID | -| `TASKCORE_COMPANY_ID` | The company the agent belongs to | -| `TASKCORE_API_URL` | Base URL for the Taskcore API | -| `TASKCORE_API_KEY` | Short-lived JWT for API authentication | -| `TASKCORE_RUN_ID` | Current heartbeat run ID | +| Variable | Description | +| --------------------- | -------------------------------------- | +| `TASKCORE_AGENT_ID` | The agent's unique ID | +| `TASKCORE_COMPANY_ID` | The company the agent belongs to | +| `TASKCORE_API_URL` | Base URL for the Taskcore API | +| `TASKCORE_API_KEY` | Short-lived JWT for API authentication | +| `TASKCORE_RUN_ID` | Current heartbeat run ID | Additional context variables are set when the wake has a specific trigger: -| Variable | Description | -|----------|-------------| -| `TASKCORE_TASK_ID` | Issue that triggered this wake | -| `TASKCORE_WAKE_REASON` | Why the agent was woken (e.g. `issue_assigned`, `issue_comment_mentioned`) | -| `TASKCORE_WAKE_COMMENT_ID` | Specific comment that triggered this wake | -| `TASKCORE_APPROVAL_ID` | Approval that was resolved | -| `TASKCORE_APPROVAL_STATUS` | Approval decision (`approved`, `rejected`) | +| Variable | Description | +| -------------------------- | -------------------------------------------------------------------------- | +| `TASKCORE_TASK_ID` | Issue that triggered this wake | +| `TASKCORE_WAKE_REASON` | Why the agent was woken (e.g. `issue_assigned`, `issue_comment_mentioned`) | +| `TASKCORE_WAKE_COMMENT_ID` | Specific comment that triggered this wake | +| `TASKCORE_APPROVAL_ID` | Approval that was resolved | +| `TASKCORE_APPROVAL_STATUS` | Approval decision (`approved`, `rejected`) | ## Session Persistence @@ -42,11 +42,11 @@ Agents maintain conversation context across heartbeats through session persisten ## Agent Status -| Status | Meaning | -|--------|---------| -| `active` | Ready to receive heartbeats | -| `idle` | Active but no heartbeat currently running | -| `running` | Heartbeat in progress | -| `error` | Last heartbeat failed | -| `paused` | Manually paused or budget-exceeded | -| `terminated` | Permanently deactivated | +| Status | Meaning | +| ------------ | ----------------------------------------- | +| `active` | Ready to receive heartbeats | +| `idle` | Active but no heartbeat currently running | +| `running` | Heartbeat in progress | +| `error` | Last heartbeat failed | +| `paused` | Manually paused or budget-exceeded | +| `terminated` | Permanently deactivated | diff --git a/docs/guides/agent-developer/task-workflow.md b/docs/guides/agent-developer/task-workflow.md index 8de6d28..582ae0c 100644 --- a/docs/guides/agent-developer/task-workflow.md +++ b/docs/guides/agent-developer/task-workflow.md @@ -17,6 +17,7 @@ POST /api/issues/{issueId}/checkout This is an atomic operation. If two agents race to checkout the same task, exactly one succeeds and the other gets `409 Conflict`. **Rules:** + - Always checkout before working - Never retry a 409 — pick a different task - If you already own the task, checkout succeeds idempotently diff --git a/docs/guides/board-operator/costs-and-budgets.md b/docs/guides/board-operator/costs-and-budgets.md index 5940085..9ea4f91 100644 --- a/docs/guides/board-operator/costs-and-budgets.md +++ b/docs/guides/board-operator/costs-and-budgets.md @@ -41,10 +41,10 @@ PATCH /api/agents/{agentId} Taskcore enforces budgets automatically: -| Threshold | Action | -|-----------|--------| -| 80% | Soft alert — agent is warned to focus on critical tasks only | -| 100% | Hard stop — agent is auto-paused, no more heartbeats | +| Threshold | Action | +| --------- | ------------------------------------------------------------ | +| 80% | Soft alert — agent is warned to focus on critical tasks only | +| 100% | Hard stop — agent is auto-paused, no more heartbeats | An auto-paused agent can be resumed by increasing its budget or waiting for the next calendar month. diff --git a/docs/guides/board-operator/delegation.md b/docs/guides/board-operator/delegation.md index 3068bfe..e4af2f3 100644 --- a/docs/guides/board-operator/delegation.md +++ b/docs/guides/board-operator/delegation.md @@ -99,13 +99,13 @@ This pattern lets you start small and scale the team based on actual work, not u If you've set a goal but nothing is happening, check these common causes: -| Check | What to look for | -|-------|-----------------| -| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. | -| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. | -| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. | -| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. | -| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. | +| Check | What to look for | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. | +| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. | +| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. | +| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. | +| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. | | **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. | ### "Do I have to tell the CEO to engage engineering and marketing?" diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 217a6fc..290bfd9 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -39,15 +39,15 @@ taskcore company export --out ./my-export ### Options -| Option | Description | Default | -|--------|-------------|---------| -| `--out ` | Output directory (required) | — | -| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` | -| `--skills ` | Export only specific skill slugs | all | -| `--projects ` | Export only specific project shortnames or IDs | all | -| `--issues ` | Export specific issue identifiers or IDs | none | -| `--project-issues ` | Export issues belonging to specific projects | none | -| `--expand-referenced-skills` | Vendor skill file contents instead of keeping upstream references | `false` | +| Option | Description | Default | +| ---------------------------- | --------------------------------------------------------------------------------- | ---------------- | +| `--out ` | Output directory (required) | — | +| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` | +| `--skills ` | Export only specific skill slugs | all | +| `--projects ` | Export only specific project shortnames or IDs | all | +| `--issues ` | Export specific issue identifiers or IDs | none | +| `--project-issues ` | Export issues belonging to specific projects | none | +| `--expand-referenced-skills` | Vendor skill file contents instead of keeping upstream references | `false` | ### Examples @@ -94,18 +94,18 @@ taskcore company import org/repo/companies/acme ### Options -| Option | Description | Default | -|--------|-------------|---------| -| `--target ` | `new` (create a new company) or `existing` (merge into existing) | inferred from context | -| `--company-id ` | Target company ID for `--target existing` | current context | -| `--new-company-name ` | Override company name for `--target new` | from package | -| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected | -| `--agents ` | Comma-separated agent slugs to import, or `all` | `all` | -| `--collision ` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` | -| `--ref ` | Git ref for GitHub imports (branch, tag, or commit) | default branch | -| `--dry-run` | Preview what would be imported without applying | `false` | -| `--yes` | Skip the interactive confirmation prompt | `false` | -| `--json` | Output result as JSON | `false` | +| Option | Description | Default | +| --------------------------- | --------------------------------------------------------------------------------- | --------------------- | +| `--target ` | `new` (create a new company) or `existing` (merge into existing) | inferred from context | +| `--company-id ` | Target company ID for `--target existing` | current context | +| `--new-company-name ` | Override company name for `--target new` | from package | +| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected | +| `--agents ` | Comma-separated agent slugs to import, or `all` | `all` | +| `--collision ` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` | +| `--ref ` | Git ref for GitHub imports (branch, tag, or commit) | default branch | +| `--dry-run` | Preview what would be imported without applying | `false` | +| `--yes` | Skip the interactive confirmation prompt | `false` | +| `--json` | Output result as JSON | `false` | ### Target Modes @@ -135,6 +135,7 @@ taskcore company import org/repo --target existing --company-id abc123 --dry-run ``` The preview shows: + - **Package contents** — How many agents, projects, tasks, and skills are in the source - **Import plan** — What will be created, renamed, skipped, or replaced - **Env inputs** — Environment variables that may need values after import @@ -181,13 +182,13 @@ taskcore company import ./package \ The CLI commands use these API endpoints under the hood: -| Action | Endpoint | -|--------|----------| -| Export company | `POST /api/companies/{companyId}/export` | +| Action | Endpoint | +| --------------------------------- | ------------------------------------------------- | +| Export company | `POST /api/companies/{companyId}/export` | | Preview import (existing company) | `POST /api/companies/{companyId}/imports/preview` | -| Apply import (existing company) | `POST /api/companies/{companyId}/imports/apply` | -| Preview import (new company) | `POST /api/companies/import/preview` | -| Apply import (new company) | `POST /api/companies/import` | +| Apply import (existing company) | `POST /api/companies/{companyId}/imports/apply` | +| Preview import (new company) | `POST /api/companies/import/preview` | +| Apply import (new company) | `POST /api/companies/import` | CEO agents can also use the safe import routes (`/imports/preview` and `/imports/apply`) which enforce non-destructive rules: `replace` is rejected, collisions resolve with `rename` or `skip`, and issues are always created as new. diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 8b1f8d3..1248445 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -7,14 +7,14 @@ Agents are the employees of your autonomous company. As the board operator, you ## Agent States -| Status | Meaning | -|--------|---------| -| `active` | Ready to receive work | -| `idle` | Active but no current heartbeat running | -| `running` | Currently executing a heartbeat | -| `error` | Last heartbeat failed | -| `paused` | Manually paused or budget-paused | -| `terminated` | Permanently deactivated (irreversible) | +| Status | Meaning | +| ------------ | --------------------------------------- | +| `active` | Ready to receive work | +| `idle` | Active but no current heartbeat running | +| `running` | Currently executing a heartbeat | +| `error` | Last heartbeat failed | +| `paused` | Manually paused or budget-paused | +| `terminated` | Permanently deactivated (irreversible) | ## Creating Agents @@ -28,6 +28,7 @@ Create agents from the Agents page. Each agent requires: - **Capabilities** — short description of what this agent does Common adapter choices: + - `claude_local` / `codex_local` / `opencode_local` for local coding agents - `openclaw_gateway` / `http` for webhook-based external agents - `process` for generic local command execution diff --git a/docs/guides/execution-policy.md b/docs/guides/execution-policy.md index 0006b18..5dd0e89 100644 --- a/docs/guides/execution-policy.md +++ b/docs/guides/execution-policy.md @@ -6,11 +6,11 @@ Taskcore's execution policy system ensures tasks are completed with the right le An execution policy is an optional structured object on any issue that defines what must happen after the executor finishes their work. It supports three layers of enforcement: -| Layer | Purpose | Scope | -|---|---|---| -| **Comment required** | Every agent run must post a comment back to the issue | Runtime invariant (always on) | -| **Review stage** | A reviewer checks quality/correctness and can request changes | Per-issue, optional | -| **Approval stage** | A manager/stakeholder gives final sign-off | Per-issue, optional | +| Layer | Purpose | Scope | +| -------------------- | ------------------------------------------------------------- | ----------------------------- | +| **Comment required** | Every agent run must post a comment back to the issue | Runtime invariant (always on) | +| **Review stage** | A reviewer checks quality/correctness and can request changes | Per-issue, optional | +| **Approval stage** | A manager/stakeholder gives final sign-off | Per-issue, optional | These layers compose. An issue can have review only, approval only, both in sequence, or neither (just the comment-required backstop). @@ -21,22 +21,22 @@ These layers compose. An issue can have review only, approval only, both in sequ ```ts interface IssueExecutionPolicy { mode: "normal" | "auto"; - commentRequired: boolean; // always true, enforced by runtime - stages: IssueExecutionStage[]; // ordered list of review/approval stages + commentRequired: boolean; // always true, enforced by runtime + stages: IssueExecutionStage[]; // ordered list of review/approval stages } interface IssueExecutionStage { - id: string; // auto-generated UUID - type: "review" | "approval"; // stage kind - approvalsNeeded: 1; // multi-approval is not supported yet + id: string; // auto-generated UUID + type: "review" | "approval"; // stage kind + approvalsNeeded: 1; // multi-approval is not supported yet participants: IssueExecutionStageParticipant[]; } interface IssueExecutionStageParticipant { id: string; type: "agent" | "user"; - agentId?: string | null; // set when type is "agent" - userId?: string | null; // set when type is "user" + agentId?: string | null; // set when type is "agent" + userId?: string | null; // set when type is "user" } ``` @@ -74,7 +74,7 @@ interface IssueExecutionDecision { actorAgentId: string | null; actorUserId: string | null; outcome: "approved" | "changes_requested"; - body: string; // required comment explaining the decision + body: string; // required comment explaining the decision createdByRunId: string | null; createdAt: Date; } @@ -127,23 +127,33 @@ interface IssueExecutionDecision { ### Policy Variants **Review only** (no approval stage): + ```json { "stages": [ - { "type": "review", "participants": [{ "type": "agent", "agentId": "qa-agent-id" }] } + { + "type": "review", + "participants": [{ "type": "agent", "agentId": "qa-agent-id" }] + } ] } ``` + Executor finishes → reviewer approves → done. **Approval only** (no review stage): + ```json { "stages": [ - { "type": "approval", "participants": [{ "type": "user", "userId": "manager-user-id" }] } + { + "type": "approval", + "participants": [{ "type": "user", "userId": "manager-user-id" }] + } ] } ``` + Executor finishes → approver signs off → done. **Multiple reviewers/approvers:** @@ -162,11 +172,11 @@ This prevents silent completions where an agent finishes work but leaves no trac ### Run-level tracking fields -| Field | Description | -|---|---| -| `issueCommentStatus` | `satisfied`, `retry_queued`, or `retry_exhausted` | +| Field | Description | +| ---------------------------------- | --------------------------------------------------- | +| `issueCommentStatus` | `satisfied`, `retry_queued`, or `retry_exhausted` | | `issueCommentSatisfiedByCommentId` | Links to the comment that fulfilled the requirement | -| `issueCommentRetryQueuedAt` | Timestamp when the retry wake was scheduled | +| `issueCommentRetryQueuedAt` | Timestamp when the retry wake was scheduled | ## Access Control @@ -250,6 +260,7 @@ The runtime reassigns to the original executor automatically. ### New Issue Dialog When creating a new issue, **Reviewer** and **Approver** buttons appear alongside the assignee selector. Clicking either opens a participant picker with: + - "No reviewer" / "No approver" (to clear) - "Me" (current user) - Full list of agents and board users diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 509b14a..cc27381 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -86,6 +86,7 @@ pnpm taskcore allowed-hostname host.docker.internal ``` Then restart Taskcore and rerun the smoke script. + - Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain). - Authenticated/private mode: ensure hostnames are in the allowed list when required: @@ -325,6 +326,7 @@ The Node.js gateway needs time to initialize. Wait 15 seconds before hitting `ht ### CLAUDE_AI_SESSION_KEY warnings (Compose only) These Docker Compose warnings are harmless and can be ignored: + ``` level=warning msg="The \"CLAUDE_AI_SESSION_KEY\" variable is not set. Defaulting to a blank string." ``` @@ -334,6 +336,7 @@ level=warning msg="The \"CLAUDE_AI_SESSION_KEY\" variable is not set. Defaulting Config file: `~/.openclaw/openclaw.json` (JSON5 format) Key settings: + - `gateway.auth.token` — the auth token for the web UI and API - `agents.defaults.model.primary` — the AI model (use `openai/gpt-5.2` or newer) - `env.OPENAI_API_KEY` — references the `OPENAI_API_KEY` env var (Compose approach) diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index c608577..5daf35c 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -20,45 +20,45 @@ Follows the existing `NewIssueDialog` / `NewProjectDialog` pattern: a `Dialog` c **Identity (always visible):** -| Field | Control | Required | Default | Notes | -|-------|---------|----------|---------|-------| -| Name | Text input (large, auto-focused) | Yes | -- | e.g. "Alice", "Build Bot" | -| Title | Text input (subtitle style) | No | -- | e.g. "VP of Engineering" | -| Role | Chip popover (select) | No | `general` | Values from `AGENT_ROLES`: ceo, cto, cmo, cfo, engineer, designer, pm, qa, devops, researcher, general | -| Reports To | Chip popover (agent select) | No | -- | Dropdown of existing agents in the company. If this is the first agent, auto-set role to `ceo` and gray out Reports To. Otherwise required unless role is `ceo`. | -| Capabilities | Text input | No | -- | Free-text description of what this agent can do | +| Field | Control | Required | Default | Notes | +| ------------ | -------------------------------- | -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Text input (large, auto-focused) | Yes | -- | e.g. "Alice", "Build Bot" | +| Title | Text input (subtitle style) | No | -- | e.g. "VP of Engineering" | +| Role | Chip popover (select) | No | `general` | Values from `AGENT_ROLES`: ceo, cto, cmo, cfo, engineer, designer, pm, qa, devops, researcher, general | +| Reports To | Chip popover (agent select) | No | -- | Dropdown of existing agents in the company. If this is the first agent, auto-set role to `ceo` and gray out Reports To. Otherwise required unless role is `ceo`. | +| Capabilities | Text input | No | -- | Free-text description of what this agent can do | **Adapter (collapsible section, default open):** -| Field | Control | Default | Notes | -|-------|---------|---------|-------| -| Adapter Type | Chip popover (select) | `claude_local` | `claude_local`, `codex_local`, `process`, `http` | -| Test environment | Button | -- | Runs adapter-specific diagnostics and returns pass/warn/fail checks for current unsaved config | -| CWD | Text input | -- | Working directory for local adapters | -| Prompt Template | Textarea | -- | Supports `{{ agent.id }}`, `{{ agent.name }}` etc. | -| Model | Text input | -- | Optional model override | +| Field | Control | Default | Notes | +| ---------------- | --------------------- | -------------- | ---------------------------------------------------------------------------------------------- | +| Adapter Type | Chip popover (select) | `claude_local` | `claude_local`, `codex_local`, `process`, `http` | +| Test environment | Button | -- | Runs adapter-specific diagnostics and returns pass/warn/fail checks for current unsaved config | +| CWD | Text input | -- | Working directory for local adapters | +| Prompt Template | Textarea | -- | Supports `{{ agent.id }}`, `{{ agent.name }}` etc. | +| Model | Text input | -- | Optional model override | **Adapter-specific fields (shown/hidden based on adapter type):** -*claude_local:* +_claude_local:_ | Field | Control | Default | |-------|---------|---------| | Max Turns Per Run | Number input | 80 | | Skip Permissions | Toggle | true | -*codex_local:* +_codex_local:_ | Field | Control | Default | |-------|---------|---------| | Search | Toggle | false | | Bypass Sandbox | Toggle | true | -*process:* +_process:_ | Field | Control | Default | |-------|---------|---------| | Command | Text input | -- | | Args | Text input (comma-separated) | -- | -*http:* +_http:_ | Field | Control | Default | |-------|---------|---------| | URL | Text input | -- | @@ -67,25 +67,25 @@ Follows the existing `NewIssueDialog` / `NewProjectDialog` pattern: a `Dialog` c **Runtime (collapsible section, default collapsed):** -| Field | Control | Default | -|-------|---------|---------| -| Context Mode | Chip popover | `thin` | -| Monthly Budget (cents) | Number input | 0 | -| Timeout (sec) | Number input | 900 | -| Grace Period (sec) | Number input | 15 | -| Extra Args | Text input | -- | -| Env Vars | Key-value pair editor | -- | +| Field | Control | Default | +| ---------------------- | --------------------- | ------- | +| Context Mode | Chip popover | `thin` | +| Monthly Budget (cents) | Number input | 0 | +| Timeout (sec) | Number input | 900 | +| Grace Period (sec) | Number input | 15 | +| Extra Args | Text input | -- | +| Env Vars | Key-value pair editor | -- | **Heartbeat Policy (collapsible section, default collapsed):** -| Field | Control | Default | -|-------|---------|---------| -| Enabled | Toggle | true | -| Interval (sec) | Number input | 300 | -| Wake on Assignment | Toggle | true | -| Wake on On-Demand | Toggle | true | -| Wake on Automation | Toggle | true | -| Cooldown (sec) | Number input | 10 | +| Field | Control | Default | +| ------------------ | ------------ | ------- | +| Enabled | Toggle | true | +| Interval (sec) | Number input | 300 | +| Wake on Assignment | Toggle | true | +| Wake on On-Demand | Toggle | true | +| Wake on Automation | Toggle | true | +| Cooldown (sec) | Number input | 10 | ### Behavior @@ -116,6 +116,7 @@ The `[...]` overflow menu contains: Terminate, Reset Session, Create API Key. Two-column layout: left column is a summary card, right column is the org position. **Summary card:** + - Adapter type + model (if set) - Heartbeat interval (e.g. "every 5 min") or "Disabled" - Last heartbeat time (relative, e.g. "3 min ago") @@ -123,6 +124,7 @@ Two-column layout: left column is a summary card, right column is the org positi - Current month spend / budget with progress bar **Org position card:** + - Reports to: clickable agent name (links to their detail page) - Direct reports: list of agents who report to this agent (clickable) @@ -131,6 +133,7 @@ Two-column layout: left column is a summary card, right column is the org positi Editable form with the same sections as the creation dialog (Adapter, Runtime, Heartbeat Policy) but pre-populated with current values. Uses inline editing -- click a value to edit, press Enter or blur to save via `agentsApi.update()`. Sections: + - **Identity**: name, title, role, reports to, capabilities - **Adapter Config**: all adapter-specific fields for the current adapter type - **Heartbeat Policy**: enable/disable, interval, wake-on triggers, cooldown @@ -143,12 +146,14 @@ Each section is a collapsible card. Save happens per-field (PATCH on blur/enter) This is the primary activity/history view. Shows a paginated list of heartbeat runs, most recent first. **Run list item:** + ``` [StatusIcon] #run-id-short source: timer 2 min ago 1.2k tokens $0.03 "Reviewed 3 PRs and filed 2 issues" ``` Fields per row: + - Status icon (green check = succeeded, red X = failed, yellow spinner = running, gray clock = queued, orange timeout = timed_out, slash = cancelled) - Run ID (short, first 8 chars) - Invocation source chip (timer, assignment, on_demand, automation) @@ -167,6 +172,7 @@ Fields per row: - Exit code and signal (if applicable) **Log viewer** within the run detail: + - Streams `heartbeat_run_events` for the run, ordered by `seq` - Each event rendered as a log line with timestamp, level (color-coded), and message - Events of type `stdout`/`stderr` shown in monospace @@ -191,6 +197,7 @@ Expand the existing costs tab: ### Properties Panel (Right Sidebar) The existing `AgentProperties` panel continues to show the quick-glance info. Add: + - Session ID (truncated, with copy button) - Last error (if any, in red) - Link to "View Configuration" (scrolls to / switches to Configuration tab) @@ -210,6 +217,7 @@ Shows a flat list of agents with status badge, name, role, title, and budget bar **Add view toggle**: List view (current) and Org Chart view. **Org Chart view:** + - Tree layout showing reporting hierarchy - Each node shows: agent name, role, status badge - CEO at the top, direct reports below, etc. @@ -217,11 +225,13 @@ Shows a flat list of agents with status badge, name, role, title, and budget bar - Clicking a node navigates to agent detail **List view improvements:** + - Add adapter type as a small chip/tag on each row - Add "last active" relative timestamp - Add running indicator (animated dot) if agent currently has a running heartbeat **Filtering:** + - Tab filters: All, Active, Paused, Error (similar to Issues page pattern) --- @@ -230,20 +240,21 @@ Shows a flat list of agents with status badge, name, role, title, and budget bar New components needed: -| Component | Purpose | -|-----------|---------| -| `NewAgentDialog` | Agent creation form dialog | -| `AgentConfigForm` | Shared form sections for create + edit (adapter, heartbeat, runtime) | -| `AdapterConfigFields` | Conditional fields based on adapter type | -| `HeartbeatPolicyFields` | Heartbeat configuration fields | -| `EnvVarEditor` | Key-value pair editor for environment variables | -| `RunListItem` | Single run row in the runs list | -| `RunDetail` | Expanded run detail with log viewer | -| `LogViewer` | Streaming log viewer with auto-scroll | -| `OrgChart` | Tree visualization of agent hierarchy | -| `AgentSelect` | Reusable agent picker (for Reports To, etc.) | +| Component | Purpose | +| ----------------------- | -------------------------------------------------------------------- | +| `NewAgentDialog` | Agent creation form dialog | +| `AgentConfigForm` | Shared form sections for create + edit (adapter, heartbeat, runtime) | +| `AdapterConfigFields` | Conditional fields based on adapter type | +| `HeartbeatPolicyFields` | Heartbeat configuration fields | +| `EnvVarEditor` | Key-value pair editor for environment variables | +| `RunListItem` | Single run row in the runs list | +| `RunDetail` | Expanded run detail with log viewer | +| `LogViewer` | Streaming log viewer with auto-scroll | +| `OrgChart` | Tree visualization of agent hierarchy | +| `AgentSelect` | Reusable agent picker (for Reports To, etc.) | Reused existing components: + - `StatusBadge`, `EntityRow`, `EmptyState`, `PropertyRow` - shadcn: `Dialog`, `Tabs`, `Button`, `Popover`, `Command`, `Separator`, `Toggle` @@ -253,21 +264,21 @@ Reused existing components: All endpoints already exist. No new server work needed for V1. -| Action | Endpoint | Used by | -|--------|----------|---------| -| List agents | `GET /companies/:id/agents` | List page | -| Get org tree | `GET /companies/:id/org` | Org chart view | -| Create agent | `POST /companies/:id/agents` | Creation dialog | -| Update agent | `PATCH /agents/:id` | Configuration tab | -| Pause/Resume/Terminate | `POST /agents/:id/{action}` | Header actions | -| Reset session | `POST /agents/:id/runtime-state/reset-session` | Overflow menu | -| Create API key | `POST /agents/:id/keys` | Overflow menu | -| Get runtime state | `GET /agents/:id/runtime-state` | Overview tab, properties panel | -| Invoke/Wakeup | `POST /agents/:id/heartbeat/invoke` | Header invoke button | -| List runs | `GET /companies/:id/heartbeat-runs?agentId=X` | Runs tab | -| Cancel run | `POST /heartbeat-runs/:id/cancel` | Run detail | -| Run events | `GET /heartbeat-runs/:id/events` | Log viewer | -| Run log | `GET /heartbeat-runs/:id/log` | Full log view | +| Action | Endpoint | Used by | +| ---------------------- | ---------------------------------------------- | ------------------------------ | +| List agents | `GET /companies/:id/agents` | List page | +| Get org tree | `GET /companies/:id/org` | Org chart view | +| Create agent | `POST /companies/:id/agents` | Creation dialog | +| Update agent | `PATCH /agents/:id` | Configuration tab | +| Pause/Resume/Terminate | `POST /agents/:id/{action}` | Header actions | +| Reset session | `POST /agents/:id/runtime-state/reset-session` | Overflow menu | +| Create API key | `POST /agents/:id/keys` | Overflow menu | +| Get runtime state | `GET /agents/:id/runtime-state` | Overview tab, properties panel | +| Invoke/Wakeup | `POST /agents/:id/heartbeat/invoke` | Header invoke button | +| List runs | `GET /companies/:id/heartbeat-runs?agentId=X` | Runs tab | +| Cancel run | `POST /heartbeat-runs/:id/cancel` | Run detail | +| Run events | `GET /heartbeat-runs/:id/events` | Log viewer | +| Run log | `GET /heartbeat-runs/:id/log` | Full log view | --- diff --git a/docs/specs/cliphub-plan.md b/docs/specs/cliphub-plan.md index 9a15190..5ee0ef2 100644 --- a/docs/specs/cliphub-plan.md +++ b/docs/specs/cliphub-plan.md @@ -8,13 +8,13 @@ **ClipHub** sells **entire team configurations** — org charts, agent roles, inter-agent workflows, governance rules, and project templates — for Taskcore-managed companies. -| Dimension | ClipHub | -|---|---| -| Unit of sale | Team blueprint (multi-agent org) | -| Buyer | Founder / team lead spinning up an AI company | -| Install target | Taskcore company (agents, projects, governance) | -| Value prop | "Skip org design — get a shipping team in minutes" | -| Price range | $0–$499 per blueprint (+ individual add-ons) | +| Dimension | ClipHub | +| -------------- | -------------------------------------------------- | +| Unit of sale | Team blueprint (multi-agent org) | +| Buyer | Founder / team lead spinning up an AI company | +| Install target | Taskcore company (agents, projects, governance) | +| Value prop | "Skip org design — get a shipping team in minutes" | +| Price range | $0–$499 per blueprint (+ individual add-ons) | --- @@ -31,6 +31,7 @@ A complete Taskcore company configuration: - **Skills & instructions**: AGENTS.md / skill files bundled per agent **Examples:** + - "SaaS Startup Team" — CEO, CTO, Engineer, CMO, Designer ($199) - "Content Agency" — Editor-in-Chief, 3 Writers, SEO Analyst, Social Manager ($149) - "Dev Shop" — CTO, 2 Engineers, QA, DevOps ($99) @@ -46,6 +47,7 @@ Single-agent configurations designed to plug into a Taskcore org: - Governance defaults (budget, permissions) **Examples:** + - "Staff Engineer" — ships production code, manages PRs ($29) - "Growth Marketer" — content pipeline, SEO, social ($39) - "DevOps Agent" — CI/CD, deployment, monitoring ($29) @@ -59,6 +61,7 @@ Portable skill files that any Taskcore agent can use: - Compatible with Taskcore's skill loading system **Examples:** + - "Git PR Workflow" — standardized PR creation and review (Free) - "Deployment Pipeline" — Cloudflare/Vercel deploy skill ($9) - "Customer Support Triage" — ticket classification and routing ($19) @@ -73,6 +76,7 @@ Pre-built approval flows and policies: - Billing code structures **Examples:** + - "Startup Governance" — lightweight, CEO approves > $50 (Free) - "Enterprise Governance" — multi-tier approval, audit trail ($49) @@ -85,15 +89,15 @@ Pre-built approval flows and policies: ```typescript interface Listing { id: string; - slug: string; // URL-friendly identifier - type: 'team_blueprint' | 'agent_blueprint' | 'skill' | 'governance_template'; + slug: string; // URL-friendly identifier + type: "team_blueprint" | "agent_blueprint" | "skill" | "governance_template"; title: string; - tagline: string; // Short pitch (≤120 chars) - description: string; // Markdown, full details + tagline: string; // Short pitch (≤120 chars) + description: string; // Markdown, full details // Pricing - price: number; // Cents (0 = free) - currency: 'usd'; + price: number; // Cents (0 = free) + currency: "usd"; // Creator creatorId: string; @@ -101,30 +105,30 @@ interface Listing { creatorAvatar: string | null; // Categorization - categories: string[]; // e.g. ['saas', 'engineering', 'marketing'] - tags: string[]; // e.g. ['claude', 'startup', '5-agent'] - agentCount: number | null; // For team blueprints + categories: string[]; // e.g. ['saas', 'engineering', 'marketing'] + tags: string[]; // e.g. ['claude', 'startup', '5-agent'] + agentCount: number | null; // For team blueprints // Content - previewImages: string[]; // Screenshots / org chart visuals - readmeMarkdown: string; // Full README shown on detail page - includedFiles: string[]; // List of files in the bundle + previewImages: string[]; // Screenshots / org chart visuals + readmeMarkdown: string; // Full README shown on detail page + includedFiles: string[]; // List of files in the bundle // Compatibility - compatibleAdapters: string[]; // ['claude_local', 'codex_local', ...] - requiredModels: string[]; // ['claude-opus-4-6', 'claude-sonnet-4-6'] - taskcoreVersionMin: string; // Minimum Taskcore version + compatibleAdapters: string[]; // ['claude_local', 'codex_local', ...] + requiredModels: string[]; // ['claude-opus-4-6', 'claude-sonnet-4-6'] + taskcoreVersionMin: string; // Minimum Taskcore version // Social proof installCount: number; - rating: number | null; // 1.0–5.0 + rating: number | null; // 1.0–5.0 reviewCount: number; // Metadata - version: string; // Semver + version: string; // Semver publishedAt: string; updatedAt: string; - status: 'draft' | 'published' | 'archived'; + status: "draft" | "published" | "archived"; } ``` @@ -142,7 +146,7 @@ interface TeamBlueprint { governance: { approvalRules: ApprovalRule[]; budgetDefaults: { role: string; monthlyCents: number }[]; - escalationChain: string[]; // Agent slugs in escalation order + escalationChain: string[]; // Agent slugs in escalation order }; // Projects @@ -157,7 +161,7 @@ interface TeamBlueprint { } interface AgentBlueprint { - slug: string; // e.g. 'cto', 'engineer-1' + slug: string; // e.g. 'cto', 'engineer-1' name: string; role: string; title: string; @@ -166,7 +170,7 @@ interface AgentBlueprint { promptTemplate: string; adapterType: string; adapterConfig: Record; - instructionsPath: string | null; // Path to AGENTS.md or similar + instructionsPath: string | null; // Path to AGENTS.md or similar skills: SkillBundle[]; budgetMonthlyCents: number; permissions: { @@ -185,7 +189,7 @@ interface ProjectTemplate { } interface ApprovalRule { - trigger: string; // e.g. 'hire_agent', 'budget_exceed' + trigger: string; // e.g. 'hire_agent', 'budget_exceed' threshold: number | null; approverRole: string; } @@ -196,17 +200,17 @@ interface ApprovalRule { ```typescript interface Creator { id: string; - userId: string; // Auth provider ID + userId: string; // Auth provider ID displayName: string; bio: string; avatarUrl: string | null; website: string | null; - listings: string[]; // Listing IDs + listings: string[]; // Listing IDs totalInstalls: number; - totalRevenue: number; // Cents earned + totalRevenue: number; // Cents earned joinedAt: string; verified: boolean; - payoutMethod: 'stripe_connect'; + payoutMethod: "stripe_connect"; stripeAccountId: string | null; } ``` @@ -218,11 +222,11 @@ interface Purchase { id: string; listingId: string; buyerUserId: string; - buyerCompanyId: string | null; // Target Taskcore company + buyerCompanyId: string | null; // Target Taskcore company pricePaidCents: number; - paymentIntentId: string | null; // Stripe - installedAt: string | null; // When deployed to company - status: 'pending' | 'completed' | 'refunded'; + paymentIntentId: string | null; // Stripe + installedAt: string | null; // When deployed to company + status: "pending" | "completed" | "refunded"; createdAt: string; } ``` @@ -235,9 +239,9 @@ interface Review { listingId: string; authorUserId: string; authorDisplayName: string; - rating: number; // 1–5 + rating: number; // 1–5 title: string; - body: string; // Markdown + body: string; // Markdown verifiedPurchase: boolean; createdAt: string; updatedAt: string; @@ -250,50 +254,50 @@ interface Review { ### 4.1 Public Pages -| Route | Page | Description | -|---|---|---| -| `/` | Homepage | Hero, featured blueprints, popular skills, how it works | -| `/browse` | Marketplace browse | Filterable grid of all listings | -| `/browse?type=team_blueprint` | Team blueprints | Filtered to team configs | -| `/browse?type=agent_blueprint` | Agent blueprints | Single-agent configs | -| `/browse?type=skill` | Skills | Skill listings | -| `/browse?type=governance_template` | Governance | Policy templates | -| `/listings/:slug` | Listing detail | Full product page | -| `/creators/:slug` | Creator profile | Bio, all listings, stats | -| `/about` | About ClipHub | Mission, how it works | -| `/pricing` | Pricing & fees | Creator revenue share, buyer info | +| Route | Page | Description | +| ---------------------------------- | ------------------ | ------------------------------------------------------- | +| `/` | Homepage | Hero, featured blueprints, popular skills, how it works | +| `/browse` | Marketplace browse | Filterable grid of all listings | +| `/browse?type=team_blueprint` | Team blueprints | Filtered to team configs | +| `/browse?type=agent_blueprint` | Agent blueprints | Single-agent configs | +| `/browse?type=skill` | Skills | Skill listings | +| `/browse?type=governance_template` | Governance | Policy templates | +| `/listings/:slug` | Listing detail | Full product page | +| `/creators/:slug` | Creator profile | Bio, all listings, stats | +| `/about` | About ClipHub | Mission, how it works | +| `/pricing` | Pricing & fees | Creator revenue share, buyer info | ### 4.2 Authenticated Pages -| Route | Page | Description | -|---|---|---| -| `/dashboard` | Buyer dashboard | Purchased items, installed blueprints | -| `/dashboard/purchases` | Purchase history | All transactions | -| `/dashboard/installs` | Installations | Deployed blueprints with status | -| `/creator` | Creator dashboard | Listing management, analytics | -| `/creator/listings/new` | Create listing | Multi-step listing wizard | -| `/creator/listings/:id/edit` | Edit listing | Modify existing listing | -| `/creator/analytics` | Analytics | Revenue, installs, views | -| `/creator/payouts` | Payouts | Stripe Connect payout history | +| Route | Page | Description | +| ---------------------------- | ----------------- | ------------------------------------- | +| `/dashboard` | Buyer dashboard | Purchased items, installed blueprints | +| `/dashboard/purchases` | Purchase history | All transactions | +| `/dashboard/installs` | Installations | Deployed blueprints with status | +| `/creator` | Creator dashboard | Listing management, analytics | +| `/creator/listings/new` | Create listing | Multi-step listing wizard | +| `/creator/listings/:id/edit` | Edit listing | Modify existing listing | +| `/creator/analytics` | Analytics | Revenue, installs, views | +| `/creator/payouts` | Payouts | Stripe Connect payout history | ### 4.3 API Routes -| Method | Endpoint | Description | -|---|---|---| -| `GET` | `/api/listings` | Browse listings (filters: type, category, price range, sort) | -| `GET` | `/api/listings/:slug` | Get listing detail | -| `POST` | `/api/listings` | Create listing (creator auth) | -| `PATCH` | `/api/listings/:id` | Update listing | -| `DELETE` | `/api/listings/:id` | Archive listing | -| `POST` | `/api/listings/:id/purchase` | Purchase listing (Stripe checkout) | -| `POST` | `/api/listings/:id/install` | Install to Taskcore company | -| `GET` | `/api/listings/:id/reviews` | Get reviews | -| `POST` | `/api/listings/:id/reviews` | Submit review | -| `GET` | `/api/creators/:slug` | Creator profile | -| `GET` | `/api/creators/me` | Current creator profile | -| `POST` | `/api/creators` | Register as creator | -| `GET` | `/api/purchases` | Buyer's purchase history | -| `GET` | `/api/analytics` | Creator analytics | +| Method | Endpoint | Description | +| -------- | ---------------------------- | ------------------------------------------------------------ | +| `GET` | `/api/listings` | Browse listings (filters: type, category, price range, sort) | +| `GET` | `/api/listings/:slug` | Get listing detail | +| `POST` | `/api/listings` | Create listing (creator auth) | +| `PATCH` | `/api/listings/:id` | Update listing | +| `DELETE` | `/api/listings/:id` | Archive listing | +| `POST` | `/api/listings/:id/purchase` | Purchase listing (Stripe checkout) | +| `POST` | `/api/listings/:id/install` | Install to Taskcore company | +| `GET` | `/api/listings/:id/reviews` | Get reviews | +| `POST` | `/api/listings/:id/reviews` | Submit review | +| `GET` | `/api/creators/:slug` | Creator profile | +| `GET` | `/api/creators/me` | Current creator profile | +| `POST` | `/api/creators` | Register as creator | +| `GET` | `/api/purchases` | Buyer's purchase history | +| `GET` | `/api/analytics` | Creator analytics | --- @@ -358,13 +362,13 @@ Running Taskcore company → "Export as Blueprint" (CLI or UI) ### 6.2 Key Design Elements -| Element | ClipHub | -|---|---| -| Product card | Org chart mini-preview + agent count badge | -| Detail page | Interactive org chart + per-agent breakdown | -| Install flow | One-click deploy to Taskcore company | -| Social proof | "X companies running this blueprint" | -| Preview | Live demo sandbox (stretch goal) | +| Element | ClipHub | +| ------------ | ------------------------------------------- | +| Product card | Org chart mini-preview + agent count badge | +| Detail page | Interactive org chart + per-agent breakdown | +| Install flow | One-click deploy to Taskcore company | +| Social proof | "X companies running this blueprint" | +| Preview | Live demo sandbox (stretch goal) | ### 6.3 Listing Card Design @@ -442,12 +446,12 @@ The install handler: ## 8. Revenue Model -| Fee | Amount | Notes | -|---|---|---| +| Fee | Amount | Notes | +| --------------------- | ----------------- | --------------------------------------- | | Creator revenue share | 90% of sale price | Minus Stripe processing (~2.9% + $0.30) | -| Platform fee | 10% of sale price | ClipHub's cut | -| Free listings | $0 | No fees for free listings | -| Stripe Connect | Standard rates | Handled by Stripe | +| Platform fee | 10% of sale price | ClipHub's cut | +| Free listings | $0 | No fees for free listings | +| Stripe Connect | Standard rates | Handled by Stripe | --- @@ -465,6 +469,7 @@ The install handler: ### 9.2 Integration with Taskcore ClipHub can be: + - **Option A**: A separate app that calls Taskcore's API to install blueprints - **Option B**: A built-in section of the Taskcore UI (`/marketplace` route) @@ -503,6 +508,7 @@ blueprint/ ## 10. MVP Scope ### Phase 1: Foundation + - [ ] Listing schema and CRUD API - [ ] Browse page with filters (type, category, price) - [ ] Listing detail page with org chart visualization @@ -511,6 +517,7 @@ blueprint/ - [ ] Install flow: blueprint → Taskcore company ### Phase 2: Payments & Social + - [ ] Stripe Connect integration - [ ] Purchase flow - [ ] Review system @@ -518,6 +525,7 @@ blueprint/ - [ ] "Export from Taskcore" CLI command ### Phase 3: Growth + - [ ] Search with relevance ranking - [ ] Featured/trending listings - [ ] Creator verification program diff --git a/docs/start/architecture.md b/docs/start/architecture.md index ae87bbe..e69de29 100644 --- a/docs/start/architecture.md +++ b/docs/start/architecture.md @@ -1,98 +0,0 @@ ---- -title: Architecture -summary: Stack overview, request flow, and adapter model ---- - -Taskcore is a monorepo with four main layers. - -## Stack Overview - -``` -┌─────────────────────────────────────┐ -│ React UI (Vite) │ -│ Dashboard, org management, tasks │ -├─────────────────────────────────────┤ -│ Express.js REST API (Node.js) │ -│ Routes, services, auth, adapters │ -├─────────────────────────────────────┤ -│ PostgreSQL (Drizzle ORM) │ -│ Schema, migrations, embedded mode │ -├─────────────────────────────────────┤ -│ Adapters │ -│ Claude Local, Codex Local, │ -│ Process, HTTP │ -└─────────────────────────────────────┘ -``` - -## Technology Stack - -| Layer | Technology | -|-------|-----------| -| Frontend | React 19, Vite 6, React Router 7, Radix UI, Tailwind CSS 4, TanStack Query | -| Backend | Node.js 20+, Express.js 5, TypeScript | -| Database | PostgreSQL 17 (or embedded PGlite), Drizzle ORM | -| Auth | Better Auth (sessions + API keys) | -| Adapters | Claude Code CLI, Codex CLI, shell process, HTTP webhook | -| Package manager | pnpm 9 with workspaces | - -## Repository Structure - -``` -taskcore/ -├── ui/ # React frontend -│ ├── src/pages/ # Route pages -│ ├── src/components/ # React components -│ ├── src/api/ # API client -│ └── src/context/ # React context providers -│ -├── server/ # Express.js API -│ ├── src/routes/ # REST endpoints -│ ├── src/services/ # Business logic -│ ├── src/adapters/ # Agent execution adapters -│ └── src/middleware/ # Auth, logging -│ -├── packages/ -│ ├── db/ # Drizzle schema + migrations -│ ├── shared/ # API types, constants, validators -│ ├── adapter-utils/ # Adapter interfaces and helpers -│ └── adapters/ -│ ├── claude-local/ # Claude Code adapter -│ └── codex-local/ # OpenAI Codex adapter -│ -├── skills/ # Agent skills -│ └── taskcore/ # Core Taskcore skill (heartbeat protocol) -│ -├── cli/ # CLI client -│ └── src/ # Setup and control-plane commands -│ -└── doc/ # Internal documentation -``` - -## Request Flow - -When a heartbeat fires: - -1. **Trigger** — Scheduler, manual invoke, or event (assignment, mention) triggers a heartbeat -2. **Adapter invocation** — Server calls the configured adapter's `execute()` function -3. **Agent process** — Adapter spawns the agent (e.g. Claude Code CLI) with Taskcore env vars and a prompt -4. **Agent work** — The agent calls Taskcore's REST API to check assignments, checkout tasks, do work, and update status -5. **Result capture** — Adapter captures stdout, parses usage/cost data, extracts session state -6. **Run record** — Server records the run result, costs, and any session state for next heartbeat - -## Adapter Model - -Adapters are the bridge between Taskcore and agent runtimes. Each adapter is a package with three modules: - -- **Server module** — `execute()` function that spawns/calls the agent, plus environment diagnostics -- **UI module** — stdout parser for the run viewer, config form fields for agent creation -- **CLI module** — terminal formatter for `taskcore run --watch` - -Built-in adapters: `claude_local`, `codex_local`, `process`, `http`. You can create custom adapters for any runtime. - -## Key Design Decisions - -- **Control plane, not execution plane** — Taskcore orchestrates agents; it doesn't run them -- **Company-scoped** — all entities belong to exactly one company; strict data boundaries -- **Single-assignee tasks** — atomic checkout prevents concurrent work on the same task -- **Adapter-agnostic** — any runtime that can call an HTTP API works as an agent -- **Embedded by default** — zero-config local mode with embedded PostgreSQL diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..e53dbc0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,38 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import globals from 'globals'; + +export default tseslint.config( + { ignores: ['**/dist/**', '**/node_modules/**', '**/.pnpm-store/**', '**/coverage/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.mjs', '**/*.cjs'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.es2021, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['off'], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/triple-slash-reference': 'off', + 'no-console': 'off', + 'no-useless-catch': 'off', + 'no-useless-escape': 'off', + 'prefer-const': 'warn', + 'no-empty': 'warn', + 'no-dupe-else-if': 'warn', + 'no-constant-binary-expression': 'warn', + 'no-control-regex': 'off', + 'no-regex-spaces': 'warn', + 'react-hooks/exhaustive-deps': 'off', + }, + } +); diff --git a/package.json b/package.json index a8c8d40..8075870 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,20 @@ "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", - "metrics:taskcore-commits": "tsx scripts/taskcore-commit-metrics.ts" + "metrics:taskcore-commits": "tsx scripts/taskcore-commit-metrics.ts", + "format": "npx prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml,css}\"", + "format:check": "npx prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml,css}\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@playwright/test": "^1.58.2", "cross-env": "^10.1.0", "esbuild": "^0.27.3", + "eslint": "^9.18.0", "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", "vitest": "^3.0.5" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8810c7b..7b2ce5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 '@playwright/test': specifier: ^1.58.2 version: 1.59.1 @@ -25,9 +28,15 @@ importers: esbuild: specifier: ^0.27.3 version: 0.27.7 + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) typescript: specifier: ^5.7.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0) @@ -1767,6 +1776,44 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1803,6 +1850,22 @@ packages: peerDependencies: hono: ^4 + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -3509,6 +3572,9 @@ packages: '@types/jsdom@28.0.1': resolution: {integrity: sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3578,6 +3644,65 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3649,12 +3774,19 @@ packages: ajv: optional: true + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -3692,6 +3824,13 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3780,6 +3919,13 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3815,6 +3961,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001788: resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} @@ -3825,6 +3975,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3884,6 +4038,13 @@ packages: codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -3912,6 +4073,9 @@ packages: compute-scroll-into-view@2.0.4: resolution: {integrity: sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -4172,6 +4336,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -4424,14 +4591,60 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + esniff@2.0.1: resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} engines: {node: '>=0.10'} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -4444,6 +4657,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -4485,6 +4702,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -4510,10 +4733,25 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -4570,6 +4808,14 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4580,6 +4826,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -4639,6 +4889,22 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4682,6 +4948,14 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -4752,12 +5026,21 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4767,6 +5050,9 @@ packages: resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} @@ -4788,6 +5074,10 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lexical@0.35.0: resolution: {integrity: sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==} @@ -4866,9 +5156,16 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5124,6 +5421,13 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -5155,6 +5459,9 @@ packages: resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -5188,12 +5495,28 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -5210,6 +5533,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-expression-matcher@1.5.0: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} @@ -5347,6 +5674,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -5520,6 +5851,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -5681,6 +6016,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -5711,6 +6050,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5785,6 +6128,12 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -5797,6 +6146,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -5811,6 +6164,13 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.58.2: + resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5869,6 +6229,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6070,6 +6433,10 @@ packages: engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6107,6 +6474,10 @@ packages: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -7436,6 +7807,52 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@2.2.0)': optionalDependencies: '@noble/hashes': 2.2.0 @@ -7469,6 +7886,17 @@ snapshots: dependencies: hono: 4.12.14 + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.0': @@ -9452,6 +9880,8 @@ snapshots: parse5: 7.3.0 undici-types: 7.25.0 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -9528,6 +9958,97 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.2': {} + + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -9616,6 +10137,13 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -9625,6 +10153,10 @@ snapshots: anser@2.3.5: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + append-field@1.0.0: {} argparse@2.0.1: {} @@ -9655,6 +10187,10 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.19: {} @@ -9710,6 +10246,15 @@ snapshots: bowser@2.14.1: {} + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.19 @@ -9747,6 +10292,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001788: {} ccount@2.0.1: {} @@ -9759,6 +10306,11 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -9825,6 +10377,12 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -9843,6 +10401,8 @@ snapshots: compute-scroll-into-view@2.0.4: {} + concat-map@0.0.1: {} + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -10120,6 +10680,8 @@ snapshots: deep-eql@5.0.2: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -10357,8 +10919,62 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + esniff@2.0.1: dependencies: d: 1.0.2 @@ -10366,6 +10982,22 @@ snapshots: event-emitter: 0.3.5 type: 2.7.3 + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-util-visit@2.0.0: @@ -10379,6 +11011,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + etag@1.8.1: {} event-emitter@0.3.5: @@ -10442,6 +11076,10 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} @@ -10464,6 +11102,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -10475,6 +11117,18 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -10531,12 +11185,20 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} hachure-fill@0.5.2: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -10620,6 +11282,17 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + inherits@2.0.4: {} inline-style-parser@0.2.7: {} @@ -10649,6 +11322,12 @@ snapshots: is-docker@3.0.0: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} is-in-ssh@1.0.0: {} @@ -10716,16 +11395,26 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} katex@0.16.45: dependencies: commander: 8.3.0 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + khroma@2.1.0: {} kleur@4.1.5: {} @@ -10745,6 +11434,11 @@ snapshots: layout-base@2.0.1: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lexical@0.35.0: {} lib0@0.2.117: @@ -10800,8 +11494,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -11362,6 +12062,14 @@ snapshots: mime@2.6.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + minimist@1.2.8: {} mlly@1.8.2: @@ -11388,6 +12096,8 @@ snapshots: nanostores@1.2.0: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} next-tick@1.1.0: {} @@ -11417,10 +12127,31 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + outvariant@1.4.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + package-manager-detector@1.6.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -11443,6 +12174,8 @@ snapshots: path-data-parser@0.1.0: {} + path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} path-key@3.1.1: {} @@ -11589,6 +12322,8 @@ snapshots: powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} + prismjs@1.30.0: {} process-warning@5.0.0: {} @@ -11831,6 +12566,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.12: @@ -12059,6 +12796,8 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} strip-literal@3.1.0: @@ -12101,6 +12840,10 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} @@ -12154,6 +12897,10 @@ snapshots: trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-dedent@2.2.0: {} tslib@2.8.1: {} @@ -12165,6 +12912,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -12180,6 +12931,17 @@ snapshots: typedarray@0.0.6: {} + typescript-eslint@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} @@ -12243,6 +13005,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 @@ -12510,6 +13276,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrappy@1.0.2: {} ws@8.20.0: {} @@ -12531,6 +13299,8 @@ snapshots: dependencies: lib0: 0.2.117 + yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76