From 42a93028fdebdef8056c48eab43f26c22536c1e5 Mon Sep 17 00:00:00 2001 From: mingchuno Date: Thu, 22 Oct 2026 09:07:51 +0100 Subject: [PATCH] feat(cli): infer setup and fold onboarding into start-work - init infers project, host, base branch, agent and validation from the repository and enables SDLC with local-operator approval - doctor prints a readable checklist (--json for skills), rejects placeholders, checks remote match, live host access, agent sign-in and validation commands, and names a fix for each failure - missing hosting tokens fall back to the gh or glab sign-in for local-operator projects; provider approval opts in with hosting.tokenFromCli - setup-agent-workflows becomes a start-work reference, so start-work is the only skill to install (ADR 0012) - README leads with start-work, run and monitor; help groups daily, setup, operator and skill commands Co-Authored-By: Claude Opus 5.5 --- README.md | 117 ++++--- docs/adr/0010-inline-fast-path.md | 2 + docs/adr/0012-onboard-through-start-work.md | 22 ++ docs/adr/README.md | 1 + docs/configuration.md | 22 +- docs/engineering/sdlc.md | 27 +- docs/operations.md | 3 +- docs/providers.md | 8 + packages/cli/README.md | 7 +- packages/cli/src/cli-config.ts | 2 + packages/cli/src/cli.ts | 255 +++++--------- packages/cli/src/doctor.ts | 325 ++++++++++++++++++ packages/cli/src/hosting-token.ts | 60 ++++ packages/cli/src/repository-inference.ts | 156 +++++++++ packages/core/src/config.ts | 2 + packages/core/src/recovery.ts | 6 +- packages/core/src/runner.ts | 8 +- scripts/pack-smoke.mjs | 6 +- scripts/test.mjs | 23 +- skills/setup-agent-workflows/SKILL.md | 68 ---- .../setup-agent-workflows/agents/openai.yaml | 2 - skills/start-work/SKILL.md | 11 +- skills/start-work/references/setup.md | 54 +++ skills/start-work/references/work-api.md | 2 +- tests/behavior.test.ts | 7 + tests/work-cli.test.ts | 172 ++++++++- tests/work-skills.test.ts | 18 +- 27 files changed, 1041 insertions(+), 345 deletions(-) create mode 100644 docs/adr/0012-onboard-through-start-work.md create mode 100644 packages/cli/src/doctor.ts create mode 100644 packages/cli/src/hosting-token.ts create mode 100644 packages/cli/src/repository-inference.ts delete mode 100644 skills/setup-agent-workflows/SKILL.md delete mode 100644 skills/setup-agent-workflows/agents/openai.yaml create mode 100644 skills/start-work/references/setup.md diff --git a/README.md b/README.md index 17de9bc..06763b2 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,53 @@ ![Print-shop workbench with a job sheet, a worked proof, and an inspected copy awaiting human approval.](docs/assets/hero.png) -A local TypeScript SDK and CLI/TUI that turns labelled GitHub or GitLab issues into reviewed change requests with bounded automatic repairs, and reviews existing labelled PRs/MRs. DBOS persists execution; each project uses its existing checkout, with one task at a time. Separate projects progress concurrently. +A local TypeScript SDK and CLI/TUI that takes engineering work from a sentence or +an issue to a reviewed change request. One agent skill, `start-work`, is the +entry point; the CLI enforces policy, records human approval and runs the work +durably on DBOS. Human review, merging and deployment stay with you. -Supports Codex and Copilot SDKs, GitHub, and GitLab.com/self-hosted GitLab. Human review and merging remain separate. +Supports Codex and Copilot, GitHub, and GitLab.com/self-hosted GitLab. -Opt-in [adaptive SDLC](docs/engineering/sdlc.md) adds one `start-work` skill and -deterministic Track A/B/C policy. Track A fixes run inline in the agent session. -Track B and C work becomes a Work Item with revision-bound human approval, and the -existing worker executes it. -See the [task contract](docs/engineering/task-contract.md) for schemas and gates. +## Quickstart + +Prerequisites: Node.js 22.12+, Git, PostgreSQL 17+, a signed-in `codex` or +`copilot`, and `gh` or `glab`. macOS and Linux are supported. + +```sh +npm install -g @mingchuno/agent-workflows +npx skills add mingchuno/agent-workflows --skill start-work +``` + +Then, in your agent, from the repository: + +```text +$start-work Fix incorrect sorting when size is null # Codex +/start-work #123 # Copilot +``` + +`start-work` classifies the task ([adaptive SDLC](docs/engineering/sdlc.md)): + +- **Track A**, a small clear change, runs inline in your agent session and opens + the change request. No setup is needed. +- **Tracks B and C** become a Work Item that waits for your recorded approval of + its scope (and design for C), then the worker implements, validates, reviews, + repairs and publishes it. The first time, `start-work` offers to set up the + repository with you: `agent-workflows init` infers the configuration from the + repository and `agent-workflows doctor` names each remaining gap. + +Day to day you need two commands: + +```sh +agent-workflows run # the worker and the web monitor; keep it running +agent-workflows monitor # terminal monitor: runs, Work Items and approvals +``` -## When to use it +Approve gates in the web monitor link `run` prints, or with `a` in the terminal +monitor. Everything else in `agent-workflows --help` is for the skill or for +recovery. See the [task contract](docs/engineering/task-contract.md) for schemas +and gates. + +## How it works DBOS provides durable execution, checkpoints and queues. This SDK supplies the coding-workflow behavior a client would otherwise build around it: @@ -26,53 +62,34 @@ coding-workflow behavior a client would otherwise build around it: - **Application controls and evidence:** explicit run outcomes, safe retry admission, session/log records, and a shared SDK, CLI, terminal and web monitor. -Clients configure providers and compose operations while DBOS remains responsible -for durability. [Custom workflows can call DBOS directly](docs/api.md#dbos-sdk-direct-usage). -Use this SDK when its existing-checkout, one-task-per-project model fits; use DBOS -directly when you need general-purpose workflows or want to own these policies. +Each project uses its existing checkout, one task at a time; separate projects +progress concurrently. [Custom workflows can call DBOS directly](docs/api.md#dbos-sdk-direct-usage). -## Quickstart +The runner fetches the configured base, creates a branch, implements the work, +runs the configured checks, and reviews local changes independently. It repairs +blocking findings or failed checks up to twice with fresh review after each +repair, then generates publication text, commits once and pushes. Ready results +create a non-draft PR/MR; unresolved findings, failed checks or incomplete +inspection create a draft. It never merges. Running the CLI authorizes these +effects and agent usage. -Prerequisites: Node.js 22.12+, Git, PostgreSQL 17+, and an authenticated Codex or Copilot runtime. macOS and Linux are supported; Windows process-group ownership is not supported. - -```sh -npm install -g @mingchuno/agent-workflows -createdb agent_workflows -export AGENT_WORKFLOWS_DATABASE_URL="postgresql://$(id -un)@localhost/agent_workflows" -agent-workflows init -``` - -For SDK use, install `@mingchuno/agent-workflows-core` locally. The CLI and core -packages share one version and release; the CLI installs its exact matching core -dependency automatically. - -Edit `agent-workflows.json`: set the checkout, hosting origin/repository, agent -stages and validation commands. Relative checkout, state and prompt-file paths -resolve from the configuration file's directory. Commits use native Git identity -and disclose retained writable agent contributions with default-enabled co-author -trailers. Set the named hosting-token environment variable through your usual -secret manager; for GitHub, follow the [PAT creation and required permissions](docs/providers.md#create-a-fine-grained-personal-access-token) guide before running. Commit or ignore the configuration before running. Keep the state directory outside managed checkouts, or explicitly Git-ignore it. - -```sh -agent-workflows run -# Open the printed web monitor URL, or in a second terminal: -agent-workflows monitor -agent-workflows status --json -``` +### Configuration -The local web monitor starts with `run` on port 8787. Use `--no-web` to disable -it or `--web-port PORT` to change its port. It ships prebuilt with the CLI and -supports the terminal monitor's inspection and operator actions. See the -[web monitor guide](docs/operations.md#web-monitor) for access and lifecycle details. +`agent-workflows.json` holds the checkout, hosting repository, agent, validation +commands and SDLC policy; see the [configuration reference](docs/configuration.md). +The CLI uses `gh` or `glab` sign-in for personal projects, or the variable named +by `hosting.tokenEnv` ([PAT permissions](docs/providers.md#create-a-fine-grained-personal-access-token)). +Set the database URL in `AGENT_WORKFLOWS_DATABASE_URL`, or keep it with other +secrets in an [`envFile`](docs/configuration.md#cli-environment-files) outside +the checkout. -Alternatively, set `"envFile": "./runner.env"` at the top level of -`agent-workflows.json` to load database, hosting and application variables from -one explicit file. Existing process values win, including empty strings. See -[environment file examples and boundaries](docs/configuration.md#cli-environment-files). +`run` serves the web monitor on port 8787; use `--no-web` or `--web-port PORT` +to change that. See the [web monitor guide](docs/operations.md#web-monitor). -The runner fetches the configured base, creates a branch, implements an eligible issue, runs optional configured checks, and reviews local changes independently. It repairs blocking findings or failed checks up to twice with fresh review after each repair, then generates publication text, commits once and pushes. Ready results create a non-draft PR/MR; unresolved findings, failed checks or incomplete inspection create a draft. Both deliveries complete the Run. It never merges. Initial use should target a repository and issue you explicitly intend to automate; running the CLI authorizes these effects and agent usage. +### Label intake and PR/MR review -Enable the standalone review workflow per project with: +Without SDLC, issues labelled `ready-for-agent` become runs directly. The +standalone review workflow reviews labelled PRs/MRs: ```json "workflows": { @@ -92,6 +109,8 @@ queue. See [workflow configuration](docs/configuration.md#workflows). ## SDK Use the SDK when you need to embed the runner or compose a custom workflow. +Install `@mingchuno/agent-workflows-core` locally; it shares one version and +release with the CLI. It accepts the same project settings as the CLI; your application supplies the database URL and loads its own environment. diff --git a/docs/adr/0010-inline-fast-path.md b/docs/adr/0010-inline-fast-path.md index bd138a6..473e051 100644 --- a/docs/adr/0010-inline-fast-path.md +++ b/docs/adr/0010-inline-fast-path.md @@ -22,6 +22,8 @@ Investigation, design and knowledge capture become references of `start-work` instead of separate skills. Engineers learn one command, and the host loads one skill description per session. A separate explicit-only `setup-agent-workflows` skill performs onboarding, and `doctor` reports whether delegation is available. +[ADR 0012](0012-onboard-through-start-work.md) later folded onboarding into +`start-work`. Inline Track A work is not enforced by the runner. Team enforcement for it comes from repository controls such as CI and protected branches, which also apply diff --git a/docs/adr/0012-onboard-through-start-work.md b/docs/adr/0012-onboard-through-start-work.md new file mode 100644 index 0000000..d7b3b8c --- /dev/null +++ b/docs/adr/0012-onboard-through-start-work.md @@ -0,0 +1,22 @@ +# Onboard through start-work and existing sign-ins + +ADR 0010 kept onboarding in a separate explicit-only `setup-agent-workflows` +skill. In practice an engineer met the gap inside `start-work`, when a Track B +task found delegation unavailable, and then had to learn a second skill name. +`init` wrote placeholders the engineer had to replace by hand, and a hosting +token had to be exported even when `gh` or `glab` was already signed in. + +`start-work` is now the only skill. Setup is its reference, offered when +`doctor` reports a gap for Track B or C work, and it changes nothing without the +human's agreement. `init` infers the project from the repository instead of +writing placeholders, and `doctor` reports each gap with its fix. This amends +ADR 0010's onboarding paragraph; the inline and delegated protocols are +unchanged. + +The CLI reads a missing hosting token from the host CLI's sign-in. That is the +engineer's own credential, which a same-account agent can already use, so it +adds no authority for `local-operator` projects. Provider approval relies on +keeping the approver's credentials apart from the runner's, so those projects +must opt in with `hosting.tokenFromCli`. The token lands in `tokenEnv` in the +CLI process only, so redaction and SDLC subprocess filtering treat it like an +exported token. SDK callers still prepare their own environment. diff --git a/docs/adr/README.md b/docs/adr/README.md index a86aa18..d233d19 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ documentation. - [0009: Separate Work Item authorization from execution](0009-adaptive-sdlc-work-items.md) - [0010: Run Track A inline and delegate only gated work](0010-inline-fast-path.md) - [0011: Publish clarified contracts to the issue](0011-publish-contracts-to-issues.md) +- [0012: Onboard through start-work and existing sign-ins](0012-onboard-through-start-work.md) For observable behavior, see the [public SDK API](../api.md), [configuration reference](../configuration.md), and [operator guide](../operations.md). diff --git a/docs/configuration.md b/docs/configuration.md index 08fb57f..6dd6847 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,11 +2,17 @@ Use this file to define one runner and one or more projects. The CLI reads `agent-workflows.json` by default; pass `--config PATH` for another file, or see -[configuration lookup](#configuration-lookup). Start -with `agent-workflows init`, then replace the generated repository, checkout, -agent, and validation values before running it. The file is strict JSON: unknown +[configuration lookup](#configuration-lookup). The file is strict JSON: unknown properties are rejected. +Start with `agent-workflows init` in the repository. It infers the project ID +from the directory, the host and repository from the `origin` remote, the base +branch from `origin/HEAD`, the agent from the installed `codex` or `copilot` +CLI, and the validation command from `package.json` (`verify`, else `test`), +`Cargo.toml`, `go.mod` or a Makefile `test` target. It enables SDLC with +`local-operator` approval, prints what it inferred, and marks anything it could +not discover as a placeholder. Run `agent-workflows doctor` next. + ## Minimal example This example assumes the file is in the managed repository root. Set the database @@ -247,8 +253,14 @@ by the previous schema, after stopping the old runner and resolving its work. | `origin` | URL string | Web origin, including a GitLab relative root if applicable. | | `repository` | nonempty string | Provider repository path, such as `OWNER/REPOSITORY` or `group/project`. | | `tokenEnv` | uppercase environment variable name | Variable holding the API token; pattern `[A-Z_][A-Z0-9_]*`. | - -All four hosting fields are required. Do not put a token value in the JSON. +| `tokenFromCli` | boolean | CLI only. When `tokenEnv` is unset, read the token from the `gh` or `glab` sign-in. Defaults to `true` with `local-operator` approval and `false` with provider approval. | + +The first four hosting fields are required. Do not put a token value in the JSON. +The CLI places a token read from `gh auth token` or `glab config get token` in +`tokenEnv` for its own process only, so the usual redaction and agent +subprocess filtering apply. Provider approval relies on separating the +approver's credentials from the runner's, so it needs an explicit opt-in. +SDK callers prepare their own environment. ### Agent profile and stages diff --git a/docs/engineering/sdlc.md b/docs/engineering/sdlc.md index 0724a51..0c61c9d 100644 --- a/docs/engineering/sdlc.md +++ b/docs/engineering/sdlc.md @@ -43,24 +43,25 @@ Policy minimums cannot be lowered; change `riskRules` deliberately instead. ## Set up delegated work -1. Install the skills from the target project with the +1. Install the skill from the target project with the [Skills CLI](https://github.com/vercel-labs/skills), selecting the agent host. ```sh - npx skills add mingchuno/agent-workflows \ - --skill start-work \ - --skill setup-agent-workflows + npx skills add mingchuno/agent-workflows --skill start-work ``` - For unpublished local changes, copy those folders from `skills/` into + For unpublished local changes, copy `skills/start-work` into `.agents/skills/`. Follow company policy for reviewed, pinned skill distribution or an internal mirror. -2. Invoke `setup-agent-workflows` once per repository. It writes the - configuration, prepares PostgreSQL and credentials with you, and verifies - them with `agent-workflows doctor`. To do this by hand, follow - [provider prerequisites](../providers.md) and keep the state directory - outside the source checkout. -3. Start the resident worker in its own terminal. +2. Invoke `$start-work` in Codex or `/start-work` in a Copilot host that supports + slash invocation, with an issue or a task. When the task needs Track B or C + and `agent-workflows doctor` reports a gap, the skill offers to set up the + repository with you: it runs `agent-workflows init`, which infers the + configuration from the repository, then works through each failing check. + To do this by hand, run `init` and `doctor` yourself and follow + [provider prerequisites](../providers.md). +3. Start the resident worker in its own terminal, and approve work in the web + monitor it prints or in `agent-workflows monitor`. ```sh agent-workflows run @@ -68,10 +69,8 @@ Policy minimums cannot be lowered; change `riskRules` deliberately instead. Commands find the configuration as described in [configuration lookup](../configuration.md#configuration-lookup). -4. Invoke `$start-work` in Codex or `/start-work` in a Copilot host that supports - slash invocation, with an issue or a task. Confirm discovery with that host - before relying on it. +Confirm skill discovery with your agent host before relying on it. The CLI persists contracts and enforces state transitions. The runner's implementation and review stages keep their own prompts. Installing the skills does not establish that an SDK-created session loads them. diff --git a/docs/operations.md b/docs/operations.md index d13799e..c610e08 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,8 @@ All commands accept `--config PATH` before the subcommand. For example, | Command | Effect | | --- | --- | -| `init` | Write a starter config; refuse overwrite | +| `init` | Write a config inferred from the repository; refuse overwrite | +| `doctor [--project ID] [--json]` | Check configuration, repository, credentials, host access, agent sign-in, validation commands, database and worker, with a fix for each failure | | `version` | Print the installed package version; also available as `--version` or `-V` | | `run [--project ID ...] [--no-web] [--web-port PORT]` | Start selected projects and the local web monitor | | `config show` | Print merged, schema-validated JSON from the current file without runtime services | diff --git a/docs/providers.md b/docs/providers.md index 109ec1f..db58dc7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -26,6 +26,14 @@ permissions of the local runtime account. The runner deliberately does not automatically resume interrupted agent work. Runtime session existence does not establish whether old processes are still writing. Use runtime-specific tools/SDKs to inspect sessions after stopping the runner and establishing ownership; there is no universal session-opening command. +## Hosting tokens + +Set the variable named by `hosting.tokenEnv`, or sign in with `gh auth login` or +`glab auth login`. With `local-operator` approval the CLI reads a missing token +from that sign-in; provider approval needs `hosting.tokenFromCli: true`. See +[hosting configuration](configuration.md#hosting). `agent-workflows doctor` +reads the configured repository once to prove the token and repository name. + ## Environment inheritance CLI `envFile` values reach validation commands, Git subprocesses and agent diff --git a/packages/cli/README.md b/packages/cli/README.md index 265a513..2e791fc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5,9 +5,10 @@ web app. Requires Node.js 22.12+ and PostgreSQL. ```sh npm install -g @mingchuno/agent-workflows -agent-workflows init -# Configure your checkout, hosting credentials, agent and database. -agent-workflows run +npx skills add mingchuno/agent-workflows --skill start-work +# In your agent: $start-work . It sets up delegated work with you. +agent-workflows run # worker and web monitor +agent-workflows monitor # terminal monitor and approvals ``` The CLI automatically installs the exact matching `@mingchuno/agent-workflows-core` diff --git a/packages/cli/src/cli-config.ts b/packages/cli/src/cli-config.ts index 54dc70b..e123a18 100644 --- a/packages/cli/src/cli-config.ts +++ b/packages/cli/src/cli-config.ts @@ -6,6 +6,7 @@ import { resolveConfiguration, } from "@mingchuno/agent-workflows-core"; import { z } from "zod"; +import { applyCliTokens } from "./hosting-token.js"; const cliConfigurationSchema = configurationInputSchema.extend({ envFile: z @@ -32,6 +33,7 @@ export async function openCliConfiguration( const { envFile, ...config } = await readConfigurationFile(path); if (envFile !== undefined) await loadEnvironmentFile(resolveEnvironmentFile(envFile)); + applyCliTokens(config.projects); return { config, envFile: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cb74180..70ad38b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,7 +14,6 @@ import { subjectReference, workOperator, } from "@mingchuno/agent-workflows-core"; -import { defaultValidationTimeoutMs } from "@mingchuno/agent-workflows-core/operator"; import { Command } from "commander"; import { render } from "ink"; import React from "react"; @@ -31,7 +30,9 @@ import { readActiveConfiguration, readRunnerControl, } from "./cli-runner-control.js"; -import { resolveConfigPath, withinCheckout } from "./config-discovery.js"; +import { resolveConfigPath } from "./config-discovery.js"; +import { registerDoctor } from "./doctor.js"; +import { inferProject } from "./repository-inference.js"; import { createTerminalNotificationWriter, Monitor } from "./tui/index.js"; import { defaultWebPort } from "./web/contracts.js"; import { startWebMonitor } from "./web/lifecycle.js"; @@ -51,7 +52,9 @@ const packageVersion: string = JSON.parse( ).version; const program = new Command() .name("agent-workflows") - .description("Local durable issue-to-review workflows") + .description( + "Durable agent coding workflows. Day to day you need only `run` and `monitor`; start-work drives the rest.", + ) .version(packageVersion) .option( "-c, --config ", @@ -123,7 +126,9 @@ async function withConfiguredStore( } program .command("init") - .description("Write a configuration scaffold without overwriting files") + .description( + "Write a configuration inferred from this repository, never overwriting", + ) .action(async () => { // A new file belongs where the user asked, never in a discovered location. discoveredConfigPath = resolveConfigPath( @@ -132,47 +137,27 @@ program { discover: false }, ); const base = configBaseDirectory(); - const checkout = process.cwd(); + const inferred = inferProject(process.cwd()); const externalState = resolve( - checkout, + inferred.checkout, "..", - `${basename(checkout)}.agent-workflows`, + `${basename(inferred.checkout)}.agent-workflows`, ); const portable = (path: string) => relative(base, path) || "."; const config = { - id: "local", - databaseUrlEnv: "AGENT_WORKFLOWS_DATABASE_URL", + id: inferred.id, stateDirectory: portable(externalState), projects: [ { - id: "example", - checkout: portable(checkout), - hosting: { - provider: "github", - origin: "https://github.com", - repository: "OWNER/REPOSITORY", - tokenEnv: "GITHUB_TOKEN", - }, - workflows: { - implementation: { enabled: true, labels: ["ready-for-agent"] }, - review: { - enabled: false, - labels: ["ready-for-review"], - rereviewOnPush: false, - }, - }, - baseBranch: "main", - branchTemplate: "agent/{issue}-{attempt}", - includeAgentCoAuthors: true, - agent: { provider: "codex" }, - validation: [ - { - command: "pnpm", - args: ["test"], - timeoutMs: defaultValidationTimeoutMs, - }, - ], - stages: { implementation: {}, publication: {}, review: {} }, + id: inferred.id, + checkout: portable(inferred.checkout), + hosting: inferred.hosting, + ...(inferred.baseBranch === "main" + ? {} + : { baseBranch: inferred.baseBranch }), + agent: inferred.agent, + sdlc: { enabled: true, approvalSource: "local-operator" }, + validation: inferred.validation, }, ], }; @@ -180,130 +165,33 @@ program flag: "wx", mode: 0o600, }); + const validation = + inferred.validation + .map((item) => [item.command, ...item.args].join(" ")) + .join("; ") || "none found"; console.log( - "Created configuration. Set repository and credentials before running.", - ); - }); -program - .command("doctor") - .description( - "Check the configuration, project, credentials, database and worker that delegated work needs", - ) - .option( - "-p, --project ", - "project to check (default: the project whose checkout contains the current directory)", - ) - .action(async (options: { project?: string }) => { - const checks: { - name: string; - status: "pass" | "warn" | "fail"; - detail: string; - }[] = []; - const check = ( - name: string, - status: "pass" | "warn" | "fail", - detail: string, - ) => checks.push({ name, status, detail }); - const report = (project?: string) => { - const ok = checks.every((item) => item.status !== "fail"); - let resolvedConfigPath: string | undefined; - try { - resolvedConfigPath = configPath(); - } catch { - // Ambiguous discovery is already reported as the configuration check. - } - console.log( - JSON.stringify( - { - ok, - version: packageVersion, - configPath: resolvedConfigPath, - project, - protocols: { inline: true, delegated: ok }, - checks, - }, - null, - 2, - ), - ); - if (!ok) process.exitCode = 1; - }; - let config: Configuration; - try { - config = await configuration(); - check("configuration", "pass", configPath()); - } catch (error) { - check( - "configuration", - "fail", - `${error instanceof Error ? error.message : String(error)}. Run the setup-agent-workflows skill or agent-workflows init.`, - ); - return report(); - } - const base = configBaseDirectory(); - const project = options.project - ? config.projects.find((candidate) => candidate.id === options.project) - : config.projects.find((candidate) => - withinCheckout(launchDirectory, resolve(base, candidate.checkout)), - ); - if (!project) { - check( - "project", - "fail", - options.project - ? `Unknown project: ${options.project}` - : `No configured project checkout contains ${launchDirectory}`, - ); - return report(); - } - check("project", "pass", project.id); - check( - "sdlc", - project.sdlc.enabled ? "pass" : "fail", - project.sdlc.enabled - ? `approval source ${project.sdlc.approvalSource}` - : "Set sdlc.enabled for this project to delegate Track B and C work", - ); - check( - "hosting-credentials", - process.env[project.hosting.tokenEnv] ? "pass" : "fail", - process.env[project.hosting.tokenEnv] - ? `${project.hosting.tokenEnv} is set` - : `Set ${project.hosting.tokenEnv}`, - ); - try { - const store = new Store(databaseUrl(config), config.id); - try { - await Promise.race([ - store.initialize().then(() => store.projects()), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Database connection timed out")), - 10_000, - ).unref(), - ), - ]); - check("database", "pass", "Connected and migrated"); - } finally { - await store.close(); - } - } catch (error) { - check( - "database", - "fail", - error instanceof Error ? error.message : String(error), - ); - } - const worker = await findRunnerControl(configPath()); - check( - "worker", - worker ? "pass" : "warn", - worker - ? `Running as process ${worker.pid}` - : "Not running; admitted work waits until agent-workflows run starts", + [ + `Created ${configPath()}`, + ` project ${inferred.id} (${portable(inferred.checkout)})`, + ` hosting ${inferred.hosting.provider} ${inferred.hosting.repository}`, + ` base ${inferred.baseBranch}`, + ` agent ${inferred.agent.provider}`, + ` validation ${validation}`, + " approval local-operator (approve in the monitor)", + ...(inferred.placeholders.length + ? [`Replace placeholder: ${inferred.placeholders.join(", ")}`] + : []), + "Next: agent-workflows doctor", + ].join("\n"), ); - report(project.id); }); +registerDoctor(program, { + version: packageVersion, + launchDirectory, + configPath, + configBaseDirectory, + configuration, +}); program .command("config") .description("Inspect configuration") @@ -317,6 +205,7 @@ program }); program .command("run") + .description("Start the worker and the web monitor; keep it running") .option("-p, --project ", "run selected project IDs") .option("--no-web", "disable the local web monitor") .option("--web-port ", "local web monitor port", String(defaultWebPort)) @@ -442,6 +331,7 @@ program }); program .command("status") + .description("Show projects, runs and command outcomes") .option("--json", "machine-readable output") .action(async (options: { json?: boolean }) => withStore(async (store) => { @@ -465,23 +355,27 @@ program } }), ); -program.command("inspect ").action(async (run: string) => - withStore(async (store) => - console.log( - JSON.stringify( - { - run: await store.run(run), - invocations: await store.invocations(run), - recovery: await store.recoveryPlan(run), - }, - null, - 2, +program + .command("inspect ") + .description("Print a run and its invocations as JSON") + .action(async (run: string) => + withStore(async (store) => + console.log( + JSON.stringify( + { + run: await store.run(run), + invocations: await store.invocations(run), + recovery: await store.recoveryPlan(run), + }, + null, + 2, + ), ), ), - ), -); + ); program .command("logs ") + .description("Print a run's stage, agent and validation logs") .option("--invocation ", "specific invocation") .action(async (run: string, options: { invocation?: string }) => withStore(async (store) => { @@ -541,6 +435,7 @@ program ); program .command("monitor") + .description("Open the terminal monitor: runs, Work Items and approvals") .option("--notify", "notify when an observed execution reaches an outcome") .action(async (options: { notify?: boolean }) => { const control = await findRunnerControl(configPath()); @@ -598,6 +493,24 @@ registerWorkCommands(program, { configuration, withStore: withConfiguredStore, }); +// Help lists groups in command order, so daily commands come first. +const helpGroups = [ + ["Daily:", ["run", "monitor"]], + ["Setup:", ["init", "doctor", "config", "version"]], + ["Operator controls:", []], + ["Used by the start-work skill:", ["work"]], +] as const; +const helpRank = (name: string) => { + const rank = helpGroups.findIndex(([, names]) => + (names as readonly string[]).includes(name), + ); + return rank === -1 ? 2 : rank; +}; +(program.commands as Command[]).sort( + (a, b) => helpRank(a.name()) - helpRank(b.name()), +); +for (const command of program.commands) + command.helpGroup(helpGroups[helpRank(command.name())]![0]); try { await program.parseAsync(); } catch (error) { diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts new file mode 100644 index 0000000..777eafb --- /dev/null +++ b/packages/cli/src/doctor.ts @@ -0,0 +1,325 @@ +import { existsSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { + type Configuration, + type Project, + Store, +} from "@mingchuno/agent-workflows-core"; +import type { Command } from "commander"; +import { findRunnerControl } from "./cli-runner-control.js"; +import { withinCheckout } from "./config-discovery.js"; +import { applyCliTokens, cliTokenAllowed } from "./hosting-token.js"; +import { onPath, remoteRepository } from "./repository-inference.js"; + +type Status = "pass" | "warn" | "fail"; +interface Check { + name: string; + status: Status; + detail: string; +} +export interface DoctorReport { + ok: boolean; + version: string; + configPath?: string; + project?: string; + protocols: { inline: true; delegated: boolean }; + checks: Check[]; +} + +interface DoctorDependencies { + version: string; + launchDirectory: string; + configPath(): string; + configBaseDirectory(): string; + configuration(): Promise; +} + +const message = (error: unknown) => + error instanceof Error ? error.message : String(error); + +function apiRequest(project: Project): { url: string; headers: HeadersInit } { + const origin = project.hosting.origin.replace(/\/$/, ""); + const token = process.env[project.hosting.tokenEnv] ?? ""; + if (project.hosting.provider === "github") + return { + url: `${origin === "https://github.com" ? "https://api.github.com" : `${origin}/api/v3`}/repos/${project.hosting.repository}`, + headers: { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + }, + }; + return { + url: `${origin}/api/v4/projects/${encodeURIComponent(project.hosting.repository)}`, + headers: { "private-token": token }, + }; +} + +/** One read of the configured repository proves the token and the name. */ +async function checkHostingAccess(project: Project): Promise { + const { url, headers } = apiRequest(project); + try { + const response = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (response.ok) + return { + name: "hosting-access", + status: "pass", + detail: `Read ${project.hosting.repository}`, + }; + const reason = + response.status === 401 + ? "the token was rejected" + : response.status === 404 || response.status === 403 + ? "the repository was not found or the token cannot read it" + : `the host answered ${response.status}`; + return { + name: "hosting-access", + status: "fail", + detail: `Cannot read ${project.hosting.repository}: ${reason}. Check hosting.repository and the token's repository access.`, + }; + } catch (error) { + return { + name: "hosting-access", + status: "warn", + detail: `Could not reach ${project.hosting.origin}: ${message(error)}`, + }; + } +} + +function agentCheck(project: Project): Check { + const provider = project.agent.provider; + if (provider === "codex") { + const home = + process.env.CODEX_HOME || resolve(process.env.HOME ?? "", ".codex"); + const signedIn = + Boolean(process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY) || + existsSync(resolve(home, "auth.json")); + return signedIn + ? { name: "agent", status: "pass", detail: "Codex sign-in found" } + : { + name: "agent", + status: "warn", + detail: "No Codex sign-in found. Run `codex login`.", + }; + } + const signedIn = + Boolean(process.env.COPILOT_GITHUB_TOKEN) || + existsSync(resolve(process.env.HOME ?? "", ".copilot")); + return signedIn + ? { name: "agent", status: "pass", detail: "Copilot sign-in found" } + : { + name: "agent", + status: "warn", + detail: "No Copilot sign-in found. Run `copilot` and sign in.", + }; +} + +function validationCheck(project: Project, checkout: string): Check { + const missing = project.validation + .map((item) => item.command) + .filter((command) => + command.includes("/") + ? !existsSync( + isAbsolute(command) ? command : resolve(checkout, command), + ) + : !onPath(command), + ); + if (!project.validation.length) + return { + name: "validation", + status: "warn", + detail: + "No validation commands. Add the project's checks to `validation`.", + }; + return missing.length + ? { + name: "validation", + status: "fail", + detail: `Not found: ${[...new Set(missing)].join(", ")}`, + } + : { + name: "validation", + status: "pass", + detail: project.validation + .map((item) => [item.command, ...item.args].join(" ")) + .join("; "), + }; +} + +export async function doctor( + dependencies: DoctorDependencies, + options: { project?: string }, +): Promise { + const checks: Check[] = []; + const check = (name: string, status: Status, detail: string) => + checks.push({ name, status, detail }); + const report = (project?: string): DoctorReport => { + let configPath: string | undefined; + try { + configPath = dependencies.configPath(); + } catch { + // Ambiguous discovery is already reported as the configuration check. + } + const ok = checks.every((item) => item.status !== "fail"); + return { + ok, + version: dependencies.version, + configPath, + project, + protocols: { inline: true, delegated: ok }, + checks, + }; + }; + let config: Configuration; + try { + config = await dependencies.configuration(); + check("configuration", "pass", dependencies.configPath()); + } catch (error) { + check( + "configuration", + "fail", + `${message(error)}. Run \`agent-workflows init\` in the repository, or ask start-work to set it up.`, + ); + return report(); + } + const base = dependencies.configBaseDirectory(); + const project = options.project + ? config.projects.find((candidate) => candidate.id === options.project) + : config.projects.find((candidate) => + withinCheckout( + dependencies.launchDirectory, + resolve(base, candidate.checkout), + ), + ); + if (!project) { + check( + "project", + "fail", + options.project + ? `Unknown project: ${options.project}` + : `No configured project checkout contains ${dependencies.launchDirectory}`, + ); + return report(); + } + check("project", "pass", project.id); + const checkout = resolve(base, project.checkout); + if (project.hosting.repository === "OWNER/REPOSITORY") + check( + "repository", + "fail", + "hosting.repository is still the OWNER/REPOSITORY placeholder", + ); + else { + const remote = remoteRepository(checkout); + const matches = + !remote || + (remote.repository.toLowerCase() === + project.hosting.repository.toLowerCase() && + remote.origin === new URL(project.hosting.origin).origin); + check( + "repository", + matches ? "pass" : "warn", + matches + ? `${project.hosting.origin} ${project.hosting.repository}` + : `Configured ${project.hosting.origin} ${project.hosting.repository}, but origin is ${remote!.origin} ${remote!.repository}`, + ); + } + check( + "sdlc", + project.sdlc.enabled ? "pass" : "fail", + project.sdlc.enabled + ? `approval source ${project.sdlc.approvalSource}` + : "Set sdlc.enabled for this project to delegate Track B and C work", + ); + const source = applyCliTokens([project]).get(project.id); + const tool = project.hosting.provider === "github" ? "gh" : "glab"; + check( + "hosting-credentials", + source ? "pass" : "fail", + source === "environment" + ? `${project.hosting.tokenEnv} is set` + : source + ? `Using the ${source} sign-in` + : cliTokenAllowed(project) + ? `Sign in with \`${tool} auth login\`, or set ${project.hosting.tokenEnv}` + : `Set ${project.hosting.tokenEnv}, or set hosting.tokenFromCli to use the ${tool} sign-in`, + ); + if (source && project.hosting.repository !== "OWNER/REPOSITORY") + checks.push(await checkHostingAccess(project)); + checks.push(agentCheck(project)); + checks.push(validationCheck(project, checkout)); + try { + const url = process.env[config.databaseUrlEnv]; + if (!url) + throw new Error(`Set ${config.databaseUrlEnv} to a PostgreSQL URL`); + const store = new Store(url, config.id); + try { + await Promise.race([ + store.initialize().then(() => store.projects()), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Database connection timed out")), + 10_000, + ).unref(), + ), + ]); + check("database", "pass", "Connected and migrated"); + } finally { + await store.close(); + } + } catch (error) { + check("database", "fail", message(error)); + } + const worker = await findRunnerControl(dependencies.configPath()); + check( + "worker", + worker ? "pass" : "warn", + worker + ? `Running as process ${worker.pid}` + : "Not running. Start `agent-workflows run` in its own terminal; approved work waits until then.", + ); + return report(project.id); +} + +const symbols: Record = { pass: "✓", warn: "!", fail: "✗" }; + +export function formatDoctor(report: DoctorReport): string { + const width = Math.max(...report.checks.map((item) => item.name.length)); + const failing = report.checks.filter((item) => item.status === "fail"); + return [ + `agent-workflows ${report.version}${report.configPath ? ` ${report.configPath}` : ""}`, + "", + ...report.checks.map( + (item) => + `${symbols[item.status]} ${item.name.padEnd(width)} ${item.detail}`, + ), + "", + report.ok + ? "Ready: start-work can delegate Track B and C work." + : `Delegated work unavailable: fix ${failing.length} failing check${failing.length === 1 ? "" : "s"}. Track A work runs inline without setup.`, + ].join("\n"); +} + +export function registerDoctor( + program: Command, + dependencies: DoctorDependencies, +): void { + program + .command("doctor") + .description("Check what start-work needs and say how to fix each gap") + .option( + "-p, --project ", + "project to check (default: the project whose checkout contains the current directory)", + ) + .option("--json", "machine-readable output") + .action(async (options: { project?: string; json?: boolean }) => { + const result = await doctor(dependencies, options); + console.log( + options.json ? JSON.stringify(result, null, 2) : formatDoctor(result), + ); + if (!result.ok) process.exitCode = 1; + }); +} diff --git a/packages/cli/src/hosting-token.ts b/packages/cli/src/hosting-token.ts new file mode 100644 index 0000000..54e1cce --- /dev/null +++ b/packages/cli/src/hosting-token.ts @@ -0,0 +1,60 @@ +import { execFileSync } from "node:child_process"; +import type { Project } from "@mingchuno/agent-workflows-core"; + +/** + * Personal projects may reuse the host CLI's sign-in. Provider approval + * sources need an explicit opt-in because they rely on credential separation. + */ +export function cliTokenAllowed(project: Project): boolean { + return ( + project.hosting.tokenFromCli ?? + project.sdlc.approvalSource === "local-operator" + ); +} + +function cliToken(project: Project): string | undefined { + const host = new URL(project.hosting.origin).host; + const [executable, args] = + project.hosting.provider === "github" + ? ["gh", ["auth", "token", "--hostname", host]] + : ["glab", ["config", "get", "token", "--host", host]]; + try { + return ( + execFileSync(executable, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }).trim() || undefined + ); + } catch { + return undefined; + } +} + +export type TokenSource = "environment" | "gh" | "glab"; +const filled = new Map(); + +/** + * Fill each missing hosting token from `gh` or `glab`. The value lands in the + * project's `tokenEnv`, so existing redaction and subprocess filtering apply. + */ +export function applyCliTokens( + projects: readonly Project[], +): Map { + const sources = new Map(); + for (const project of projects) { + const name = project.hosting.tokenEnv; + if (process.env[name]) { + sources.set(project.id, filled.get(name) ?? "environment"); + continue; + } + if (!cliTokenAllowed(project)) continue; + const value = cliToken(project); + if (!value) continue; + process.env[name] = value; + const source = project.hosting.provider === "github" ? "gh" : "glab"; + filled.set(name, source); + sources.set(project.id, source); + } + return sources; +} diff --git a/packages/cli/src/repository-inference.ts b/packages/cli/src/repository-inference.ts new file mode 100644 index 0000000..e87c10a --- /dev/null +++ b/packages/cli/src/repository-inference.ts @@ -0,0 +1,156 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, delimiter, join } from "node:path"; + +export interface RemoteRepository { + provider: "github" | "gitlab"; + origin: string; + repository: string; +} + +function git(directory: string, args: string[]): string | undefined { + try { + return execFileSync("git", args, { + cwd: directory, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return undefined; + } +} + +/** Parse an SSH, scp-style or HTTPS Git remote into hosting settings. */ +export function parseRemote(url: string): RemoteRepository | undefined { + let host: string; + let path: string; + const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(url); + if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) { + host = scp[1]!; + path = scp[2]!; + } else { + try { + const parsed = new URL(url); + if (!["https:", "http:", "ssh:", "git:"].includes(parsed.protocol)) + return undefined; + host = parsed.hostname; + path = parsed.pathname; + } catch { + return undefined; + } + } + const repository = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); + if (!host || repository.split("/").length < 2) return undefined; + const provider = + host === "github.com" || host.includes("github") ? "github" : "gitlab"; + if (provider === "github" && repository.split("/").length !== 2) + return undefined; + return { provider, origin: `https://${host}`, repository }; +} + +export function remoteRepository( + directory: string, +): RemoteRepository | undefined { + const url = git(directory, ["remote", "get-url", "origin"]); + return url ? parseRemote(url) : undefined; +} + +export function onPath(executable: string): boolean { + return (process.env.PATH ?? "") + .split(delimiter) + .some((directory) => directory && existsSync(join(directory, executable))); +} + +function packageScripts(checkout: string): Record { + try { + const parsed = JSON.parse( + readFileSync(join(checkout, "package.json"), "utf8"), + ); + return parsed && typeof parsed.scripts === "object" ? parsed.scripts : {}; + } catch { + return {}; + } +} + +function packageManager(checkout: string): string { + if (existsSync(join(checkout, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(checkout, "yarn.lock"))) return "yarn"; + if ( + existsSync(join(checkout, "bun.lock")) || + existsSync(join(checkout, "bun.lockb")) + ) + return "bun"; + return "npm"; +} + +/** The project's own baseline check, or none when nothing obvious exists. */ +export function inferValidation( + checkout: string, +): { command: string; args: string[] }[] { + const scripts = packageScripts(checkout); + const script = ["verify", "test"].find( + (name) => typeof scripts[name] === "string", + ); + if (script) + return [{ command: packageManager(checkout), args: ["run", script] }]; + if (existsSync(join(checkout, "Cargo.toml"))) + return [{ command: "cargo", args: ["test"] }]; + if (existsSync(join(checkout, "go.mod"))) + return [{ command: "go", args: ["test", "./..."] }]; + try { + if (/^test:/m.test(readFileSync(join(checkout, "Makefile"), "utf8"))) + return [{ command: "make", args: ["test"] }]; + } catch { + // No Makefile. + } + return []; +} + +export interface InferredProject { + id: string; + checkout: string; + hosting: RemoteRepository & { tokenEnv: string }; + baseBranch: string; + agent: { provider: "codex" | "copilot" }; + validation: { command: string; args: string[] }[]; + /** Values `init` could not discover and the human must replace. */ + placeholders: string[]; +} + +export function inferProject(directory: string): InferredProject { + const checkout = + git(directory, ["rev-parse", "--show-toplevel"]) ?? directory; + const placeholders: string[] = []; + let remote = remoteRepository(checkout); + if (!remote) { + placeholders.push("hosting.repository"); + remote = { + provider: "github", + origin: "https://github.com", + repository: "OWNER/REPOSITORY", + }; + } + const head = git(checkout, [ + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ]); + const id = + basename(checkout) + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") || "project"; + return { + id, + checkout, + hosting: { + ...remote, + tokenEnv: remote.provider === "github" ? "GITHUB_TOKEN" : "GITLAB_TOKEN", + }, + baseBranch: head?.replace(/^origin\//, "") || "main", + agent: { + provider: !onPath("codex") && onPath("copilot") ? "copilot" : "codex", + }, + validation: inferValidation(checkout), + placeholders, + }; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a6ee8fc..42ad6c8 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -56,6 +56,8 @@ export const projectSchema = z origin: z.url(), repository: z.string().min(1), tokenEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/), + /** CLI only: read a missing token from `gh` or `glab` sign-in. */ + tokenFromCli: z.boolean().optional(), }), workflows: z .strictObject({ diff --git a/packages/core/src/recovery.ts b/packages/core/src/recovery.ts index 2b3630b..4927697 100644 --- a/packages/core/src/recovery.ts +++ b/packages/core/src/recovery.ts @@ -100,7 +100,11 @@ export async function executionFingerprint( hosting, ...execution } = project; - const { tokenEnv: _token, ...destination } = hosting; + const { + tokenEnv: _token, + tokenFromCli: _tokenFromCli, + ...destination + } = hosting; const prompts = projectPrompts(project); const remote = await command( "git", diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index aeaaa12..9823a85 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -798,7 +798,13 @@ function assertReloadableConfiguration( if (!previous) continue; if (previous.checkout !== project.checkout) changed.push(`projects.${project.id}.checkout`); - for (const key of ["provider", "origin", "repository", "tokenEnv"] as const) + for (const key of [ + "provider", + "origin", + "repository", + "tokenEnv", + "tokenFromCli", + ] as const) if (previous.hosting[key] !== project.hosting[key]) changed.push(`projects.${project.id}.hosting.${key}`); } diff --git a/scripts/pack-smoke.mjs b/scripts/pack-smoke.mjs index 9aae270..40623c8 100644 --- a/scripts/pack-smoke.mjs +++ b/scripts/pack-smoke.mjs @@ -260,7 +260,7 @@ try { const cli = join(consumer, "node_modules/.bin/agent-workflows"); for (const option of ["version", "--version", "-V"]) assert.equal(run(cli, [option]), `${manifest.version}\n`); - assert.match(run(cli, ["--help"]), /Local durable issue-to-review workflows/); + assert.match(run(cli, ["--help"]), /Durable agent coding workflows/); assert.throws( () => run(cli, ["--env-file", "missing.env", "init"]), (error) => @@ -270,7 +270,9 @@ try { const config = JSON.parse( await readFile(join(consumer, "agent-workflows.json"), "utf8"), ); - assert.equal(config.id, "local"); + // init names the runner and project after the directory it ran in. + assert.equal(config.id, config.projects[0].id); + assert.equal(config.projects[0].sdlc.enabled, true); assert.equal(config.projects[0].checkout, "."); assert.equal(config.envFile, undefined); await writeFile( diff --git a/scripts/test.mjs b/scripts/test.mjs index aa1d590..08ad48a 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -1,8 +1,8 @@ import { execFileSync, spawn } from "node:child_process"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; const mode = process.argv[2]; if (process.argv.length > 3 || (mode && mode !== "--pack-smoke")) @@ -54,6 +54,12 @@ try { ]); url = `postgresql://agent_workflows@127.0.0.1:${port}/agent_workflows_test`; } + const signedOut = join(testDirectory, "signed-out"); + await mkdir(signedOut); + for (const name of ["gh", "glab"]) + await writeFile(join(signedOut, name), "#!/bin/sh\nexit 1\n", { + mode: 0o755, + }); const files = (await readdir("tests")) .filter((file) => /\.test\.tsx?$/.test(file)) .map((file) => join("tests", file)); @@ -62,7 +68,18 @@ try { mode === "--pack-smoke" ? ["scripts/pack-smoke.mjs"] : ["--import", "tsx", "--test", "--test-concurrency=1", ...files], - { stdio: "inherit", env: { ...process.env, TEST_DATABASE_URL: url } }, + { + stdio: "inherit", + env: { + ...process.env, + TEST_DATABASE_URL: url, + // Keep the developer's gh and glab sign-in out of hosting fallback; + // gh also reads the system keyring, so shadow both executables. + PATH: `${signedOut}${delimiter}${process.env.PATH}`, + GITHUB_TOKEN: "", + GITLAB_TOKEN: "", + }, + }, ); process.exitCode = await new Promise((resolve, reject) => { child.once("error", reject); diff --git a/skills/setup-agent-workflows/SKILL.md b/skills/setup-agent-workflows/SKILL.md deleted file mode 100644 index 43c7d49..0000000 --- a/skills/setup-agent-workflows/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: setup-agent-workflows -disable-model-invocation: true -description: Set up agent-workflows for the current repository so start-work can delegate Track B and C work. Run once per repository, or when agent-workflows doctor reports a failing check. ---- - -# Set up agent-workflows - -Explore, present what you found, confirm with the human, then write. Ask one -question at a time and lead with the recommended answer. Change nothing before -the human confirms, and never overwrite an existing configuration. - -## 1. Explore (read only) - -- `agent-workflows version` and `agent-workflows doctor`. If doctor already - passes, report that and stop. -- `git remote -v`: GitHub or GitLab, origin and `OWNER/REPOSITORY`. -- An existing configuration: what `agent-workflows doctor` reports as - `configPath`, which also searches `$AGENT_WORKFLOWS_HOME`. -- `gh auth status` or `glab auth status`, and which of `codex` or `copilot` is - installed and signed in. -- PostgreSQL: a URL the human already uses, `pg_isready`, or Docker. -- Validation commands from AGENTS.md or the package scripts. - -## 2. Decide with the human - -1. **Goal.** Track A works inline with no setup. Recommend full setup only when - the human wants approval-gated Track B and C work run by the worker. -2. **Configuration location.** For one repository, recommend - `agent-workflows.json` at the repository root with `checkout: "."`, - committed if the team shares the policy, otherwise listed in - `.git/info/exclude`. For several repositories or a dedicated directory, - put each configuration in `$AGENT_WORKFLOWS_HOME` (default - `~/.config/agent-workflows`) with an absolute `checkout`; the CLI finds the - one that manages the current checkout. Before adding a second runner, check - the four collision conditions in the - [operations guide](https://github.com/mingchuno/agent-workflows/blob/main/docs/operations.md#several-runners-on-one-database). -3. **Database.** PostgreSQL is required. Offer the human's existing server, or - a local container such as - `docker run -d --name agent-workflows-db -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:17`. - A Homebrew server on macOS needs `LC_ALL` set, for example - `LC_ALL=en_US.UTF-8`, or it fails at startup. -4. **Approval source.** `local-operator` for personal projects. For team - projects, use `github` or `gitlab` with an explicit `approvers` allowlist. - -## 3. Write - -1. Install the CLI if missing, after the human agrees: - `npm install -g @mingchuno/agent-workflows`. -2. Run `agent-workflows init` in the repository root, adding `--config PATH` - for a file outside it. It refuses to overwrite. -3. Edit the generated file: project `id`, `hosting`, `agent.provider`, - `validation`, and `sdlc` (`enabled: true`, `approvalSource`, `approvers`). - Keep `stateDirectory` outside the checkout. -4. Put secrets only in an environment file outside the checkout with mode 600, - referenced by `envFile`: the database URL variable named by - `databaseUrlEnv`, and the hosting token variable named by - `hosting.tokenEnv`. Never write a secret into the configuration or the - conversation. -5. Install the entry skill: - `npx skills add mingchuno/agent-workflows --skill start-work`. - -## 4. Verify - -Run `agent-workflows doctor` until every check passes or only `worker` warns. -Tell the human to start the worker in its own terminal with -`agent-workflows run`, and that `/start-work` (or `$start-work`) is now the -only command they need. diff --git a/skills/setup-agent-workflows/agents/openai.yaml b/skills/setup-agent-workflows/agents/openai.yaml deleted file mode 100644 index 5b1f887..0000000 --- a/skills/setup-agent-workflows/agents/openai.yaml +++ /dev/null @@ -1,2 +0,0 @@ -policy: - allow_implicit_invocation: false diff --git a/skills/start-work/SKILL.md b/skills/start-work/SKILL.md index 2203fd1..ee8649f 100644 --- a/skills/start-work/SKILL.md +++ b/skills/start-work/SKILL.md @@ -11,8 +11,8 @@ that need the human. ## 1. Check the backend -Run `agent-workflows doctor` from the repository. If the command is missing, -treat the backend as absent. Read the JSON result. +Run `agent-workflows doctor --json` from the repository. If the command is +missing, treat the backend as absent. Read the JSON result. When doctor passes the `configuration` and `project` checks, record its `configPath` and `project`. Pass `--config CONFIG_PATH` before the subcommand @@ -25,10 +25,11 @@ one elsewhere, rerun doctor with `--config PATH`. | --- | --- | | `protocols.delegated: true` | The project uses agent-workflows SDLC. Its policy is authoritative. | | `sdlc` check fails, or no configuration | No SDLC policy applies. Track A still works inline. | -| Any other failing check | Delegated work is unavailable until it is fixed. Report the failing check and suggest the `setup-agent-workflows` skill. | +| Any other failing check | Delegated work is unavailable until it is fixed. Report the failing check and its fix. | A `worker` warning only means admitted work waits until `agent-workflows run` -starts. +starts. When delegated work is unavailable and the task needs Track B or C, +offer to [set it up](references/setup.md) before continuing. ## 2. Resume or resolve the task @@ -61,7 +62,7 @@ choose the higher track. | --- | --- | | A | [Fast path](references/fast-path.md): this session implements, verifies, reviews and opens the change request. No Work Item, database or worker. | | B or C, delegated available | [Delegated](references/delegated.md): record the Work Item, wait for recorded human approval, and let the runner execute. | -| B or C, no SDLC policy | Present the contract. Ask the human to choose: set up the backend with `setup-agent-workflows`, or approve the scope here and continue inline with the fast-path steps. Never choose for them. Record the approved scope in the change request. | +| B or C, no SDLC policy | Present the contract. Ask the human to choose: [set up the backend](references/setup.md), or approve the scope here and continue inline with the fast-path steps. Never choose for them. Record the approved scope in the change request. | When SDLC is enabled, Track B and C work must use the delegated protocol. diff --git a/skills/start-work/references/setup.md b/skills/start-work/references/setup.md new file mode 100644 index 0000000..cbe9332 --- /dev/null +++ b/skills/start-work/references/setup.md @@ -0,0 +1,54 @@ +# Set up delegated work + +Use this when `doctor` reports a failing check and the human wants Track B or C +work run by the worker. Track A never needs it. Change nothing before the human +agrees, ask one question at a time, lead with the recommended answer, and never +overwrite an existing configuration. + +## 1. Explore (read only) + +- `agent-workflows version`. If the command is missing, offer to install it: + `npm install -g @mingchuno/agent-workflows`. +- `agent-workflows doctor --json` from the repository. Its `configPath` shows + whether a configuration exists, including one in `$AGENT_WORKFLOWS_HOME`. +- PostgreSQL: a URL the human already uses, `pg_isready`, or Docker. + +## 2. Write the configuration + +When doctor found no configuration, run `agent-workflows init` in the repository +root. It infers the project, host and repository from `origin`, the base branch, +the agent runtime and the validation command, and enables SDLC with +`local-operator` approval. Show the human its summary and ask what to change. + +- **Location.** The repository root suits one repository: commit the file if the + team shares the policy, otherwise add it to `.git/info/exclude`. For several + repositories, pass `--config ~/.config/agent-workflows/NAME.json`; the CLI + finds the file whose project manages the current checkout. Before adding a + second runner, check the collision conditions in the + [operations guide](https://github.com/mingchuno/agent-workflows/blob/main/docs/operations.md#several-runners-on-one-database). +- **Approval.** Keep `local-operator` for personal projects; the human approves + in the monitor. Team projects use `github` or `gitlab` with an `approvers` + allowlist. +- **Policy.** Add `sdlc.riskRules` for paths that always need Track C, such as + migrations or authentication. + +## 3. Credentials and database + +- **Hosting.** With `local-operator` approval, the CLI reuses the `gh` or `glab` + sign-in when the token variable is unset. Otherwise ask the human to set the + variable named by `hosting.tokenEnv`, or to opt in with + `hosting.tokenFromCli: true`. +- **Database.** Offer the human's server or a local container such as + `docker run -d --name agent-workflows-db -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:17`. + A Homebrew server on macOS needs `LC_ALL` set, for example + `LC_ALL=en_US.UTF-8`. +- **Secrets.** Put the database URL and any token in an environment file outside + the checkout with mode 600 and reference it with `envFile`. Never write a + secret into the configuration or the conversation. + +## 4. Verify + +Rerun `agent-workflows doctor` until every check passes or only `worker` warns, +and fix what each failing check names. Then tell the human the two commands they +need: `agent-workflows run` in its own terminal, and `agent-workflows monitor` +(or the web link `run` prints) to approve work. Continue the original task. diff --git a/skills/start-work/references/work-api.md b/skills/start-work/references/work-api.md index 3231be7..b9f4590 100644 --- a/skills/start-work/references/work-api.md +++ b/skills/start-work/references/work-api.md @@ -10,7 +10,7 @@ configuration. Work commands print JSON. Discover options with `agent-workflows work --help`. ```sh -agent-workflows doctor +agent-workflows doctor --json agent-workflows --config CONFIG work classify --project app --file /tmp/work-contract.json agent-workflows --config CONFIG work classify --project app --file /tmp/work-contract.json --changed-from BASE agent-workflows --config CONFIG work list --project app diff --git a/tests/behavior.test.ts b/tests/behavior.test.ts index ab64b75..159a2f1 100644 --- a/tests/behavior.test.ts +++ b/tests/behavior.test.ts @@ -517,6 +517,13 @@ test("failed work cannot retry until files are preserved and checkout made clean await runner.start(); await waitFor(() => terminal(runner, 1)); const original = (await runner.store.runs())[0]!; + // The outcome is recorded just before the run releases its controller. + await waitFor(async () => + runner.retry(original.id).then( + () => assert.fail("retry admitted an unpreserved checkout"), + (error: Error) => error.message !== "Work has not stopped", + ), + ); await assert.rejects(runner.retry(original.id), /clean/); await git("add", "."); await git("commit", "-m", "preserve unfinished fixture"); diff --git a/tests/work-cli.test.ts b/tests/work-cli.test.ts index 7b8b915..c382799 100644 --- a/tests/work-cli.test.ts +++ b/tests/work-cli.test.ts @@ -263,13 +263,13 @@ test("work classify routes Track A inline and escalates an oversized diff withou test("doctor reports missing setup and still allows the inline protocol", async () => { const outside = await mkdtemp(join(tmpdir(), "aw-doctor-")); const { cli } = await cliFixture({ enabled: true }); - const result = await cli( - outside, - { - AGENT_WORKFLOWS_CONFIG: join(outside, "missing.json"), - }, - "doctor", - ); + const env = { AGENT_WORKFLOWS_CONFIG: join(outside, "missing.json") }; + const readable = await cli(outside, env, "doctor"); + assert.equal(readable.exitCode, 1); + assert.match(readable.stdout, /✗ configuration/); + assert.match(readable.stdout, /agent-workflows init/); + assert.match(readable.stdout, /Track A work runs inline without setup/); + const result = await cli(outside, env, "doctor", "--json"); assert.equal(result.exitCode, 1); const report = JSON.parse(result.stdout); assert.equal(report.ok, false); @@ -281,15 +281,34 @@ test("doctor reports missing setup and still allows the inline protocol", async test("doctor resolves the project from the working directory and checks the database", { skip: !process.env.TEST_DATABASE_URL, }, async () => { - const { root, cli } = await cliFixture({ enabled: true }); + const authorizations: string[] = []; + const server = createServer((request, response) => { + authorizations.push(String(request.headers.authorization)); + response.setHeader("content-type", "application/json"); + if (request.url === "/api/v3/repos/fixture/fixture") + return response.end(JSON.stringify({ full_name: "fixture/fixture" })); + response.statusCode = 404; + response.end("{}"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + const { root, cli } = await cliFixture( + { enabled: true }, + `http://127.0.0.1:${port}`, + ); const result = await cli( root, { AGENT_WORKFLOWS_DATABASE_URL: process.env.TEST_DATABASE_URL, FIXTURE_HOST_TOKEN: "fixture-token", + CODEX_HOME: await mkdtemp(join(tmpdir(), "aw-codex-")), + CODEX_API_KEY: "", + OPENAI_API_KEY: "", }, "doctor", - ); + "--json", + ).finally(() => server.close()); assert.equal(result.exitCode, 0, result.stdout + result.stderr); const report = JSON.parse(result.stdout); assert.equal(report.project, "app"); @@ -304,12 +323,17 @@ test("doctor resolves the project from the working directory and checks the data { configuration: "pass", project: "pass", + repository: "pass", sdlc: "pass", "hosting-credentials": "pass", + "hosting-access": "pass", + agent: "warn", + validation: "warn", database: "pass", worker: "warn", }, ); + assert.deepEqual(authorizations, ["Bearer fixture-token"]); assert.doesNotMatch(result.stdout, /fixture-token/); }); @@ -350,6 +374,7 @@ test("doctor discovers the configuration that manages a linked worktree", async import.meta.resolve("tsx"), fileURLToPath(new URL("../packages/cli/src/cli.ts", import.meta.url)), "doctor", + "--json", ], { cwd: worktree, @@ -483,3 +508,132 @@ test("work publish creates one GitHub issue for local work and rebinds it", { await once(server, "close"); } }); + +const cliPath = fileURLToPath( + new URL("../packages/cli/src/cli.ts", import.meta.url), +); +const runCli = (cwd: string, env: NodeJS.ProcessEnv, ...args: string[]) => + command( + process.execPath, + ["--import", import.meta.resolve("tsx"), cliPath, ...args], + { + cwd, + env: { ...process.env, AGENT_WORKFLOWS_CONFIG: "", ...env }, + timeoutMs: 20000, + allowFailure: true, + }, + ); + +test("init infers the project, host, agent and validation from the repository", async () => { + const { root, git } = await repository(); + await git( + "remote", + "set-url", + "origin", + "git@gitlab.example.com:team/sub/app.git", + ); + await writeFile( + join(root, "package.json"), + JSON.stringify({ scripts: { test: "node --test", verify: "pnpm test" } }), + ); + await writeFile(join(root, "pnpm-lock.yaml"), ""); + const result = await runCli( + root, + {}, + "--config", + join(root, "agent-workflows.json"), + "init", + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /gitlab team\/sub\/app/); + assert.match(result.stdout, /Next: agent-workflows doctor/); + const { readFile } = await import("node:fs/promises"); + const written = JSON.parse( + await readFile(join(root, "agent-workflows.json"), "utf8"), + ); + const project = written.projects[0]; + assert.equal(project.id, basename(root).replace(/[^a-zA-Z0-9_-]+/g, "-")); + assert.equal(project.checkout, "."); + assert.deepEqual(project.hosting, { + provider: "gitlab", + origin: "https://gitlab.example.com", + repository: "team/sub/app", + tokenEnv: "GITLAB_TOKEN", + }); + assert.deepEqual(project.sdlc, { + enabled: true, + approvalSource: "local-operator", + }); + assert.deepEqual(project.validation, [ + { command: "pnpm", args: ["run", "verify"] }, + ]); +}); + +test("init marks an unknown remote as a placeholder that doctor rejects", async () => { + const { root, git } = await repository(); + await git("remote", "remove", "origin"); + const config = join(root, "agent-workflows.json"); + const init = await runCli(root, {}, "--config", config, "init"); + assert.equal(init.exitCode, 0, init.stderr); + assert.match(init.stdout, /Replace placeholder: hosting.repository/); + const report = JSON.parse( + ( + await runCli( + root, + { AGENT_WORKFLOWS_DATABASE_URL: "" }, + "--config", + config, + "doctor", + "--json", + ) + ).stdout, + ); + const repositoryCheck = report.checks.find( + (item: { name: string }) => item.name === "repository", + ); + assert.equal(repositoryCheck.status, "fail"); + assert.match(repositoryCheck.detail, /placeholder/); +}); + +test("a missing hosting token falls back to the gh sign-in only for local approval", async () => { + const bin = await mkdtemp(join(tmpdir(), "aw-fake-gh-")); + const calls = join(bin, "calls"); + await writeFile( + join(bin, "gh"), + `#!/bin/sh\necho "$@" >> "${calls}"\necho cli-token\n`, + { mode: 0o755 }, + ); + const env = { + PATH: `${bin}:${process.env.PATH}`, + AGENT_WORKFLOWS_DATABASE_URL: "", + FIXTURE_HOST_TOKEN: "", + }; + const local = await cliFixture({ enabled: true }, "http://127.0.0.1:9"); + const report = JSON.parse( + (await local.cli(local.root, env, "doctor", "--json")).stdout, + ); + const credentials = report.checks.find( + (item: { name: string }) => item.name === "hosting-credentials", + ); + assert.equal(credentials.status, "pass"); + assert.equal(credentials.detail, "Using the gh sign-in"); + assert.doesNotMatch(JSON.stringify(report), /cli-token/); + const { readFile } = await import("node:fs/promises"); + assert.match( + await readFile(calls, "utf8"), + /^auth token --hostname 127\.0\.0\.1:9$/m, + ); + + const team = await cliFixture( + { enabled: true, approvalSource: "github", approvers: ["lead"] }, + "http://127.0.0.1:9", + ); + const refused = JSON.parse( + (await team.cli(team.root, env, "doctor", "--json")).stdout, + ); + const missing = refused.checks.find( + (item: { name: string }) => item.name === "hosting-credentials", + ); + assert.equal(missing.status, "fail"); + assert.match(missing.detail, /hosting.tokenFromCli/); +}); diff --git a/tests/work-skills.test.ts b/tests/work-skills.test.ts index 8a31a7b..826f2d4 100644 --- a/tests/work-skills.test.ts +++ b/tests/work-skills.test.ts @@ -15,7 +15,7 @@ import { } from "../packages/core/src/work/schema.js"; const root = fileURLToPath(new URL("../", import.meta.url)); -const skillNames = ["start-work", "setup-agent-workflows"]; +const skillNames = ["start-work"]; async function markdownFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); @@ -57,16 +57,14 @@ test("skills have discoverable metadata and resolvable local references", async } }); -test("setup is explicit-only in every supported host", async () => { - const directory = resolve(root, "skills/setup-agent-workflows"); - assert.match( - await readFile(resolve(directory, "SKILL.md"), "utf8"), - /^disable-model-invocation: true$/m, - ); - assert.match( - await readFile(resolve(directory, "agents/openai.yaml"), "utf8"), - /allow_implicit_invocation: false/, +test("start-work is the only installable skill and carries setup", async () => { + assert.deepEqual(await readdir(resolve(root, "skills")), skillNames); + const skill = await readFile( + resolve(root, "skills/start-work/SKILL.md"), + "utf8", ); + assert.match(skill, /\(references\/setup\.md\)/); + assert.match(skill, /doctor --json/); }); test("the intake API example is a real standard-track contract with revision-bound approval", async () => {