diff --git a/README.md b/README.md index 8440602..7b1929b 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,26 @@ night-watch run --- +## Agent / Machine-Readable CLI + +External agents and automation should use the JSON-first manageability commands instead of scraping human output: + +```bash +night-watch agent status --json +night-watch health --json +night-watch config list --json +night-watch config get reviewerEnabled --json +night-watch config set reviewerEnabled false --json +night-watch job pause reviewer --json +night-watch job resume reviewer --json +``` + +JSON modes write one JSON document to stdout on success and reserve stderr for errors. `night-watch status --json` keeps its legacy shape for backward compatibility; `night-watch agent status --json` adds a stable wrapper with `schemaVersion`, `status`, `paused`, `queue`, `board`, `health`, and `lastRuns`. + +Paused jobs are stored in `night-watch.config.json` under `pausedJobs`. Cron scripts and queue claim/dispatch paths read this state before starting work, so `night-watch job pause --json` prevents new cron/queue-dispatched runs for that job until resumed. + +--- + ## Who It's For Night Watch is strongest when: diff --git a/SELF_REVIEW.md b/SELF_REVIEW.md new file mode 100644 index 0000000..6e5edea --- /dev/null +++ b/SELF_REVIEW.md @@ -0,0 +1,24 @@ +# Self Review: Agent Manageability CLI + +## Implementation + +- Added `night-watch agent status --json` with `schemaVersion`, legacy status data, pause state, queue state, board summary, health checks, and recent run timestamps. +- Added `night-watch config list/get/set --json` with dot-path access, simple value parsing, config reload validation, and rollback on invalid persisted values. +- Added `night-watch health --json` with lightweight automation readiness checks and JSON-only stdout. +- Added `night-watch job pause/resume --json`, persisted pause state under `pausedJobs`, and wired cron/queue entry points to skip paused jobs before starting new work. +- Documented the machine-readable CLI contract in `README.md` and `docs/reference/commands.md`; committed the PRD at `docs/prds/agent-manageability-cli.md`. + +## Tests Run + +- `yarn workspace @jonit-dev/night-watch-cli test src/__tests__/commands/agent.test.ts` +- `yarn workspace @jonit-dev/night-watch-cli test src/__tests__/commands/agent.test.ts src/__tests__/commands/status.test.ts src/__tests__/commands/queue.test.ts` +- `bash -n scripts/night-watch-cron.sh scripts/night-watch-pr-reviewer-cron.sh scripts/night-watch-qa-cron.sh scripts/night-watch-audit-cron.sh scripts/night-watch-slicer-cron.sh scripts/night-watch-plan-cron.sh scripts/night-watch-merger-cron.sh scripts/night-watch-pr-resolver-cron.sh scripts/night-watch-helpers.sh` +- `yarn lint` (passes with existing warnings) +- `yarn verify` +- `yarn workspace @jonit-dev/night-watch-cli build` + +## Known Limitations + +- `job pause` prevents new cron/queue-dispatched starts; it does not terminate a job that is already running. +- `agent status` includes board details only when a board is already configured. Provider/API errors are captured in `board.error` instead of failing the whole status snapshot. +- `lastRuns` is based on available job-run telemetry from the last 30 days; projects without telemetry return null timestamps. diff --git a/docs/prds/agent-manageability-cli.md b/docs/prds/agent-manageability-cli.md new file mode 100644 index 0000000..5498fab --- /dev/null +++ b/docs/prds/agent-manageability-cli.md @@ -0,0 +1,598 @@ +# PRD: Agent Manageability CLI + +**Complexity: 8 -> HARD mode** + +--- + +## 1. Context + +**Problem:** Night Watch can run autonomously, but an external AI agent such as Hermes cannot fully operate it through a stable, machine-readable CLI contract. Status, logs, job triggers, queue state, board movement, config edits, and health checks are spread across commands with mixed human and JSON output. This forces agents to scrape terminal output, infer process state from files, and mutate config manually. + +**Files Analyzed:** + +- `packages/cli/src/commands/status.ts` - current project status command and legacy JSON shape +- `packages/core/src/utils/status-data.ts` - shared status snapshot source for CLI and dashboard +- `packages/cli/src/commands/logs.ts` - log tailing and static log output +- `packages/cli/src/commands/queue.ts` - global queue status, enqueue, dispatch, claim, and completion commands +- `packages/cli/src/commands/board.ts` - board status, PRD creation, next issue, move issue, and comments +- `packages/cli/src/commands/run.ts` - executor command entry point +- `packages/cli/src/commands/review.ts` - reviewer command entry point +- `packages/cli/src/commands/qa.ts` - QA command entry point +- `packages/cli/src/commands/audit.ts` - audit command entry point +- `packages/cli/src/commands/plan.ts` - planner command entry point +- `packages/cli/src/commands/analytics.ts` - analytics command entry point +- `packages/cli/src/commands/merge.ts` - merger command entry point +- `packages/core/src/utils/config-writer.ts` - existing safe config writer utility +- `packages/core/src/types.ts` - job types, config, status, and board-facing types + +**Current Behavior:** + +- `night-watch status --json` returns project, process, PRD, PR, crontab, and log data, but does not include queue details, board items, last successful job timestamps, or agent availability checks. +- `night-watch logs --type -f` can stream one job log, but there is no JSON metadata mode for log paths or a stable "job" namespace. +- Specific jobs can be started through existing commands (`run`, `review`, `qa`, `audit`, `plan`, `analytics`, `merge`), but there is no uniform `job run ` command that an agent can call safely. +- There is no CLI-level pause/resume mechanism per job; users must edit cron/config or uninstall schedules. +- There is no first-class non-interactive `config get/set/list` command for agents to read and write config. +- Board commands can list and move issues, but agent workflows need clear JSON output guarantees and predictable exit codes. +- `doctor` checks setup, but there is no lightweight health command focused on automation readiness: cron installed, last success, stale locks, queue availability, provider binary/API availability. + +**Integration Points Checklist:** + +```markdown +**How will this feature be reached?** + +- [ ] Entry point: new `night-watch agent ` command group +- [ ] Entry point: new `night-watch job ` command group +- [ ] Entry point: new `night-watch config ` command group +- [ ] Entry point: new `night-watch health --json` command +- [ ] Caller: external AI agents such as Hermes, shell scripts, dashboard automation +- [ ] Registration: wire commands into `packages/cli/src/cli.ts` + +**Is this user-facing?** + +- [ ] YES -> documented CLI commands and JSON schemas +- [ ] YES -> status output remains backward compatible +- [ ] YES -> pause/resume affects cron-dispatched jobs + +**Full user flow:** + +1. Hermes runs `night-watch agent status --json` +2. CLI returns a complete machine-readable snapshot of jobs, queues, PRs, board items, config flags, crontab, logs, and health +3. Hermes sees `executor.running=false` and `prds.pending=3` +4. Hermes runs `night-watch job run executor --json` +5. CLI starts the executor immediately and returns `{ "started": true, "job": "executor", "pid": 12345 }` +6. Hermes streams `night-watch job logs executor --follow` +7. Hermes pauses reviewer with `night-watch job pause reviewer --json` while a human investigates +8. Hermes moves board item #42 with `night-watch board move-issue 42 --column "Review" --json` +9. Hermes runs `night-watch health --json` to confirm cron, provider, queue, and locks are healthy +``` + +--- + +## 2. Solution + +**Approach:** + +- Add an agent-grade CLI layer that wraps existing Night Watch primitives with stable JSON schemas, consistent job names, predictable exit codes, and non-interactive behavior. +- Keep existing human-facing commands intact. `status --json`, `logs`, `queue`, and `board` continue to work, while new commands reuse the same core utilities. +- Extend the shared status data layer to include queue state, board summaries, last job success/failure data, pause state, and health checks. +- Store pause/resume state in config so cron scripts can skip paused jobs deterministically without editing crontab entries. +- Add a safe config CLI that supports dot-path reads/writes with validation through existing config load/save code. +- Add health checks that are cheap enough for agents to poll frequently. + +**Target Commands:** + +| Command | Purpose | +| -------------------------------------------------------------- | ------------------------------------------------------------------- | --- | ----- | ------- | --------- | --------------- | ---------------------------------- | +| `night-watch agent status --json` | Full machine-readable project snapshot for external agents | +| `night-watch job run --json` | Trigger a specific job immediately | +| `night-watch job pause --json` | Pause cron/queue dispatch for a specific job | +| `night-watch job resume --json` | Resume cron/queue dispatch for a specific job | +| `night-watch config list --json` | Print resolved config | +| `night-watch config get --json` | Read a config value by dot path | +| `night-watch config set --json` | Write a config value by dot path with validation | +| `night-watch board status --json` | List board items with status | +| `night-watch board move-issue --column --json` | Move items between columns | +| `night-watch job logs --follow` | Stream a specific job log | +| `night-watch health --json` | Report cron, locks, queue, provider, board, and last success health | + +**Status JSON Contract:** + +```json +{ + "projectName": "joao", + "projectDir": "/home/joao", + "provider": "claude", + "reviewerEnabled": true, + "autoMerge": false, + "autoMergeMethod": "squash", + "executor": { + "running": false, + "pid": null + }, + "reviewer": { + "running": false, + "pid": null + }, + "qa": { + "running": false, + "pid": null + }, + "audit": { + "running": false, + "pid": null + }, + "planner": { + "running": false, + "pid": null + }, + "analytics": { + "running": false, + "pid": null + }, + "merger": { + "running": false, + "pid": null + }, + "prds": { + "pending": 0, + "claimed": 0, + "done": 0 + }, + "prs": { + "open": 0 + }, + "crontab": { + "installed": false, + "entries": [] + }, + "logs": { + "executor": { + "path": "/home/joao/logs/executor.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "reviewer": { + "path": "/home/joao/logs/reviewer.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "qa": { + "path": "/home/joao/logs/night-watch-qa.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "audit": { + "path": "/home/joao/logs/audit.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "planner": { + "path": "/home/joao/logs/slicer.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "analytics": { + "path": "/home/joao/logs/analytics.log", + "lastLines": [], + "exists": false, + "size": 0 + }, + "merger": { + "path": "/home/joao/logs/merger.log", + "lastLines": [], + "exists": false, + "size": 0 + } + } +} +``` + +**Extended Agent Status Additions:** + +```json +{ + "schemaVersion": 1, + "paused": { + "executor": false, + "reviewer": false, + "qa": false, + "audit": false, + "planner": false, + "analytics": false, + "merger": false + }, + "queue": { + "enabled": true, + "running": null, + "pending": { + "total": 0, + "byType": {} + }, + "items": [] + }, + "board": { + "configured": false, + "columns": [], + "items": [] + }, + "health": { + "ok": true, + "checks": [] + }, + "lastRuns": { + "executor": { + "lastSuccessAt": null, + "lastFailureAt": null, + "lastExitCode": null + } + } +} +``` + +**Architecture Diagram:** + +```mermaid +flowchart TD + A[Hermes / External Agent] --> B[night-watch agent status --json] + A --> C[night-watch job run/pause/resume/logs] + A --> D[night-watch config get/set/list] + A --> E[night-watch board status/move-issue] + A --> F[night-watch health --json] + + B --> G[status-data.ts] + G --> H[Lock Files] + G --> I[Queue SQLite] + G --> J[GitHub Board Provider] + G --> K[Crontab Utils] + G --> L[Logs Directory] + + C --> M[Existing Job Commands / Cron Scripts] + D --> N[loadConfig + saveConfig] + F --> G +``` + +**Key Decisions:** + +- Use `agent status` for the complete external-agent contract instead of expanding every legacy status field immediately. +- Keep `night-watch status --json` backward compatible; it may call the same builder but should not remove fields or rename keys. +- Use canonical job names: `executor`, `reviewer`, `qa`, `audit`, `planner`, `analytics`, `merger`. Accept aliases (`run`, `review`, `slicer`, `slice`, `merge`) but normalize output. +- Store pause state in config under `jobs..paused` or an equivalent typed config object, then have cron scripts and `job run` respect it unless `--force` is passed. +- Return JSON on stdout only for `--json`; warnings and diagnostics go to stderr. +- Exit code `0` means the requested operation succeeded. Exit code `1` means a user/actionable failure. Exit code `2` means validation or usage error. Exit code `3` means dependency unavailable. Exit code `4` means operation refused because the job is paused or already running. +- Redact secret-looking config values in `config list --json` unless `--include-secrets` is passed. + +**Data Changes:** + +- Add pause state to `INightWatchConfig`. +- Reuse existing queue SQLite tables for queue status. +- Reuse existing execution history table if available for last success/failure timestamps; otherwise add a small job run history helper shared by cron result parsing and health checks. + +--- + +## 3. Sequence Flow + +```mermaid +sequenceDiagram + participant Agent as Hermes + participant CLI as Night Watch CLI + participant Core as Core Status/Config + participant Cron as Job Script + participant Board as Board Provider + participant Logs as Log Files + + Agent->>CLI: night-watch agent status --json + CLI->>Core: fetchAgentStatusSnapshot(projectDir, config) + Core->>Core: read locks, queue, crontab, health, last runs + Core->>Board: get board items + Core->>Logs: tail job logs + CLI-->>Agent: JSON status snapshot + + Agent->>CLI: night-watch job pause reviewer --json + CLI->>Core: save config pause state + CLI-->>Agent: {"job":"reviewer","paused":true} + + Agent->>CLI: night-watch job run executor --json + CLI->>Core: validate job, config, locks, provider + CLI->>Cron: spawn executor script + Cron-->>CLI: pid + CLI-->>Agent: {"job":"executor","started":true,"pid":12345} + + Agent->>CLI: night-watch job logs executor --follow + CLI->>Logs: tail -f logs/executor.log + Logs-->>Agent: stream lines + + Agent->>CLI: night-watch board move-issue 42 --column Review --json + CLI->>Board: move issue + Board-->>CLI: updated issue + CLI-->>Agent: JSON issue + + Agent->>CLI: night-watch health --json + CLI->>Core: run health checks + CLI-->>Agent: {"ok":true,"checks":[...]} +``` + +--- + +## 4. Execution Phases + +### Phase 1: Agent Status Snapshot + +**User-visible outcome:** `night-watch agent status --json` returns one complete JSON document for external agents. + +**Files (5):** + +- `packages/core/src/utils/status-data.ts` - add `fetchAgentStatusSnapshot()` +- `packages/core/src/types.ts` - add agent status JSON types +- `packages/cli/src/commands/agent.ts` - add `agent status` +- `packages/cli/src/cli.ts` - register the agent command group +- `packages/cli/src/__tests__/commands/agent.test.ts` - tests for status JSON + +**Implementation:** + +- [ ] Define `IAgentStatusSnapshot` with `schemaVersion`, legacy status fields, `queue`, `board`, `paused`, `health`, and `lastRuns` +- [ ] Reuse `fetchStatusSnapshot()` for processes, PRDs, PRs, crontab, and logs +- [ ] Add queue summary from `getQueueStatus()` +- [ ] Add board summary using the configured board provider; if unavailable, return `configured: false` and a health warning +- [ ] Add `--lines ` option for log tail size, defaulting to existing status behavior +- [ ] Ensure `--json` prints JSON only to stdout + +**Tests Required:** + +| Test File | Test Name | Assertion | +| --------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- | +| `packages/cli/src/__tests__/commands/agent.test.ts` | `agent status outputs schema version` | JSON contains `schemaVersion: 1` | +| `packages/cli/src/__tests__/commands/agent.test.ts` | `agent status includes queue and board fields` | JSON has `queue.pending.total` and `board.configured` | +| `packages/cli/src/__tests__/commands/agent.test.ts` | `agent status preserves legacy process shape` | JSON has `executor.running` and `reviewer.pid` | + +**Verification Plan:** + +1. `night-watch agent status --json | jq .schemaVersion` returns `1` +2. `night-watch agent status --json | jq .queue.pending.total` returns a number +3. `yarn verify` passes + +--- + +### Phase 2: Job Control Commands + +**User-visible outcome:** Agents can trigger, pause, resume, and inspect jobs through one stable command namespace. + +**Files (6):** + +- `packages/cli/src/commands/job.ts` - add `job run`, `job pause`, `job resume`, `job logs`, `job status` +- `packages/cli/src/cli.ts` - register job command group +- `packages/core/src/types.ts` - add pause config type +- `packages/core/src/config/config.ts` - load pause defaults +- `packages/core/src/utils/config-writer.ts` - support pause updates if needed +- `packages/cli/src/__tests__/commands/job.test.ts` - command tests + +**Implementation:** + +- [ ] Add canonical job validation and alias normalization +- [ ] Implement `night-watch job run --json` by dispatching the matching existing command/script +- [ ] Add `--force` to run paused jobs intentionally +- [ ] Refuse to start a job when its lock indicates a live process unless `--force` is passed +- [ ] Implement `job pause ` by writing pause state to config +- [ ] Implement `job resume ` by clearing pause state +- [ ] Implement `job status --json` as a filtered view of `agent status` +- [ ] Implement `job logs --lines --follow` as a stable wrapper around existing log paths + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ------------------------------------------------- | -------------------------------------- | ------------------------------------ | +| `packages/cli/src/__tests__/commands/job.test.ts` | `job run validates job type` | invalid job exits with usage error | +| `packages/cli/src/__tests__/commands/job.test.ts` | `job pause writes config state` | config contains paused reviewer | +| `packages/cli/src/__tests__/commands/job.test.ts` | `job resume clears config state` | config contains resumed reviewer | +| `packages/cli/src/__tests__/commands/job.test.ts` | `job logs resolves canonical log path` | executor maps to `logs/executor.log` | + +**Verification Plan:** + +1. `night-watch job pause reviewer --json` returns `{ "job": "reviewer", "paused": true }` +2. `night-watch job resume reviewer --json` returns `{ "job": "reviewer", "paused": false }` +3. `night-watch job run executor --dry-run --json` returns a start plan without spawning +4. `yarn verify` passes + +--- + +### Phase 3: Cron Pause Enforcement + +**User-visible outcome:** Paused jobs do not run from cron or queue dispatch, and status explains why. + +**Files (8):** + +- `scripts/night-watch-run-cron.sh` - skip when executor is paused +- `scripts/night-watch-pr-reviewer-cron.sh` - skip when reviewer is paused +- `scripts/night-watch-qa-cron.sh` - skip when QA is paused +- `scripts/night-watch-audit-cron.sh` - skip when audit is paused +- `scripts/night-watch-plan-cron.sh` - skip when planner is paused +- `scripts/night-watch-analytics-cron.sh` - skip when analytics is paused +- `scripts/night-watch-merger-cron.sh` - skip when merger is paused +- `packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts` - pause smoke tests + +**Implementation:** + +- [ ] Add a shared helper in `scripts/night-watch-helpers.sh` to read job pause state through the CLI or config file +- [ ] Emit `NIGHT_WATCH_RESULT:skipped_paused|job=` when a paused cron run exits +- [ ] Ensure queue claim/dispatch does not mark paused jobs as running +- [ ] Add `--force` bypass for manual `job run` +- [ ] Include pause state in `agent status` and `health` + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ------------------------------------------------------------ | ------------------------------------- | ------------------------------- | +| `packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts` | `reviewer cron skips when paused` | log contains `skipped_paused` | +| `packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts` | `executor cron skips when paused` | no provider command is invoked | +| `packages/cli/src/__tests__/commands/job.test.ts` | `job run force bypasses paused state` | command proceeds with `--force` | + +**Verification Plan:** + +1. Pause reviewer, run reviewer cron manually, confirm it exits 0 with skipped result +2. Resume reviewer, run reviewer cron manually, confirm normal execution path +3. `yarn verify` passes + +--- + +### Phase 4: Config CLI + +**User-visible outcome:** Agents can safely read and write Night Watch config without opening files. + +**Files (5):** + +- `packages/cli/src/commands/config.ts` - add config command group +- `packages/cli/src/cli.ts` - register config command +- `packages/core/src/utils/config-paths.ts` - dot-path get/set helpers +- `packages/core/src/utils/config-writer.ts` - validate and persist patched config +- `packages/cli/src/__tests__/commands/config.test.ts` - command tests + +**Implementation:** + +- [ ] Implement `night-watch config list --json` +- [ ] Implement `night-watch config get --json` +- [ ] Implement `night-watch config set --json` +- [ ] Parse booleans, numbers, null, arrays, and objects from JSON values when possible +- [ ] Redact secrets by default for paths matching `token`, `secret`, `key`, `password`, or provider env values +- [ ] Add `--include-secrets` for explicit unredacted output +- [ ] Reject unknown paths unless `--allow-new` is passed +- [ ] Validate by reloading config after writes + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ---------------------------------------------------- | ---------------------------------------- | ------------------------------------------------- | +| `packages/cli/src/__tests__/commands/config.test.ts` | `config get reads dot path` | `provider` returns configured provider | +| `packages/cli/src/__tests__/commands/config.test.ts` | `config set writes boolean` | `reviewerEnabled` updates to false | +| `packages/cli/src/__tests__/commands/config.test.ts` | `config list redacts secrets by default` | secret-like values are replaced with `[redacted]` | + +**Verification Plan:** + +1. `night-watch config get provider --json` returns a JSON value +2. `night-watch config set reviewerEnabled false --json` persists and reloads +3. `night-watch config list --json` produces valid JSON +4. `yarn verify` passes + +--- + +### Phase 5: Board JSON Guarantees + +**User-visible outcome:** Board listing and movement commands are safe for agents to consume programmatically. + +**Files (3):** + +- `packages/cli/src/commands/board.ts` - add/normalize JSON output for status and move commands +- `packages/core/src/board/types.ts` - document board item JSON fields if needed +- `packages/cli/src/__tests__/commands/board.test.ts` - JSON contract tests + +**Implementation:** + +- [ ] Ensure `board status --json` returns an array of board items with stable fields: `number`, `title`, `column`, `status`, `priority`, `labels`, `url` +- [ ] Add `--json` to `board move-issue`, `board comment`, `board create-prd`, and `board next-issue` if missing +- [ ] Make board command errors JSON-formatted when `--json` is passed +- [ ] Preserve existing human output when `--json` is absent + +**Tests Required:** + +| Test File | Test Name | Assertion | +| --------------------------------------------------- | ------------------------------------------ | ------------------------------------------- | +| `packages/cli/src/__tests__/commands/board.test.ts` | `board status json has stable item fields` | item contains `number`, `column`, and `url` | +| `packages/cli/src/__tests__/commands/board.test.ts` | `move issue json returns updated issue` | JSON contains new column | +| `packages/cli/src/__tests__/commands/board.test.ts` | `create prd json returns issue metadata` | JSON contains issue number and URL | + +**Verification Plan:** + +1. `night-watch board status --json | jq '.[0].column'` works when board has items +2. `night-watch board move-issue 42 --column Review --json` returns updated metadata +3. `yarn verify` passes + +--- + +### Phase 6: Health Command + +**User-visible outcome:** Agents can run one command to decide whether Night Watch is operational. + +**Files (4):** + +- `packages/cli/src/commands/health.ts` - add health command +- `packages/core/src/utils/health.ts` - reusable health checks +- `packages/cli/src/cli.ts` - register health command +- `packages/cli/src/__tests__/commands/health.test.ts` - health tests + +**Implementation:** + +- [ ] Check crontab installed and entries match current project +- [ ] Check stale lock files and live PIDs for each job +- [ ] Check logs directory is writable +- [ ] Check provider binary is available and provider env is minimally present +- [ ] Check global queue availability and stale queue entries +- [ ] Check board provider configuration and access +- [ ] Check last success/failure timestamps for each job +- [ ] Return `{ "ok": boolean, "checks": [...] }` with machine-readable `id`, `status`, `severity`, `message`, and `details` + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ---------------------------------------------------- | ------------------------------------- | ---------------------------------------- | +| `packages/cli/src/__tests__/commands/health.test.ts` | `health json includes check ids` | JSON contains `checks[].id` | +| `packages/cli/src/__tests__/commands/health.test.ts` | `health fails on stale lock` | `ok` is false and check status is `fail` | +| `packages/cli/src/__tests__/commands/health.test.ts` | `health warns when board unavailable` | board check has `warn` severity | + +**Verification Plan:** + +1. `night-watch health --json | jq .ok` returns a boolean +2. Stop or remove crontab entries and confirm health reports the issue +3. `yarn verify` passes + +--- + +### Phase 7: Documentation & Agent Contract + +**User-visible outcome:** External agents have a documented CLI contract with examples and exit codes. + +**Files (3):** + +- `docs/reference/commands.md` - document new commands +- `docs/integrations/agent-manageability.md` - document agent JSON schemas and workflows +- `templates/skills/_codex-block.md` - update Night Watch agent usage examples if needed + +**Implementation:** + +- [ ] Add command reference entries for `agent`, `job`, `config`, and `health` +- [ ] Document canonical job names and accepted aliases +- [ ] Document JSON schema versions and backward compatibility expectations +- [ ] Document exit codes +- [ ] Add example Hermes workflow for status -> run -> logs -> board move -> health + +**Tests Required:** + +| Test File | Test Name | Assertion | +| --------- | ------------------ | ------------- | +| N/A | Documentation-only | Manual review | + +**Verification Plan:** + +1. `docs/reference/commands.md` includes all new commands +2. Examples run successfully in a local initialized project +3. `yarn verify` passes + +--- + +## 5. Acceptance Criteria + +- [ ] `night-watch agent status --json` returns the legacy status fields plus schema version, queue, board, pause, health, and last-run data +- [ ] Status JSON includes all job process fields: executor, reviewer, QA, audit, planner, analytics, and merger +- [ ] Agents can trigger any supported job immediately with `night-watch job run --json` +- [ ] Agents can pause and resume individual jobs with CLI commands +- [ ] Paused jobs are skipped by cron/queue dispatch and reported clearly +- [ ] Agents can read and write config through `night-watch config get/set/list` +- [ ] Board items can be listed and moved with stable JSON output +- [ ] Job logs can be streamed with a stable job-name interface +- [ ] `night-watch health --json` reports cron status, last success, stale locks, queue availability, board access, and provider availability +- [ ] All new commands use predictable exit codes +- [ ] Existing human-facing CLI behavior remains backward compatible +- [ ] All specified tests pass +- [ ] `yarn verify` passes diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9b9c95f..745528b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -170,6 +170,63 @@ night-watch status --json # Output as JSON --- +## Agent / Machine-Readable CLI + +These commands are intended for external agents, shell automation, and dashboards that need stable JSON contracts. In JSON mode, successful commands write a single JSON document to stdout. Human-oriented progress text is suppressed; errors are written to stderr and use non-zero exit codes. + +`night-watch status --json` remains backward compatible. Use `night-watch agent status --json` for the expanded schema: + +```bash +night-watch agent status --json +``` + +Top-level fields: + +- `schemaVersion` — current schema version, currently `1` +- `generatedAt` — ISO timestamp for the snapshot +- `project` — project name, directory, and provider +- `status` — legacy status data from `night-watch status --json` +- `paused` — per-job pause state from config +- `queue` — global queue status, matching `night-watch queue status --json` +- `board` — board columns/items when a board is configured, otherwise empty arrays +- `health` — lightweight automation readiness checks +- `lastRuns` — recent success/failure timestamps when job run telemetry is available + +Config inspection and edits: + +```bash +night-watch config list --json +night-watch config get reviewerEnabled --json +night-watch config get queue.enabled --json +night-watch config set reviewerEnabled false --json +night-watch config set queue.maxConcurrency 2 --json +night-watch config set providerEnv '{"ANTHROPIC_BASE_URL":"https://example.test"}' --json +``` + +Config paths are dot paths into the resolved Night Watch config. `set` parses `true`, `false`, `null`, numbers, JSON objects/arrays/quoted strings, and otherwise stores the input as a string. Unknown paths fail predictably without mutating config. + +Health checks: + +```bash +night-watch health --json +``` + +The health payload contains `schemaVersion`, `ok`, and `checks[]`. It currently checks config load, cron installation, queue configuration, provider configuration, and stale lock indicators. + +Pause/resume: + +```bash +night-watch job pause executor --json +night-watch job resume executor --json +night-watch job pause reviewer --json +``` + +Pause state is stored in `night-watch.config.json` under `pausedJobs`. Cron scripts call `night-watch job is-paused ` before starting work, and queue claim/dispatch skips paused jobs. This prevents new cron/queue-dispatched runs; it does not kill an already running process. + +Supported job names are `executor`, `reviewer`, `qa`, `audit`, `slicer`, `planner`, `analytics`, `pr-resolver`, and `merger`. + +--- + ## `night-watch logs` View log output from executor and reviewer. diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts new file mode 100644 index 0000000..057bde6 --- /dev/null +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -0,0 +1,226 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Command } from 'commander'; + +let mockProjectDir: string; + +vi.mock('child_process', () => ({ + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const callback = typeof _opts === 'function' ? (_opts as typeof cb) : cb; + if (_cmd.includes('git rev-parse')) { + callback?.(new Error('not a git repo'), { stdout: '', stderr: '' }); + return; + } + callback?.(null, { stdout: '', stderr: '' }); + }, + ), + execFile: vi.fn(), + execSync: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('@night-watch/core/utils/crontab.js', () => ({ + getEntries: vi.fn(() => []), + getProjectEntries: vi.fn(() => []), + generateMarker: vi.fn((name: string) => `# night-watch-cli: ${name}`), +})); + +const originalCwd = process.cwd; +process.cwd = () => mockProjectDir; + +import { agentCommand, configCommand, healthCommand, jobCommand } from '@/cli/commands/agent.js'; + +function registerProgram(): Command { + const program = new Command(); + program.exitOverride(); + agentCommand(program); + configCommand(program); + healthCommand(program); + jobCommand(program); + return program; +} + +function writeConfig(projectDir: string, value: Record = {}): void { + fs.writeFileSync( + path.join(projectDir, 'night-watch.config.json'), + JSON.stringify( + { + projectName: 'test-project', + defaultBranch: 'main', + provider: 'claude', + reviewerEnabled: true, + prdDir: 'docs/PRDs/night-watch', + maxRuntime: 7200, + reviewerMaxRuntime: 3600, + queue: { enabled: true }, + ...value, + }, + null, + 2, + ), + ); +} + +describe('agent manageability commands', () => { + let tempDir: string; + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-agent-test-')); + mockProjectDir = tempDir; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' })); + writeConfig(tempDir); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + process.exitCode = undefined; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + process.cwd = originalCwd; + }); + + it('prints agent status as one JSON stdout payload', async () => { + const program = registerProgram(); + await program.parseAsync(['node', 'test', 'agent', 'status', '--json']); + + expect(stdoutSpy).toHaveBeenCalledTimes(1); + expect(stderrSpy).not.toHaveBeenCalled(); + const payload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(payload.schemaVersion).toBe(1); + expect(payload.status.projectName).toBe('test-project'); + expect(payload.paused.executor).toBe(false); + expect(payload.queue).toHaveProperty('pending'); + expect(payload.health).toHaveProperty('checks'); + }); + + it('gets and sets config dot paths with JSON-only stdout', async () => { + const program = registerProgram(); + await program.parseAsync([ + 'node', + 'test', + 'config', + 'set', + 'reviewerEnabled', + 'false', + '--json', + ]); + + expect(stdoutSpy).toHaveBeenCalledTimes(1); + expect(stderrSpy).not.toHaveBeenCalled(); + const setPayload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(setPayload).toMatchObject({ + schemaVersion: 1, + ok: true, + path: 'reviewerEnabled', + value: false, + }); + + stdoutSpy.mockClear(); + await program.parseAsync(['node', 'test', 'config', 'get', 'reviewerEnabled', '--json']); + const getPayload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(getPayload).toMatchObject({ schemaVersion: 1, path: 'reviewerEnabled', value: false }); + }); + + it('fails invalid config paths predictably without stdout in JSON mode', async () => { + const program = registerProgram(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + + await expect( + program.parseAsync(['node', 'test', 'config', 'get', 'missing.path', '--json']), + ).rejects.toThrow('exit'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(stderrSpy.mock.calls[0][0])); + expect(payload).toMatchObject({ + schemaVersion: 1, + ok: false, + error: 'Unknown config path: missing.path', + }); + exitSpy.mockRestore(); + }); + + it('rolls back invalid config values after reload validation', async () => { + const program = registerProgram(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + + await expect( + program.parseAsync([ + 'node', + 'test', + 'config', + 'set', + 'reviewerEnabled', + 'not-a-boolean', + '--json', + ]), + ).rejects.toThrow('exit'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + const payload = JSON.parse(String(stderrSpy.mock.calls[0][0])); + expect(payload.error).toBe('Invalid value for config path: reviewerEnabled'); + expect( + JSON.parse(fs.readFileSync(path.join(tempDir, 'night-watch.config.json'), 'utf-8')) + .reviewerEnabled, + ).toBe(true); + exitSpy.mockRestore(); + }); + + it('pauses and resumes jobs through config with JSON output', async () => { + const program = registerProgram(); + await program.parseAsync(['node', 'test', 'job', 'pause', 'executor', '--json']); + + const pausePayload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(pausePayload).toMatchObject({ + schemaVersion: 1, + ok: true, + job: 'executor', + paused: true, + }); + expect( + JSON.parse(fs.readFileSync(path.join(tempDir, 'night-watch.config.json'), 'utf-8')).pausedJobs + .executor, + ).toBe(true); + + stdoutSpy.mockClear(); + await program.parseAsync(['node', 'test', 'job', 'resume', 'executor', '--json']); + const resumePayload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(resumePayload).toMatchObject({ + schemaVersion: 1, + ok: true, + job: 'executor', + paused: false, + }); + }); + + it('prints health JSON without noisy stderr output', async () => { + const program = registerProgram(); + await program.parseAsync(['node', 'test', 'health', '--json']); + + expect(stdoutSpy).toHaveBeenCalledTimes(1); + expect(stderrSpy).not.toHaveBeenCalled(); + const payload = JSON.parse(String(stdoutSpy.mock.calls[0][0])); + expect(payload.schemaVersion).toBe(1); + expect(Array.isArray(payload.checks)).toBe(true); + }); +}); diff --git a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts index 291838d..a5e2e01 100644 --- a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts +++ b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts @@ -464,6 +464,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'printf \'%s\\n\' "$*" >> "$NW_SMOKE_QUEUE_CALL_LOG"\n' + 'if [[ "$1" == "queue" && ( "$2" == "complete" || "$2" == "dispatch" ) ]]; then\n' + ' exit 0\n' + @@ -526,6 +529,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'printf \'%s\\n\' "$*" >> "$NW_SMOKE_QUEUE_CALL_LOG"\n' + 'if [[ "$1" == "queue" && ( "$2" == "complete" || "$2" == "dispatch" ) ]]; then\n' + ' exit 0\n' + @@ -2747,6 +2753,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'if [[ "$1" == "board" && "$2" == "close-issue" ]]; then\n' + ' exit 0\n' + 'fi\n' + @@ -2864,6 +2873,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'if [[ "$1" == "board" && "$2" == "next-issue" ]]; then\n' + " echo '[]'\n" + ' exit 0\n' + @@ -2903,6 +2915,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'if [[ "$1" == "board" && "$2" == "next-issue" ]]; then\n' + ' echo \'[{"number":53,"title":"PRD: Backlink Exchange System","body":"Cooldown repro"}]\'\n' + ' exit 0\n' + @@ -2971,6 +2986,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'if [[ "$1" == "board" && "$2" == "move-issue" ]]; then\n' + ' # Log the move-issue call for verification\n' + ' echo "$*" >> "$NW_SMOKE_MOVE_LOG"\n' + @@ -3039,6 +3057,9 @@ describe('core flow smoke tests (bash scripts)', () => { fs.writeFileSync( nwCli, '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "job" && "$2" == "is-paused" ]]; then\n' + + ' exit 1\n' + + 'fi\n' + 'if [[ "$1" == "board" && "$2" == "move-issue" ]]; then\n' + ' exit 0\n' + 'fi\n' + diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index da04d4d..ec01a53 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -34,6 +34,7 @@ import { notifyCommand } from './commands/notify.js'; import { summaryCommand } from './commands/summary.js'; import { resolveCommand } from './commands/resolve.js'; import { mergeCommand } from './commands/merge.js'; +import { agentCommand, configCommand, healthCommand, jobCommand } from './commands/agent.js'; // Find the package root (works from both src/ in dev and dist/src/ in production) const __filename = fileURLToPath(import.meta.url); @@ -137,4 +138,10 @@ resolveCommand(program); // Register merge command (merger orchestrator) mergeCommand(program); +// Register machine-readable agent manageability commands +agentCommand(program); +configCommand(program); +healthCommand(program); +jobCommand(program); + program.parse(); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts new file mode 100644 index 0000000..209f5ff --- /dev/null +++ b/packages/cli/src/commands/agent.ts @@ -0,0 +1,424 @@ +/** + * Machine-readable agent manageability commands. + */ + +import { Command } from 'commander'; +import { + createBoardProvider, + fetchStatusSnapshot, + getConfigValue, + getJobRunsAnalytics, + getQueueStatus, + getValidJobTypes, + loadConfig, + parseConfigValue, + setConfigValue, +} from '@night-watch/core'; +import type { + IJobRunAnalytics, + INightWatchConfig, + IStatusSnapshot, + JobType, +} from '@night-watch/core'; + +const SCHEMA_VERSION = 1; +const JSON_OPTION = '--json'; +const JSON_OPTION_DESCRIPTION = 'Output as JSON'; + +export interface IJsonOptions { + json?: boolean; +} + +interface ICommandErrorPayload { + schemaVersion: number; + ok: false; + error: string; +} + +interface IHealthCheck { + name: string; + ok: boolean; + message: string; +} + +interface IHealthPayload { + schemaVersion: number; + ok: boolean; + checks: IHealthCheck[]; +} + +interface ILastRunInfo { + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastExitCode: number | null; +} + +interface ILegacyStatus { + projectName: string; + projectDir: string; + provider: string; + reviewerEnabled: boolean; + autoMerge: boolean; + autoMergeMethod: string; + executor: { running: boolean; pid: number | null }; + reviewer: { running: boolean; pid: number | null }; + qa: { running: boolean; pid: number | null }; + audit: { running: boolean; pid: number | null }; + planner: { running: boolean; pid: number | null }; + analytics: { running: boolean; pid: number | null }; + merger: { running: boolean; pid: number | null }; + prds: { pending: number; claimed: number; done: number }; + prs: { open: number }; + crontab: { installed: boolean; entries: string[] }; + logs: Record; +} + +interface IAgentStatusPayload { + schemaVersion: number; + generatedAt: string; + project: { + name: string; + dir: string; + provider: string; + }; + status: ILegacyStatus; + paused: Record; + queue: ReturnType; + board: { + configured: boolean; + columns: Array<{ id: string; name: string }>; + items: unknown[]; + error: string | null; + }; + health: IHealthPayload; + lastRuns: Record; +} + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function fail(message: string, options?: IJsonOptions): never { + if (options?.json) { + const payload: ICommandErrorPayload = { + schemaVersion: SCHEMA_VERSION, + ok: false, + error: message, + }; + process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + process.stderr.write(`${message}\n`); + } + process.exit(1); +} + +function getProcess( + snapshot: IStatusSnapshot, + name: string, +): { running: boolean; pid: number | null } { + const processInfo = snapshot.processes.find((processEntry) => processEntry.name === name); + return { running: processInfo?.running ?? false, pid: processInfo?.pid ?? null }; +} + +function buildLegacyStatus(snapshot: IStatusSnapshot, config: INightWatchConfig): ILegacyStatus { + const pendingPrds = snapshot.prds.filter( + (prd) => prd.status === 'ready' || prd.status === 'blocked', + ).length; + const claimedPrds = snapshot.prds.filter((prd) => prd.status === 'in-progress').length; + const donePrds = snapshot.prds.filter((prd) => prd.status === 'done').length; + const logs = Object.fromEntries( + snapshot.logs.map((log) => [ + log.name, + { path: log.path, lastLines: log.lastLines, exists: log.exists, size: log.size }, + ]), + ); + + return { + projectName: snapshot.projectName, + projectDir: snapshot.projectDir, + provider: config.provider, + reviewerEnabled: config.reviewerEnabled, + autoMerge: config.autoMerge, + autoMergeMethod: config.autoMergeMethod, + executor: getProcess(snapshot, 'executor'), + reviewer: getProcess(snapshot, 'reviewer'), + qa: getProcess(snapshot, 'qa'), + audit: getProcess(snapshot, 'audit'), + planner: getProcess(snapshot, 'planner'), + analytics: getProcess(snapshot, 'analytics'), + merger: getProcess(snapshot, 'merger'), + prds: { pending: pendingPrds, claimed: claimedPrds, done: donePrds }, + prs: { open: snapshot.prs.length }, + crontab: snapshot.crontab, + logs, + }; +} + +function buildPausedState(config: INightWatchConfig): Record { + return Object.fromEntries( + getValidJobTypes().map((jobType) => [jobType, config.pausedJobs?.[jobType] === true]), + ); +} + +function buildLastRuns(analytics: IJobRunAnalytics): Record { + const lastRuns = Object.fromEntries( + getValidJobTypes().map((jobType) => [ + jobType, + { lastSuccessAt: null, lastFailureAt: null, lastExitCode: null }, + ]), + ) as Record; + + for (const run of analytics.recentRuns) { + const item = lastRuns[run.jobType]; + if (!item) continue; + const finishedAt = run.finishedAt ? new Date(run.finishedAt * 1000).toISOString() : null; + if (run.status === 'success' && item.lastSuccessAt === null) { + item.lastSuccessAt = finishedAt; + item.lastExitCode = 0; + } else if (run.status !== 'success' && item.lastFailureAt === null) { + item.lastFailureAt = finishedAt; + item.lastExitCode = 1; + } + } + + return lastRuns; +} + +async function getBoardSnapshot( + projectDir: string, + config: INightWatchConfig, +): Promise { + if (config.boardProvider?.enabled === false || !config.boardProvider?.projectNumber) { + return { configured: false, columns: [], items: [], error: null }; + } + + try { + const provider = createBoardProvider(config.boardProvider, projectDir); + const [columns, items] = await Promise.all([provider.getColumns(), provider.getAllIssues()]); + return { configured: true, columns, items, error: null }; + } catch (error) { + return { + configured: true, + columns: [], + items: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function buildHealth(snapshot: IStatusSnapshot, config: INightWatchConfig): IHealthPayload { + const checks: IHealthCheck[] = [ + { + name: 'config', + ok: true, + message: 'Configuration loaded', + }, + { + name: 'cron', + ok: snapshot.crontab.installed, + message: snapshot.crontab.installed + ? 'Cron entries installed' + : 'No Night Watch cron entries found', + }, + { + name: 'queue', + ok: true, + message: config.queue.enabled ? 'Global queue enabled' : 'Global queue disabled', + }, + { + name: 'provider', + ok: Boolean(config.provider), + message: config.provider + ? `Provider configured: ${config.provider}` + : 'No provider configured', + }, + ]; + + const staleLocks = snapshot.processes.filter( + (processInfo) => !processInfo.running && processInfo.pid !== null, + ); + checks.push({ + name: 'locks', + ok: staleLocks.length === 0, + message: + staleLocks.length === 0 + ? 'No stale lock files detected' + : `Stale lock files detected for ${staleLocks.map((lock) => lock.name).join(', ')}`, + }); + + return { schemaVersion: SCHEMA_VERSION, ok: checks.every((check) => check.ok), checks }; +} + +async function buildAgentStatus(projectDir: string): Promise { + const config = loadConfig(projectDir); + const snapshot = await fetchStatusSnapshot(projectDir, config); + const analytics = getJobRunsAnalytics(24 * 30); + const health = buildHealth(snapshot, config); + + return { + schemaVersion: SCHEMA_VERSION, + generatedAt: snapshot.timestamp.toISOString(), + project: { name: snapshot.projectName, dir: snapshot.projectDir, provider: config.provider }, + status: buildLegacyStatus(snapshot, config), + paused: buildPausedState(config), + queue: getQueueStatus(), + board: await getBoardSnapshot(projectDir, config), + health, + lastRuns: buildLastRuns(analytics), + }; +} + +function normalizeJobType(job: string): JobType { + if (getValidJobTypes().includes(job as JobType)) { + return job as JobType; + } + throw new Error(`Invalid job: ${job}. Valid jobs: ${getValidJobTypes().join(', ')}`); +} + +export function agentCommand(program: Command): void { + const agent = program.command('agent').description('Machine-readable agent operations'); + + agent + .command('status') + .description('Print a stable machine-readable project snapshot') + .requiredOption(JSON_OPTION, 'Output status as JSON') + .action(async () => { + writeJson(await buildAgentStatus(process.cwd())); + }); +} + +export function configCommand(program: Command): void { + const config = program.command('config').description('Inspect and edit Night Watch config'); + + config + .command('list') + .description('Print resolved config') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action((options: IJsonOptions) => { + const value = loadConfig(process.cwd()); + if (options.json) { + writeJson({ schemaVersion: SCHEMA_VERSION, config: value }); + } else { + writeJson(value); + } + }); + + config + .command('get ') + .description('Read a resolved config value by dot path') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action((dotPath: string, options: IJsonOptions) => { + try { + const result = getConfigValue(process.cwd(), dotPath); + if (options.json) { + writeJson({ schemaVersion: SCHEMA_VERSION, ...result }); + } else { + writeJson(result.value); + } + } catch (error) { + fail(error instanceof Error ? error.message : String(error), options); + } + }); + + config + .command('set ') + .description('Write a config value by dot path') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action((dotPath: string, rawValue: string, options: IJsonOptions) => { + try { + const result = setConfigValue(process.cwd(), dotPath, parseConfigValue(rawValue)); + if (options.json) { + writeJson({ schemaVersion: SCHEMA_VERSION, ok: true, ...result }); + } else { + process.stdout.write(`Updated ${result.path}\n`); + } + } catch (error) { + fail(error instanceof Error ? error.message : String(error), options); + } + }); +} + +export function healthCommand(program: Command): void { + program + .command('health') + .description('Check automation readiness') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action(async (options: IJsonOptions) => { + const config = loadConfig(process.cwd()); + const snapshot = await fetchStatusSnapshot(process.cwd(), config); + const health = buildHealth(snapshot, config); + if (options.json) { + writeJson(health); + } else { + for (const check of health.checks) { + process.stdout.write(`${check.ok ? 'ok' : 'fail'} ${check.name}: ${check.message}\n`); + } + } + if (!health.ok) { + process.exitCode = 1; + } + }); +} + +export function jobCommand(program: Command): void { + const job = program.command('job').description('Manage Night Watch jobs'); + + job + .command('pause ') + .description('Pause a cron/queue-dispatched job') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action((jobName: string, options: IJsonOptions) => { + try { + const jobType = normalizeJobType(jobName); + const result = setConfigValue(process.cwd(), `pausedJobs.${jobType}`, true); + if (options.json) { + writeJson({ + schemaVersion: SCHEMA_VERSION, + ok: true, + job: jobType, + paused: result.value, + }); + } else { + process.stdout.write(`Paused ${jobType}\n`); + } + } catch (error) { + fail(error instanceof Error ? error.message : String(error), options); + } + }); + + job + .command('resume ') + .description('Resume a cron/queue-dispatched job') + .option(JSON_OPTION, JSON_OPTION_DESCRIPTION) + .action((jobName: string, options: IJsonOptions) => { + try { + const jobType = normalizeJobType(jobName); + const result = setConfigValue(process.cwd(), `pausedJobs.${jobType}`, false); + if (options.json) { + writeJson({ + schemaVersion: SCHEMA_VERSION, + ok: true, + job: jobType, + paused: result.value, + }); + } else { + process.stdout.write(`Resumed ${jobType}\n`); + } + } catch (error) { + fail(error instanceof Error ? error.message : String(error), options); + } + }); + + job + .command('is-paused ') + .description('Return zero when a job is paused') + .action((jobName: string) => { + try { + const jobType = normalizeJobType(jobName); + const paused = loadConfig(process.cwd()).pausedJobs?.[jobType] === true; + process.exit(paused ? 0 : 1); + } catch { + process.exit(1); + } + }); +} diff --git a/packages/cli/src/commands/queue.ts b/packages/cli/src/commands/queue.ts index ebc2e87..5d051f4 100644 --- a/packages/cli/src/commands/queue.ts +++ b/packages/cli/src/commands/queue.ts @@ -70,6 +70,14 @@ function printQueueEntry(entry: IQueueEntry, indent = ''): void { } } +function isJobPaused(projectDir: string, jobType: JobType): boolean { + try { + return loadConfig(projectDir).pausedJobs?.[jobType] === true; + } catch { + return false; + } +} + export function createQueueCommand(): Command { const queue = new Command('queue'); queue.description('Manage the global job queue'); @@ -203,6 +211,10 @@ export function createQueueCommand(): Command { const projectName = path.basename(projectDir); const queueConfig = loadConfig(projectDir).queue; + if (isJobPaused(projectDir, jobType as JobType)) { + logger.info(`Skipping enqueue for paused job: ${jobType}`); + return; + } const id = enqueueJob( projectDir, projectName, @@ -256,6 +268,12 @@ export function createQueueCommand(): Command { return; } + if (isJobPaused(entry.projectPath, entry.jobType)) { + logger.info(`Skipping paused queued job: ${entry.jobType} for ${entry.projectName}`); + removeJob(entry.id); + return; + } + logger.info(`Dispatching ${entry.jobType} for ${entry.projectName} (ID: ${entry.id})`); // Construct the spawn command based on job type @@ -331,6 +349,9 @@ export function createQueueCommand(): Command { } const queueConfig = loadConfig(projectDir).queue; + if (isJobPaused(projectDir, jobType as JobType)) { + process.exit(2); + } const projectName = path.basename(projectDir); const callerPid = opts.pid ? parseInt(opts.pid, 10) : undefined; const result = claimJobSlot( diff --git a/packages/core/src/config-normalize.ts b/packages/core/src/config-normalize.ts index dae8390..5bca901 100644 --- a/packages/core/src/config-normalize.ts +++ b/packages/core/src/config-normalize.ts @@ -321,6 +321,20 @@ export function normalizeConfig(rawConfig: Record): Partial> = {}; + for (const jobType of VALID_JOB_TYPES) { + const paused = readBoolean(rawPausedJobs[jobType]); + if (paused !== undefined) { + pausedJobs[jobType] = paused; + } + } + if (Object.keys(pausedJobs).length > 0) { + normalized.pausedJobs = pausedJobs; + } + } + // Parse provider schedule overrides const rawScheduleOverrides = readObject(rawConfig.providerScheduleOverrides); if (rawScheduleOverrides) { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 216bc13..7ffc5f3 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -113,6 +113,7 @@ export function getDefaultConfig(): INightWatchConfig { jobProviders: { ...DEFAULT_JOB_PROVIDERS }, providerScheduleOverrides: [...DEFAULT_PROVIDER_SCHEDULE_OVERRIDES], queue: { ...DEFAULT_QUEUE }, + pausedJobs: {}, webhookTriggers: cloneWebhookTriggers(DEFAULT_WEBHOOK_TRIGGERS), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f46e11..608356f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export * from './utils/logger.js'; export * from './utils/cancel.js'; export * from './utils/checks.js'; export * from './utils/config-writer.js'; +export * from './utils/config-path.js'; export * from './utils/crontab.js'; export * from './utils/execution-history.js'; export * from './utils/git-utils.js'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fe35152..2e34859 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -339,6 +339,9 @@ export interface INightWatchConfig { /** Global job queue configuration */ queue: IQueueConfig; + /** Jobs paused from cron/queue dispatch by `night-watch job pause` */ + pausedJobs?: Partial>; + /** Authenticated inbound job webhook trigger configuration */ webhookTriggers: IWebhookTriggerConfig; diff --git a/packages/core/src/utils/config-path.ts b/packages/core/src/utils/config-path.ts new file mode 100644 index 0000000..9b898a3 --- /dev/null +++ b/packages/core/src/utils/config-path.ts @@ -0,0 +1,147 @@ +/** + * Dot-path helpers for non-interactive config inspection and edits. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CONFIG_FILE_NAME } from '../constants.js'; +import { getDefaultConfig, loadConfig } from '../config.js'; +import { getValidJobTypes } from '../jobs/job-registry.js'; +import type { INightWatchConfig, JobType } from '../types.js'; +import { saveConfig } from './config-writer.js'; + +export interface IConfigPathResult { + path: string; + value: unknown; +} + +export interface IConfigSetResult extends IConfigPathResult { + previousValue: unknown; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function splitConfigPath(dotPath: string): string[] { + const parts = dotPath + .split('.') + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length === 0 || parts.some((part) => part === '__proto__' || part === 'constructor')) { + throw new Error(`Invalid config path: ${dotPath}`); + } + return parts; +} + +function readRawConfig(projectDir: string): Record { + const configPath = path.join(projectDir, CONFIG_FILE_NAME); + if (!fs.existsSync(configPath)) { + return {}; + } + return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; +} + +function writeRawConfig(projectDir: string, rawConfig: Record): void { + const configPath = path.join(projectDir, CONFIG_FILE_NAME); + fs.writeFileSync(configPath, `${JSON.stringify(rawConfig, null, 2)}\n`); +} + +function getValueAtPath(source: unknown, parts: string[]): unknown { + let current = source; + for (const part of parts) { + if (!isPlainObject(current) || !(part in current)) { + return undefined; + } + current = current[part]; + } + return current; +} + +function setValueAtPath(target: Record, parts: string[], value: unknown): void { + let current = target; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (next === undefined) { + current[part] = {}; + } else if (!isPlainObject(next)) { + throw new Error(`Cannot set ${parts.join('.')}: ${part} is not an object`); + } + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; +} + +function hasKnownConfigPath(parts: string[]): boolean { + if (parts[0] === 'pausedJobs' && parts.length === 2) { + return getValidJobTypes().includes(parts[1] as JobType); + } + + const defaults = getDefaultConfig() as unknown as Record; + let current: unknown = defaults; + for (const part of parts) { + if (!isPlainObject(current) || !(part in current)) { + return false; + } + current = current[part]; + } + return true; +} + +export function parseConfigValue(rawValue: string): unknown { + const trimmed = rawValue.trim(); + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + if (trimmed === 'null') return null; + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) { + return Number(trimmed); + } + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return JSON.parse(trimmed); + } + return rawValue; +} + +export function getConfigValue(projectDir: string, dotPath: string): IConfigPathResult { + const parts = splitConfigPath(dotPath); + if (!hasKnownConfigPath(parts)) { + throw new Error(`Unknown config path: ${dotPath}`); + } + const config = loadConfig(projectDir) as unknown as Record; + return { path: parts.join('.'), value: getValueAtPath(config, parts) }; +} + +export function setConfigValue( + projectDir: string, + dotPath: string, + value: unknown, +): IConfigSetResult { + const parts = splitConfigPath(dotPath); + if (!hasKnownConfigPath(parts)) { + throw new Error(`Unknown config path: ${dotPath}`); + } + + const rawConfig = readRawConfig(projectDir); + const originalRawConfig = JSON.parse(JSON.stringify(rawConfig)) as Record; + const currentConfig = loadConfig(projectDir) as unknown as Record; + const previousValue = getValueAtPath(currentConfig, parts); + + setValueAtPath(rawConfig, parts, value); + const result = saveConfig(projectDir, rawConfig as unknown as Partial); + if (!result.success) { + throw new Error(`Failed to save config: ${result.error}`); + } + + const reloaded = loadConfig(projectDir) as unknown as Record; + const reloadedValue = getValueAtPath(reloaded, parts); + if (JSON.stringify(reloadedValue) !== JSON.stringify(value)) { + writeRawConfig(projectDir, originalRawConfig); + throw new Error(`Invalid value for config path: ${parts.join('.')}`); + } + + return { path: parts.join('.'), previousValue, value: reloadedValue }; +} diff --git a/packages/core/src/utils/config-writer.ts b/packages/core/src/utils/config-writer.ts index 2df8d2d..8f885d5 100644 --- a/packages/core/src/utils/config-writer.ts +++ b/packages/core/src/utils/config-writer.ts @@ -20,6 +20,7 @@ const PARTIAL_MERGE_KEYS = new Set([ 'roadmapScanner', 'queue', 'providerPresets', + 'pausedJobs', ]); function isPlainObject(value: unknown): value is Record { diff --git a/scripts/night-watch-audit-cron.sh b/scripts/night-watch-audit-cron.sh index d9dd078..786ccd8 100755 --- a/scripts/night-watch-audit-cron.sh +++ b/scripts/night-watch-audit-cron.sh @@ -28,6 +28,7 @@ mkdir -p "${LOG_DIR}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=night-watch-helpers.sh source "${SCRIPT_DIR}/night-watch-helpers.sh" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" # emit_result helper - must be defined before use emit_result() { diff --git a/scripts/night-watch-cron.sh b/scripts/night-watch-cron.sh index 34734a7..de4bd5a 100755 --- a/scripts/night-watch-cron.sh +++ b/scripts/night-watch-cron.sh @@ -50,6 +50,7 @@ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}") # NOTE: Lock file path must match executorLockPath() in src/utils/status-data.ts LOCK_FILE="/tmp/night-watch-${PROJECT_RUNTIME_KEY}.lock" SCRIPT_TYPE="executor" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" emit_result() { local status="${1:?status required}" diff --git a/scripts/night-watch-helpers.sh b/scripts/night-watch-helpers.sh index 765997b..3f9a2fe 100644 --- a/scripts/night-watch-helpers.sh +++ b/scripts/night-watch-helpers.sh @@ -1338,6 +1338,22 @@ arm_global_queue_cleanup() { append_exit_trap "__night_watch_queue_cleanup \$?" } +skip_if_job_paused() { + local script_type="${1:?script_type required}" + local project_dir="${2:?project_dir required}" + + local cli_bin + cli_bin=$(resolve_night_watch_cli) || return 0 + + if (cd "${project_dir}" && "${cli_bin}" job is-paused "${script_type}" >/dev/null 2>&1); then + log "SKIP: ${script_type} is paused in night-watch.config.json" + if command -v emit_result >/dev/null 2>&1; then + emit_result "skip_paused" + fi + exit 0 + fi +} + # Atomically claim a queue slot or enqueue for later dispatch. # Uses DB transaction (via `queue claim` CLI) for atomicity — no flock needed. # Sets NW_QUEUE_ENTRY_ID on success and arms the cleanup trap. @@ -1345,6 +1361,8 @@ arm_global_queue_cleanup() { claim_or_enqueue() { local script_type="${1:?script_type required}" local project_dir="${2:?project_dir required}" + skip_if_job_paused "${script_type}" "${project_dir}" + local provider_key provider_key=$(resolve_provider_key "${project_dir}" "${script_type}") diff --git a/scripts/night-watch-merger-cron.sh b/scripts/night-watch-merger-cron.sh index 79f2aa8..5b1b61e 100755 --- a/scripts/night-watch-merger-cron.sh +++ b/scripts/night-watch-merger-cron.sh @@ -56,6 +56,7 @@ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}") # NOTE: Lock file path must match mergerLockPath() in src/utils/status-data.ts LOCK_FILE="/tmp/night-watch-merger-${PROJECT_RUNTIME_KEY}.lock" SCRIPT_TYPE="merger" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" MERGED_PRS=0 FAILED_PRS=0 diff --git a/scripts/night-watch-plan-cron.sh b/scripts/night-watch-plan-cron.sh index 100e315..c7b1760 100755 --- a/scripts/night-watch-plan-cron.sh +++ b/scripts/night-watch-plan-cron.sh @@ -61,6 +61,8 @@ PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PRO PRD_DIR="${NW_PRD_DIR:-docs/PRDs}" PLAN_TASK="${NW_PLAN_TASK:-}" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" + rotate_log log_separator log "RUN-START: planner invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}" diff --git a/scripts/night-watch-pr-resolver-cron.sh b/scripts/night-watch-pr-resolver-cron.sh index 12f4cbe..c94d376 100755 --- a/scripts/night-watch-pr-resolver-cron.sh +++ b/scripts/night-watch-pr-resolver-cron.sh @@ -63,6 +63,7 @@ PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PRO # NOTE: Lock file path must match resolverLockPath() in src/utils/status-data.ts LOCK_FILE="/tmp/night-watch-pr-resolver-${PROJECT_RUNTIME_KEY}.lock" SCRIPT_TYPE="pr-resolver" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" emit_result() { local status="${1:?status required}" diff --git a/scripts/night-watch-pr-reviewer-cron.sh b/scripts/night-watch-pr-reviewer-cron.sh index 3332fc1..357ba31 100755 --- a/scripts/night-watch-pr-reviewer-cron.sh +++ b/scripts/night-watch-pr-reviewer-cron.sh @@ -74,6 +74,7 @@ else fi SCRIPT_TYPE="reviewer" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" READY_FOR_REVIEW_LABEL="${NW_READY_FOR_REVIEW_LABEL:-ready-for-review}" READY_FOR_REVIEW_MARKER_NAME="night-watch-ready-for-review" READY_TO_MERGE_LABEL="${NW_PR_RESOLVER_READY_LABEL:-ready-to-merge}" diff --git a/scripts/night-watch-qa-cron.sh b/scripts/night-watch-qa-cron.sh index bbd9182..7d1f6ca 100755 --- a/scripts/night-watch-qa-cron.sh +++ b/scripts/night-watch-qa-cron.sh @@ -46,6 +46,7 @@ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}") # NOTE: Lock file path must match qaLockPath() in src/utils/status-data.ts LOCK_FILE="/tmp/night-watch-qa-${PROJECT_RUNTIME_KEY}.lock" SCRIPT_TYPE="qa" +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" emit_result() { local status="${1:?status required}" diff --git a/scripts/night-watch-slicer-cron.sh b/scripts/night-watch-slicer-cron.sh index cab812f..104162d 100755 --- a/scripts/night-watch-slicer-cron.sh +++ b/scripts/night-watch-slicer-cron.sh @@ -35,6 +35,7 @@ source "${SCRIPT_DIR}/night-watch-helpers.sh" ensure_provider_on_path "${PROVIDER_CMD}" || true PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}") LOCK_FILE="/tmp/night-watch-slicer-${PROJECT_RUNTIME_KEY}.lock" +SCRIPT_TYPE="slicer" PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PROVIDER_LABEL}") emit_result() { @@ -53,6 +54,8 @@ if ! validate_provider "${PROVIDER_CMD}"; then exit 1 fi +skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}" + rotate_log log_separator log "RUN-START: slicer invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}"