diff --git a/.github/prompts/pr-review.md b/.github/prompts/pr-review.md index 39d5b595..cd02c529 100644 --- a/.github/prompts/pr-review.md +++ b/.github/prompts/pr-review.md @@ -4,6 +4,7 @@ Review the pull request and produce one standardized markdown review comment. The output must be concise, specific, and action-oriented. Avoid generic praise, nitpicks, or repeated points. **Focus areas** + 1. Code quality and maintainability 2. Functional correctness 3. Architecture and best practices @@ -14,6 +15,7 @@ The output must be concise, specific, and action-oriented. Avoid generic praise, 8. Non-blocking issues worth tracking **Critical rules** + - Use the exact section order and heading names defined below. - Always include the score line exactly as `**🏆 Overall Score:** XX/100`. - Keep the summary and conclusion short. @@ -23,6 +25,7 @@ The output must be concise, specific, and action-oriented. Avoid generic praise, - Do not output review boilerplate outside the required structure. **Scoring guidance** + - 90-100: Exceptional quality, merge-ready with no meaningful concerns - 75-89: Strong implementation with minor issues or follow-ups - 60-74: Functional but has notable quality, correctness, or testing gaps @@ -37,7 +40,7 @@ Use this exact structure: **🏆 Overall Score:** XX/100 -*1-2 sentence high-level summary of what the PR does and the overall quality assessment.* +_1-2 sentence high-level summary of what the PR does and the overall quality assessment._ --- @@ -63,9 +66,9 @@ If there are fewer than 3 real improvements, include only the meaningful ones. Include this section only when you found at least one likely bug. Use this exact table shape: -| Bug Name | Affected Files | Description | Confidence | -|----------|---------------|-------------|------------| -| | `` | | High 🟢 / Medium 🟡 / Low 🔴 | +| Bug Name | Affected Files | Description | Confidence | +| ---------- | -------------- | ------------------------------------- | ---------------------------- | +| | `` | | High 🟢 / Medium 🟡 / Low 🔴 | --- @@ -73,17 +76,18 @@ Include this section only when you found at least one likely bug. Use this exact Include this section only when you found non-bug issues worth flagging, such as performance, testing, maintainability, or design concerns. Use this exact table shape: -| Issue Type | Issue Name | Affected Components | Description | Impact/Severity | -|------------|------------|---------------------|-------------|-----------------| +| Issue Type | Issue Name | Affected Components | Description | Impact/Severity | +| -------------------------------------------------- | ------------ | --------------------- | ------------------ | ------------------- | | Performance / Testing / Maintainability / Security | | `` | | High / Medium / Low | --- ### **🔚 Conclusion** -*1-2 sentence conclusion on merge readiness, seriousness of findings, and whether fixes are required before merge.* +_1-2 sentence conclusion on merge readiness, seriousness of findings, and whether fixes are required before merge._ **Review quality bar** + - Prefer 2-3 substantial strengths and 0-3 meaningful improvements. - Distinguish clearly between bugs and non-bug issues. - Do not call something a bug unless there is a concrete failure mode. @@ -95,7 +99,7 @@ Include this section only when you found non-bug issues worth flagging, such as **🏆 Overall Score:** 85/100 -*The PR implements a comprehensive feature with solid structure, good coverage, and only a few medium-priority follow-ups.* +_The PR implements a comprehensive feature with solid structure, good coverage, and only a few medium-priority follow-ups._ --- @@ -116,21 +120,21 @@ Include this section only when you found non-bug issues worth flagging, such as ### **🐛 Bugs Found** -| Bug Name | Affected Files | Description | Confidence | -|----------|---------------|-------------|------------| -| Example Integration Mismatch | `server/example.ts` | The endpoint path appears inconsistent with the client call, which could cause the integration to fail at runtime. | Medium 🟡 | +| Bug Name | Affected Files | Description | Confidence | +| ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------- | +| Example Integration Mismatch | `server/example.ts` | The endpoint path appears inconsistent with the client call, which could cause the integration to fail at runtime. | Medium 🟡 | --- ### **📋 Issues Found** -| Issue Type | Issue Name | Affected Components | Description | Impact/Severity | -|------------|------------|---------------------|-------------|-----------------| -| Performance | Sequential Checks | `matching.service.ts` | Multiple serial queries are executed per item, which may increase latency at scale. | Medium | -| Testing | Fragile Mocks | `service.spec.ts` | Nested mocks make test setup hard to maintain and reason about. | Low | +| Issue Type | Issue Name | Affected Components | Description | Impact/Severity | +| ----------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------- | --------------- | +| Performance | Sequential Checks | `matching.service.ts` | Multiple serial queries are executed per item, which may increase latency at scale. | Medium | +| Testing | Fragile Mocks | `service.spec.ts` | Nested mocks make test setup hard to maintain and reason about. | Low | --- ### **🔚 Conclusion** -*This is a strong PR with a clear structure and solid test coverage. The main follow-ups are manageable, but any real bugs identified should be addressed before merge.* +_This is a strong PR with a clear structure and solid test coverage. The main follow-ups are manageable, but any real bugs identified should be addressed before merge._ diff --git a/docs/marketing/landing.md b/docs/marketing/landing.md index 20bd80f8..55cc19b8 100644 --- a/docs/marketing/landing.md +++ b/docs/marketing/landing.md @@ -38,16 +38,20 @@ Marketing landing page for Night Watch CLI to convert visitors (developers, solo ### 2. Hero Section **Headline (large, bold):** + > Your repo's night shift. **Subheadline (slate-400, 1-2 lines max):** + > Define work during the day. Night Watch executes overnight. Wake up to pull requests, reviewed code, and tested features. **CTA buttons:** + - Primary: `npm install -g @jonit-dev/night-watch-cli` (copyable code block styled as a button, click-to-copy) - Secondary: "Read the docs" (ghost/outline button, links to docs) **Below the fold teaser:** + - A single animated terminal recording (asciinema or typed.js simulation) showing: ``` $ night-watch init @@ -63,11 +67,11 @@ Marketing landing page for Night Watch CLI to convert visitors (developers, solo Three cards in a row, each with an icon, title, and 1-line description: -| Icon | Title | Description | -|------|-------|-------------| -| Moon/Clock | Async-first | Not pair-programming. Queued execution while you sleep. | -| GitBranch | Safe isolation | Every task runs in its own git worktree. Your main branch stays clean. | -| Eye/Shield | Human-in-the-loop | You review every PR. Configurable trust dials control auto-merge. | +| Icon | Title | Description | +| ---------- | ----------------- | ---------------------------------------------------------------------- | +| Moon/Clock | Async-first | Not pair-programming. Queued execution while you sleep. | +| GitBranch | Safe isolation | Every task runs in its own git worktree. Your main branch stays clean. | +| Eye/Shield | Human-in-the-loop | You review every PR. Configurable trust dials control auto-merge. | ### 4. How It Works (horizontal stepper or vertical timeline) @@ -86,13 +90,13 @@ Four steps, each with a number, title, short description, and a small illustrati Grid of 5 agent cards (2x3 or horizontal scroll on mobile). Each card: -| Agent | Role | Schedule hint | -|-------|------|---------------| -| Executor | Implements specs as code, opens PRs | Hourly | -| Reviewer | Scores PRs, requests fixes, auto-merges | Every 3 hours | -| QA | Generates and runs Playwright e2e tests | 4x daily | -| Auditor | Scans codebase for quality issues | Weekly | -| Slicer | Breaks roadmap items into granular specs | Every 6 hours | +| Agent | Role | Schedule hint | +| -------- | ---------------------------------------- | ------------- | +| Executor | Implements specs as code, opens PRs | Hourly | +| Reviewer | Scores PRs, requests fixes, auto-merges | Every 3 hours | +| QA | Generates and runs Playwright e2e tests | 4x daily | +| Auditor | Scans codebase for quality issues | Weekly | +| Slicer | Breaks roadmap items into granular specs | Every 6 hours | Each card should have a subtle colored accent bar on top (different color per agent) and a small icon. On hover, show a brief example of what the agent outputs (e.g., Reviewer: "Score: 87/100 — ready to merge"). @@ -113,12 +117,14 @@ Each card should have a subtle colored accent bar on top (different color per ag Two-column layout: **Night Watch is strongest when:** (green checkmarks) + - You already use structured specs, PRDs, or queued board items - You want async execution, not another pair-programming UI - Your work can be broken into small, reviewable pull requests - You care about overnight throughput on bounded tasks **Night Watch is a weaker fit when:** (gray x-marks) + - Work starts vague and gets clarified only during implementation - Your team is not comfortable reviewing AI-generated pull requests - You want a general-purpose AI coding assistant @@ -147,6 +153,7 @@ night-watch install # setup automated cron ``` Below the code block: + - Link: "5-minute walkthrough" (→ docs/walkthrough.md) - Link: "Full docs" (→ docs/) - Link: "Commands reference" (→ docs/commands.md) @@ -155,11 +162,11 @@ Below the code block: Small horizontal strip showing supported providers: -| Provider | Mode | -|----------|------| -| Claude CLI | Default, with rate-limit fallback | -| Codex CLI | Full support | -| GLM-5 / Custom endpoints | Via `providerEnv` config | +| Provider | Mode | +| ------------------------ | --------------------------------- | +| Claude CLI | Default, with rate-limit fallback | +| Codex CLI | Full support | +| GLM-5 / Custom endpoints | Via `providerEnv` config | Caption: "Bring your own AI provider. Night Watch wraps the CLI — you stay in control of credentials and costs." @@ -209,14 +216,17 @@ Caption: "Bring your own AI provider. Night Watch wraps the CLI — you stay in ## Copy Alternatives for Hero Option A (current recommendation): + > **Your repo's night shift.** > Define work during the day. Night Watch executes overnight. Wake up to pull requests. Option B: + > **Ship while you sleep.** > Night Watch turns your specs into reviewed pull requests — overnight, automatically, safely. Option C: + > **Async PRD execution for AI-native teams.** > Queue specs. Night Watch implements, reviews, tests, and opens PRs while you're offline. diff --git a/docs/marketing/naming-branding.md b/docs/marketing/naming-branding.md index 22db8a97..fd91b2e7 100644 --- a/docs/marketing/naming-branding.md +++ b/docs/marketing/naming-branding.md @@ -3,12 +3,14 @@ ## Name Decision: Keep "Night Watch CLI" ### Why It's Fine + - npm package is scoped (`@jonit-dev/night-watch-cli`) — no npm conflict - CLI binary is `night-watch` — different from `nightwatch` (Nightwatch.js) - Target audience finds the tool via GitHub, word-of-mouth, direct links — not by Googling "nightwatch" - Search intent is completely different: testers find Nightwatch.js, developers automating spec execution find Night Watch CLI ### Nightwatch.js Overlap — Mitigated By + - Owning different keyword territory (see SEO section below) - Scoped npm package - Different CLI binary name @@ -18,14 +20,17 @@ ## GitHub Description **Recommended:** + > Turn GitHub issues into pull requests automatically. AI agents that implement, review, and test your specs on a schedule. **Alternatives considered:** + - `Async AI execution layer for spec-driven teams. Queue work, wake up to PRs.` - `Cron-based AI coding agent. Specs in, pull requests out.` - `AI agent that implements your specs, opens PRs, reviews code, and runs tests — on a schedule.` **What was rejected and why:** + - "Semi-autonomous software engineering app factory" — "app factory" implies it builds entire apps; "semi-autonomous" hedges weakly - "Autonomous PRD execution using AI Provider CLIs + cron" — too technical, not outcome-first @@ -34,6 +39,7 @@ ## SEO Strategy ### Keywords We Own (Nightwatch.js does NOT compete here) + - AI PR automation - async coding agent - PRD automation tool @@ -44,6 +50,7 @@ - spec to PR automation ### Keywords to Avoid (Nightwatch.js dominates) + - nightwatch e2e - nightwatch testing - nightwatchjs @@ -54,24 +61,27 @@ ## Names Researched & Rejected ### Fully Available (domains + npm) — Rejected Alternatives -| Name | Reason rejected | -|------|----------------| -| sentinelcli | Good but loses brand continuity | -| nightcrew | Good vibe, weaker SEO | -| nightshift | Best alternative if rebrand needed | -| autohelm | Nautical metaphor, niche | -| repocrew | Clear but generic | -| cronpilot | Technical, not memorable | -| specforge | SEO-strong but cold | -| agentforge | Rides hype wave | + +| Name | Reason rejected | +| ----------- | ---------------------------------- | +| sentinelcli | Good but loses brand continuity | +| nightcrew | Good vibe, weaker SEO | +| nightshift | Best alternative if rebrand needed | +| autohelm | Nautical metaphor, niche | +| repocrew | Clear but generic | +| cronpilot | Technical, not memorable | +| specforge | SEO-strong but cold | +| agentforge | Rides hype wave | ### Why "AgenticSwarmCLI" Was Rejected + - "Agentic" is overused buzzword, will age poorly - "Swarm" implies chaos — opposite of the controlled, spec-driven philosophy - No personality or memorability -- Describes *how* it works, not *what it does for you* +- Describes _how_ it works, not _what it does for you_ ### Naming Principles Applied + - Outcome-first over mechanism-first - Evocative metaphor > technical description - Short, speakable, memorable diff --git a/docs/marketing/reddit-promotion.md b/docs/marketing/reddit-promotion.md index b2e7c584..c8be7970 100644 --- a/docs/marketing/reddit-promotion.md +++ b/docs/marketing/reddit-promotion.md @@ -12,33 +12,33 @@ ### Tier 1 — High relevance, post first -| Subreddit | Members | Why | Post type | -|---|---|---|---| -| r/SideProject | ~200k | Built for show-and-tell of indie tools | Show-off / Launch | -| r/ChatGPTCoding | ~300k | AI-assisted coding is the core topic | Tutorial / Demo | -| r/ClaudeAI | ~150k | Night Watch uses Claude as primary provider | Tool showcase | -| r/selfhosted | ~400k | CLI tool you run on your own infra | Project announcement | -| r/opensource | ~50k | It's open source on GitHub | Launch post | +| Subreddit | Members | Why | Post type | +| --------------- | ------- | ------------------------------------------- | -------------------- | +| r/SideProject | ~200k | Built for show-and-tell of indie tools | Show-off / Launch | +| r/ChatGPTCoding | ~300k | AI-assisted coding is the core topic | Tutorial / Demo | +| r/ClaudeAI | ~150k | Night Watch uses Claude as primary provider | Tool showcase | +| r/selfhosted | ~400k | CLI tool you run on your own infra | Project announcement | +| r/opensource | ~50k | It's open source on GitHub | Launch post | ### Tier 2 — Good fit, post after Tier 1 -| Subreddit | Members | Why | Post type | -|---|---|---|---| -| r/programming | ~6M | General dev audience, use sparingly | Link post to blog/landing | -| r/webdev | ~2M | Many solo devs / small teams here | Show-off | -| r/devops | ~300k | Cron-driven automation, CI integration | Tool announcement | -| r/github | ~30k | GitHub Projects integration is a key feature | Demo | -| r/solopreneur | ~100k | "Your overnight engineering team" resonates | Story post | -| r/indiehackers | ~100k | Solo founder building with AI | Behind-the-scenes | +| Subreddit | Members | Why | Post type | +| -------------- | ------- | -------------------------------------------- | ------------------------- | +| r/programming | ~6M | General dev audience, use sparingly | Link post to blog/landing | +| r/webdev | ~2M | Many solo devs / small teams here | Show-off | +| r/devops | ~300k | Cron-driven automation, CI integration | Tool announcement | +| r/github | ~30k | GitHub Projects integration is a key feature | Demo | +| r/solopreneur | ~100k | "Your overnight engineering team" resonates | Story post | +| r/indiehackers | ~100k | Solo founder building with AI | Behind-the-scenes | ### Tier 3 — Niche, optional -| Subreddit | Members | Why | Post type | -|---|---|---|---| -| r/node | ~200k | Built with Node/TypeScript | Project post | -| r/typescript | ~100k | TypeScript monorepo, tsyringe DI | Project post | -| r/MachineLearning | ~3M | AI agents in production | Discussion | -| r/artificial | ~200k | Practical AI agent use case | Discussion | +| Subreddit | Members | Why | Post type | +| ----------------- | ------- | -------------------------------- | ------------ | +| r/node | ~200k | Built with Node/TypeScript | Project post | +| r/typescript | ~100k | TypeScript monorepo, tsyringe DI | Project post | +| r/MachineLearning | ~3M | AI agents in production | Discussion | +| r/artificial | ~200k | Practical AI agent use case | Discussion | --- @@ -47,6 +47,7 @@ ### Template A — Show-off / Launch (r/SideProject, r/opensource, r/webdev) **Title options (pick one):** + - "I built a CLI that runs Claude/Codex on a schedule and opens PRs while I sleep" - "Night Watch: queue up tasks during the day, wake up to PRs in the morning" - "I got tired of context-switching, so I built a cron-based orchestrator for Claude/Codex" @@ -123,6 +124,7 @@ Would love feedback, especially from anyone who's experimented with automating p ### Template B — Tutorial / How-I-Use-It (r/ChatGPTCoding, r/ClaudeAI) **Title options:** + - "How I use Claude to implement my entire backlog overnight (Night Watch CLI)" - "I set up an AI agent pipeline that writes code, reviews PRs, and runs tests on autopilot" - "My setup: Claude Code + cron + GitHub Projects = overnight AI engineering team" @@ -157,8 +159,10 @@ I wake up to Telegram notifications with PR links. **Setup takes ~2 minutes:** ``` + npx @jonit-dev/night-watch-cli init -night-watch install # sets up cron +night-watch install # sets up cron + ``` It supports Claude and Codex as providers, with automatic rate-limit fallback. You can configure per-job providers (e.g., Codex for execution, Claude for reviews). @@ -175,6 +179,7 @@ Happy to answer questions about the architecture or share more about how I use i ### Template C — Technical / DevOps angle (r/devops, r/selfhosted) **Title options:** + - "Night Watch — cron-driven AI agent pipeline for automated PRs, reviews, and QA" - "Open-source CLI that turns GitHub Projects into an overnight AI execution pipeline" @@ -194,7 +199,9 @@ Built an open-source CLI tool for automating software engineering tasks on a sch **Agent pipeline:** ``` + Slicer (roadmap → PRDs) → Executor (PRD → PR) → Reviewer (PR → score/fix) → QA (PR → e2e tests) → Auto-merge + ``` **Key design decisions:** @@ -209,7 +216,9 @@ Slicer (roadmap → PRDs) → Executor (PRD → PR) → Reviewer (PR → score/f [IMAGE_8: Cron entries installed by `night-watch install`] ``` + npx @jonit-dev/night-watch-cli init + ``` GitHub: (repo link) @@ -222,6 +231,7 @@ Looking for feedback on the scheduling model and the review scoring approach. ### Template D — Story / Founder angle (r/solopreneur, r/indiehackers) **Title options:** + - "I built an AI 'night shift' for my codebase — it does my backlog while I sleep" - "As a solo dev, I needed more hands. So I built an AI team that works overnight" @@ -268,29 +278,31 @@ Anyone else automating parts of their dev workflow? Curious what's working for o Replace these with actual screenshots before posting: -| Placeholder | What to capture | -|---|---| -| `[IMAGE_1]` | Web dashboard — jobs list or overview page | -| `[IMAGE_2]` | Terminal: successful `night-watch run` output showing PR URL | -| `[IMAGE_3]` | GitHub PR opened by Night Watch — show title, description, review score comment | -| `[IMAGE_4]` | GitHub Projects board with columns (Draft / Ready / In Progress / Review / Done) | -| `[IMAGE_5]` | Telegram notification — successful run with PR summary | -| `[IMAGE_6]` | Web dashboard — run history or analytics view | -| `[IMAGE_7]` | Architecture diagram or agent pipeline flow chart | -| `[IMAGE_8]` | Terminal: `night-watch install` output showing cron entries | -| `[IMAGE_9]` | GitHub Projects board before/after (items moved to Done) | -| `[IMAGE_10]` | Morning Telegram batch — multiple completed PR notifications | +| Placeholder | What to capture | +| ------------ | -------------------------------------------------------------------------------- | +| `[IMAGE_1]` | Web dashboard — jobs list or overview page | +| `[IMAGE_2]` | Terminal: successful `night-watch run` output showing PR URL | +| `[IMAGE_3]` | GitHub PR opened by Night Watch — show title, description, review score comment | +| `[IMAGE_4]` | GitHub Projects board with columns (Draft / Ready / In Progress / Review / Done) | +| `[IMAGE_5]` | Telegram notification — successful run with PR summary | +| `[IMAGE_6]` | Web dashboard — run history or analytics view | +| `[IMAGE_7]` | Architecture diagram or agent pipeline flow chart | +| `[IMAGE_8]` | Terminal: `night-watch install` output showing cron entries | +| `[IMAGE_9]` | GitHub Projects board before/after (items moved to Done) | +| `[IMAGE_10]` | Morning Telegram batch — multiple completed PR notifications | --- ## Posting Strategy ### Timing + - Post between **9–11 AM EST** on **Tuesday, Wednesday, or Thursday** (peak Reddit engagement) - Space posts across subreddits: 1–2 per day over a week - Don't cross-post — write slightly different titles/angles for each sub ### Order + 1. **Day 1:** r/SideProject (Template A) + r/ClaudeAI (Template B) 2. **Day 2:** r/ChatGPTCoding (Template B) + r/opensource (Template A) 3. **Day 3:** r/selfhosted (Template C) + r/webdev (Template A) @@ -298,6 +310,7 @@ Replace these with actual screenshots before posting: 5. **Day 5:** r/programming (link post) + r/indiehackers (Template D) ### Engagement rules + - Reply to every comment within the first 2 hours - Be honest about limitations — "it works great for scoped tasks, not for vague requests" - Don't be defensive about AI criticism — acknowledge valid concerns @@ -305,6 +318,7 @@ Replace these with actual screenshots before posting: - If someone asks "how is this different from X" — answer directly, don't dodge ### What to avoid + - Don't say "game-changer" or "revolutionary" - Don't post to subreddits that ban self-promotion (check rules first) - Don't use multiple accounts or ask friends to upvote diff --git a/instructions/prd-executor.md b/instructions/prd-executor.md index d03019e8..702a6086 100644 --- a/instructions/prd-executor.md +++ b/instructions/prd-executor.md @@ -275,3 +275,4 @@ If two parallel agents modify the same file: - **Full context** - each agent gets the complete phase spec + project rules - **Verify always** - never skip yarn verify between waves - **Task tracking** - every phase is a tracked task with status updates +- **Clean up worktrees** - after opening a PR for a worktree branch, immediately remove the worktree to avoid blocking future `git checkout` operations diff --git a/landing-page/README.md b/landing-page/README.md index 2935392a..d8a18650 100644 --- a/landing-page/README.md +++ b/landing-page/README.md @@ -10,8 +10,7 @@ View your app in AI Studio: https://ai.studio/apps/1792d0eb-0443-4b50-981a-c8366 ## Run Locally -**Prerequisites:** Node.js - +**Prerequisites:** Node.js 1. Install dependencies: `npm install` diff --git a/landing-page/src/components/AgentAnimation.tsx b/landing-page/src/components/AgentAnimation.tsx index 0818de88..07064fa2 100644 --- a/landing-page/src/components/AgentAnimation.tsx +++ b/landing-page/src/components/AgentAnimation.tsx @@ -1,35 +1,66 @@ -import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion'; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion'; import { Player } from '@remotion/player'; -import { Terminal, CheckSquare, GitPullRequest, CheckCircle2, Code2 } from 'lucide-react'; - -const KanbanColumn = ({ title, x }: { title: string, x: number }) => ( -
+import { CheckCircle2, CheckSquare, Code2, GitPullRequest, Terminal } from 'lucide-react'; + +const KanbanColumn = ({ title, x }: { title: string; x: number }) => ( +
{title}
); const Ticket = ({ frame }: { frame: number }) => { - const x = interpolate(frame, - [50, 80, 140, 170, 230, 260], - [30, 230, 230, 430, 430, 630], - { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } - ); + const x = interpolate(frame, [50, 80, 140, 170, 230, 260], [30, 230, 230, 430, 430, 630], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); const isCoding = frame > 80 && frame < 140; const isReviewing = frame > 190 && frame < 230; const isDone = frame >= 260; - + const pulseOpacity = interpolate(Math.sin(frame / 3), [-1, 1], [0.3, 1]); return ( -
-
Auth Flow
+
+
+ Auth Flow +
-
#42
+
+ #42 +
{isCoding && } {isReviewing && } {isDone && } @@ -39,50 +70,112 @@ const Ticket = ({ frame }: { frame: number }) => { }; const ExecutorAgent = ({ frame }: { frame: number }) => { - const opacity = interpolate(frame, [20, 30, 170, 180], [0, 1, 1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); - const x = interpolate(frame, [50, 80, 140, 170], [30, 230, 230, 430], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); - const y = interpolate(frame, [20, 30], [150, 130], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); + const opacity = interpolate(frame, [20, 30, 170, 180], [0, 1, 1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const x = interpolate(frame, [50, 80, 140, 170], [30, 230, 230, 430], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const y = interpolate(frame, [20, 30], [150, 130], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); return ( -
+
Executor
); -} +}; const ReviewerAgent = ({ frame }: { frame: number }) => { - const opacity = interpolate(frame, [170, 180, 270, 280], [0, 1, 1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); - const x = interpolate(frame, [230, 260], [430, 630], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); - const y = interpolate(frame, [170, 180], [150, 130], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); + const opacity = interpolate(frame, [170, 180, 270, 280], [0, 1, 1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const x = interpolate(frame, [230, 260], [430, 630], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const y = interpolate(frame, [170, 180], [150, 130], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); return ( -
+
Reviewer
); -} +}; const PRBadge = ({ frame }: { frame: number }) => { const { fps } = useVideoConfig(); const scale = spring({ frame: frame - 190, fps, config: { damping: 12 } }); - const opacity = interpolate(frame, [250, 260], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); + const opacity = interpolate(frame, [250, 260], [1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); if (frame < 190) return null; return ( -
+
PR #43 Opened
); -} +}; const MergedBadge = ({ frame }: { frame: number }) => { const { fps } = useVideoConfig(); @@ -91,17 +184,29 @@ const MergedBadge = ({ frame }: { frame: number }) => { if (frame < 260) return null; return ( -
+
Merged
); -} +}; export const AgentComposition = () => { const frame = useCurrentFrame(); diff --git a/landing-page/src/components/Agents.tsx b/landing-page/src/components/Agents.tsx index 944da429..82619c25 100644 --- a/landing-page/src/components/Agents.tsx +++ b/landing-page/src/components/Agents.tsx @@ -1,52 +1,52 @@ -import { Terminal, CheckSquare, Activity, Search, Scissors } from 'lucide-react'; +import { Activity, CheckSquare, Scissors, Search, Terminal } from 'lucide-react'; import { motion } from 'motion/react'; const agents = [ { - name: "Executor", - role: "Implements specs as code, opens PRs", - schedule: "Hourly", + name: 'Executor', + role: 'Implements specs as code, opens PRs', + schedule: 'Hourly', icon: Terminal, - color: "bg-indigo-500", - glow: "group-hover:shadow-[0_0_30px_-5px_rgba(99,102,241,0.3)]", - hoverText: "Branch created: feat/auth-flow" + color: 'bg-indigo-500', + glow: 'group-hover:shadow-[0_0_30px_-5px_rgba(99,102,241,0.3)]', + hoverText: 'Branch created: feat/auth-flow', }, { - name: "Reviewer", - role: "Scores PRs, requests fixes, auto-merges", - schedule: "Every 3 hours", + name: 'Reviewer', + role: 'Scores PRs, requests fixes, auto-merges', + schedule: 'Every 3 hours', icon: CheckSquare, - color: "bg-emerald-500", - glow: "group-hover:shadow-[0_0_30px_-5px_rgba(16,185,129,0.3)]", - hoverText: "Score: 87/100 — ready to merge" + color: 'bg-emerald-500', + glow: 'group-hover:shadow-[0_0_30px_-5px_rgba(16,185,129,0.3)]', + hoverText: 'Score: 87/100 — ready to merge', }, { - name: "QA", - role: "Generates and runs Playwright e2e tests", - schedule: "4x daily", + name: 'QA', + role: 'Generates and runs Playwright e2e tests', + schedule: '4x daily', icon: Activity, - color: "bg-amber-500", - glow: "group-hover:shadow-[0_0_30px_-5px_rgba(245,158,11,0.3)]", - hoverText: "3 tests passed, 0 failed" + color: 'bg-amber-500', + glow: 'group-hover:shadow-[0_0_30px_-5px_rgba(245,158,11,0.3)]', + hoverText: '3 tests passed, 0 failed', }, { - name: "Auditor", - role: "Scans codebase for quality issues", - schedule: "Weekly", + name: 'Auditor', + role: 'Scans codebase for quality issues', + schedule: 'Weekly', icon: Search, - color: "bg-purple-500", - glow: "group-hover:shadow-[0_0_30px_-5px_rgba(168,85,247,0.3)]", - hoverText: "Found 2 unused dependencies" + color: 'bg-purple-500', + glow: 'group-hover:shadow-[0_0_30px_-5px_rgba(168,85,247,0.3)]', + hoverText: 'Found 2 unused dependencies', }, { - name: "Slicer", - role: "Breaks roadmap items into granular specs", - schedule: "Every 6 hours", + name: 'Slicer', + role: 'Breaks roadmap items into granular specs', + schedule: 'Every 6 hours', icon: Scissors, - color: "bg-pink-500", - glow: "group-hover:shadow-[0_0_30px_-5px_rgba(236,72,153,0.3)]", - hoverText: "Split epic into 4 sub-tasks" - } + color: 'bg-pink-500', + glow: 'group-hover:shadow-[0_0_30px_-5px_rgba(236,72,153,0.3)]', + hoverText: 'Split epic into 4 sub-tasks', + }, ]; export function Agents() { @@ -56,12 +56,14 @@ export function Agents() {

Five agents. One closed loop.

-

Specialized agents working together to move tickets from backlog to production.

+

+ Specialized agents working together to move tickets from backlog to production. +

{agents.map((agent, index) => ( - -
+
@@ -82,10 +86,12 @@ export function Agents() {

{agent.name}

{agent.role}

- +
-

{'>'} {agent.hoverText}

+

+ {'>'} {agent.hoverText} +

diff --git a/landing-page/src/components/BackgroundEffects.tsx b/landing-page/src/components/BackgroundEffects.tsx index c0bfa5e4..6c3d1f7d 100644 --- a/landing-page/src/components/BackgroundEffects.tsx +++ b/landing-page/src/components/BackgroundEffects.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; -function StarLayer({ count, size, className }: { count: number, size: number, className: string }) { - const [stars, setStars] = useState<{x: number, y: number, opacity: number}[]>([]); - +function StarLayer({ count, size, className }: { count: number; size: number; className: string }) { + const [stars, setStars] = useState<{ x: number; y: number; opacity: number }[]>([]); + useEffect(() => { const newStars = Array.from({ length: count }).map(() => ({ x: Math.random() * 100, y: Math.random() * 100, - opacity: Math.random() * 0.8 + 0.2 + opacity: Math.random() * 0.8 + 0.2, })); setStars(newStars); }, [count]); @@ -15,7 +15,7 @@ function StarLayer({ count, size, className }: { count: number, size: number, cl return (
{stars.map((star, i) => ( -
{/* Grid - faded at top to show sky */} -
- + {/* Glowing Orbs / Aurora */}
- + {/* Low Fog */}
- + {/* Vignette */}
diff --git a/landing-page/src/components/DashboardPreview.tsx b/landing-page/src/components/DashboardPreview.tsx index 01afbd37..bf2fc2aa 100644 --- a/landing-page/src/components/DashboardPreview.tsx +++ b/landing-page/src/components/DashboardPreview.tsx @@ -5,11 +5,15 @@ export function DashboardPreview() {
-

Full visibility into your night shift

-

Web dashboard included. Real-time updates via SSE. No extra hosting required.

+

+ Full visibility into your night shift +

+

+ Web dashboard included. Real-time updates via SSE. No extra hosting required. +

- {/* Massive glow behind dashboard */}
- +
- +
{/* Fake Browser Header */}
@@ -34,45 +38,67 @@ export function DashboardPreview() { localhost:3000
- + {/* Fake Dashboard Content */}
{/* Sidebar */}
-
Board
-
Pull Requests
-
Agents
-
Settings
+
+ Board +
+
+ Pull Requests +
+
+ Agents +
+
+ Settings +
- + {/* Main Area */}
{/* Agent Status Bar */}
{['Executor', 'Reviewer', 'QA', 'Auditor', 'Slicer'].map((agent, i) => ( -
+
{agent}
-
-
{i === 0 ? 'Running...' : 'Idle'}
+
+
+ {i === 0 ? 'Running...' : 'Idle'} +
))}
- + {/* Kanban Board Fake */}
{['Ready', 'In Progress', 'Review', 'Done'].map((col, i) => ( -
-
{col}
+
+
+ {col} +
{i === 0 && ( <>
Add dark mode toggle
#45 - P2 + + P2 +
@@ -82,7 +108,9 @@ export function DashboardPreview() {
-
Implement user settings
+
+ Implement user settings +
#42 @@ -97,7 +125,9 @@ export function DashboardPreview() {
Fix navigation bug
PR #16 - Score: 92 + + Score: 92 +
)} diff --git a/landing-page/src/components/Fit.tsx b/landing-page/src/components/Fit.tsx index 481f8049..07a42cf5 100644 --- a/landing-page/src/components/Fit.tsx +++ b/landing-page/src/components/Fit.tsx @@ -6,7 +6,7 @@ export function Fit() {
-
    {[ - "You already use structured specs, PRDs, or queued board items", - "You want async execution, not another pair-programming UI", - "Your work can be broken into small, reviewable pull requests", - "You care about overnight throughput on bounded tasks" + 'You already use structured specs, PRDs, or queued board items', + 'You want async execution, not another pair-programming UI', + 'Your work can be broken into small, reviewable pull requests', + 'You care about overnight throughput on bounded tasks', ].map((text, i) => (
  • @@ -31,7 +31,7 @@ export function Fit() {
-
    {[ - "Work starts vague and gets clarified only during implementation", - "Your team is not comfortable reviewing AI-generated pull requests", - "You want a general-purpose AI coding assistant" + 'Work starts vague and gets clarified only during implementation', + 'Your team is not comfortable reviewing AI-generated pull requests', + 'You want a general-purpose AI coding assistant', ].map((text, i) => (
  • diff --git a/landing-page/src/components/Footer.tsx b/landing-page/src/components/Footer.tsx index 68401c8d..c0c419d6 100644 --- a/landing-page/src/components/Footer.tsx +++ b/landing-page/src/components/Footer.tsx @@ -1,4 +1,4 @@ -import { MoonStar, Github } from 'lucide-react'; +import { Github, MoonStar } from 'lucide-react'; export function Footer() { return ( @@ -11,20 +11,47 @@ export function Footer() {
- Night Watch + + Night Watch + - + - +
-

Built by jonit-dev

+

+ Built by{' '} + + jonit-dev + +

Night Watch is open source. MIT licensed.

diff --git a/landing-page/src/components/Hero.tsx b/landing-page/src/components/Hero.tsx index eba23577..00bb2c0e 100644 --- a/landing-page/src/components/Hero.tsx +++ b/landing-page/src/components/Hero.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Copy, Check, ChevronDown } from 'lucide-react'; +import { Check, Copy } from 'lucide-react'; import { motion } from 'motion/react'; import { AgentAnimationPlayer } from './AgentAnimation'; @@ -28,44 +28,57 @@ export function Hero() { v1.0 is now live - - Your repo's night shift. + Your repo's{' '} + + night shift. + - - - Define work during the day. Night Watch executes overnight. Wake up to pull requests, reviewed code, and tested features. + Define work during the day. Night Watch executes overnight. Wake up to pull requests, + reviewed code, and tested features. - - - + Read the docs - {/* Glow effect behind player */}
- +
@@ -87,11 +100,16 @@ export function Hero() { transition={{ delay: 1.2, duration: 1 }} className="absolute bottom-8 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-2" > - Scroll - - + Scroll + + + diff --git a/landing-page/src/components/HowItWorks.tsx b/landing-page/src/components/HowItWorks.tsx index c461a799..a3ff981b 100644 --- a/landing-page/src/components/HowItWorks.tsx +++ b/landing-page/src/components/HowItWorks.tsx @@ -2,25 +2,27 @@ import { motion } from 'motion/react'; const steps = [ { - number: "01", - title: "Define work", - description: "Create a GitHub issue or write a PRD. Mark it as 'Ready' on your project board." + number: '01', + title: 'Define work', + description: "Create a GitHub issue or write a PRD. Mark it as 'Ready' on your project board.", }, { - number: "02", - title: "Night Watch picks it up", - description: "The executor claims the next issue, creates a worktree, and implements the spec." + number: '02', + title: 'Night Watch picks it up', + description: 'The executor claims the next issue, creates a worktree, and implements the spec.', }, { - number: "03", - title: "Automated review cycle", - description: "The reviewer scores the PR, requests fixes, and retries. QA generates and runs e2e tests." + number: '03', + title: 'Automated review cycle', + description: + 'The reviewer scores the PR, requests fixes, and retries. QA generates and runs e2e tests.', }, { - number: "04", - title: "You wake up to PRs", - description: "Review, approve, merge. Or let auto-merge handle it when the score is high enough." - } + number: '04', + title: 'You wake up to PRs', + description: + 'Review, approve, merge. Or let auto-merge handle it when the score is high enough.', + }, ]; export function HowItWorks() { @@ -28,13 +30,17 @@ export function HowItWorks() {
-

From spec to merged PR — while you sleep

-

A fully autonomous pipeline that respects your workflow.

+

+ From spec to merged PR — while you sleep +

+

+ A fully autonomous pipeline that respects your workflow. +

{steps.map((step, index) => ( - {step.number}

{step.title}

{step.description}

- + {index < steps.length - 1 && (
)} diff --git a/landing-page/src/components/Navbar.tsx b/landing-page/src/components/Navbar.tsx index 05b1db76..8dd7278b 100644 --- a/landing-page/src/components/Navbar.tsx +++ b/landing-page/src/components/Navbar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MoonStar, Github, Menu, X } from 'lucide-react'; +import { Github, Menu, MoonStar, X } from 'lucide-react'; export function Navbar() { const [isOpen, setIsOpen] = useState(false); @@ -14,29 +14,52 @@ export function Navbar() {
- Night Watch + + Night Watch + - + {/* Desktop Nav */} - + {/* Mobile Menu Toggle */} -
diff --git a/landing-page/src/components/ValueProps.tsx b/landing-page/src/components/ValueProps.tsx index edd21a34..d7a7fc28 100644 --- a/landing-page/src/components/ValueProps.tsx +++ b/landing-page/src/components/ValueProps.tsx @@ -4,19 +4,19 @@ import { motion } from 'motion/react'; const props = [ { icon: Clock, - title: "Async-first", - description: "Not pair-programming. Queued execution while you sleep." + title: 'Async-first', + description: 'Not pair-programming. Queued execution while you sleep.', }, { icon: GitBranch, - title: "Safe isolation", - description: "Every task runs in its own git worktree. Your main branch stays clean." + title: 'Safe isolation', + description: 'Every task runs in its own git worktree. Your main branch stays clean.', }, { icon: ShieldCheck, - title: "Human-in-the-loop", - description: "You review every PR. Configurable trust dials control auto-merge." - } + title: 'Human-in-the-loop', + description: 'You review every PR. Configurable trust dials control auto-merge.', + }, ]; export function ValueProps() { @@ -26,7 +26,7 @@ export function ValueProps() {
{props.map((prop, index) => ( - { +export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); return { plugins: [react(), tailwindcss()], diff --git a/packages/cli/src/__tests__/commands/doctor.test.ts b/packages/cli/src/__tests__/commands/doctor.test.ts index 44982ea0..816e30fe 100644 --- a/packages/cli/src/__tests__/commands/doctor.test.ts +++ b/packages/cli/src/__tests__/commands/doctor.test.ts @@ -2,14 +2,14 @@ * Tests for doctor command — validateWebhook and CLI */ -import { describe, it, expect } from "vitest"; -import { execSync } from "child_process"; -import path from "path"; -import { validateWebhook } from "@/cli/commands/doctor.js"; -import { IWebhookConfig } from "@night-watch/core/types.js"; +import { describe, it, expect } from 'vitest'; +import { execSync } from 'child_process'; +import path from 'path'; +import { validateWebhook } from '@/cli/commands/doctor.js'; +import { IWebhookConfig } from '@night-watch/core/types.js'; -const CLI_PATH = path.join(process.cwd(), "dist/cli.js"); -const TSX_PATH = "node"; +const CLI_PATH = path.join(process.cwd(), 'dist/cli.js'); +const TSX_PATH = 'node'; // Cache CLI outputs to avoid repeated spawns let cachedMainHelp: string | null = null; @@ -19,7 +19,7 @@ let cachedDoctorOutput: string | null = null; function getMainHelp(): string { if (!cachedMainHelp) { cachedMainHelp = execSync(`${TSX_PATH} ${CLI_PATH} --help`, { - encoding: "utf-8", + encoding: 'utf-8', cwd: process.cwd(), timeout: 10000, }); @@ -30,7 +30,7 @@ function getMainHelp(): string { function getDoctorHelp(): string { if (!cachedDoctorHelp) { cachedDoctorHelp = execSync(`${TSX_PATH} ${CLI_PATH} doctor --help`, { - encoding: "utf-8", + encoding: 'utf-8', cwd: process.cwd(), timeout: 10000, }); @@ -40,220 +40,220 @@ function getDoctorHelp(): string { function getDoctorOutput(): string { if (!cachedDoctorOutput) { - const repoRoot = path.resolve(process.cwd(), "..", ".."); + const repoRoot = path.resolve(process.cwd(), '..', '..'); try { cachedDoctorOutput = execSync(`${TSX_PATH} ${CLI_PATH} doctor`, { - encoding: "utf-8", + encoding: 'utf-8', cwd: repoRoot, - stdio: "pipe", + stdio: 'pipe', timeout: 10000, }); } catch (error) { const err = error as { stdout?: string }; - cachedDoctorOutput = err.stdout || ""; + cachedDoctorOutput = err.stdout || ''; } } return cachedDoctorOutput; } -describe("doctor command", () => { - describe("validateWebhook", () => { - it("should pass valid slack webhook", () => { +describe('doctor command', () => { + describe('validateWebhook', () => { + it('should pass valid slack webhook', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://hooks.slack.com/services/T00/B00/xxx", - events: ["run_succeeded", "run_failed"], + type: 'slack', + url: 'https://hooks.slack.com/services/T00/B00/xxx', + events: ['run_succeeded', 'run_failed'], }; expect(validateWebhook(webhook)).toEqual([]); }); - it("should fail slack webhook with invalid URL", () => { + it('should fail slack webhook with invalid URL', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://example.com/webhook", - events: ["run_failed"], + type: 'slack', + url: 'https://example.com/webhook', + events: ['run_failed'], }; const issues = validateWebhook(webhook); expect(issues.length).toBeGreaterThan(0); - expect(issues[0]).toContain("hooks.slack.com"); + expect(issues[0]).toContain('hooks.slack.com'); }); - it("should fail slack webhook with missing URL", () => { + it('should fail slack webhook with missing URL', () => { const webhook: IWebhookConfig = { - type: "slack", - events: ["run_failed"], + type: 'slack', + events: ['run_failed'], }; const issues = validateWebhook(webhook); - expect(issues).toContain("Missing URL"); + expect(issues).toContain('Missing URL'); }); - it("should pass valid discord webhook", () => { + it('should pass valid discord webhook', () => { const webhook: IWebhookConfig = { - type: "discord", - url: "https://discord.com/api/webhooks/123/abc", - events: ["run_failed"], + type: 'discord', + url: 'https://discord.com/api/webhooks/123/abc', + events: ['run_failed'], }; expect(validateWebhook(webhook)).toEqual([]); }); - it("should fail discord webhook with invalid URL", () => { + it('should fail discord webhook with invalid URL', () => { const webhook: IWebhookConfig = { - type: "discord", - url: "https://example.com/hook", - events: ["run_failed"], + type: 'discord', + url: 'https://example.com/hook', + events: ['run_failed'], }; const issues = validateWebhook(webhook); expect(issues.length).toBeGreaterThan(0); - expect(issues[0]).toContain("discord.com/api/webhooks"); + expect(issues[0]).toContain('discord.com/api/webhooks'); }); - it("should fail discord webhook with missing URL", () => { + it('should fail discord webhook with missing URL', () => { const webhook: IWebhookConfig = { - type: "discord", - events: ["run_failed"], + type: 'discord', + events: ['run_failed'], }; const issues = validateWebhook(webhook); - expect(issues).toContain("Missing URL"); + expect(issues).toContain('Missing URL'); }); - it("should pass valid telegram webhook", () => { + it('should pass valid telegram webhook', () => { const webhook: IWebhookConfig = { - type: "telegram", - botToken: "123456:ABC-DEF", - chatId: "-1001234567890", - events: ["run_failed"], + type: 'telegram', + botToken: '123456:ABC-DEF', + chatId: '-1001234567890', + events: ['run_failed'], }; expect(validateWebhook(webhook)).toEqual([]); }); - it("should fail telegram without botToken", () => { + it('should fail telegram without botToken', () => { const webhook: IWebhookConfig = { - type: "telegram", - chatId: "-100123", - events: ["run_failed"], + type: 'telegram', + chatId: '-100123', + events: ['run_failed'], }; const issues = validateWebhook(webhook); - expect(issues).toContain("Missing botToken"); + expect(issues).toContain('Missing botToken'); }); - it("should fail telegram without chatId", () => { + it('should fail telegram without chatId', () => { const webhook: IWebhookConfig = { - type: "telegram", - botToken: "123:ABC", - events: ["run_failed"], + type: 'telegram', + botToken: '123:ABC', + events: ['run_failed'], }; const issues = validateWebhook(webhook); - expect(issues).toContain("Missing chatId"); + expect(issues).toContain('Missing chatId'); }); - it("should fail telegram without both botToken and chatId", () => { + it('should fail telegram without both botToken and chatId', () => { const webhook: IWebhookConfig = { - type: "telegram", - events: ["run_failed"], + type: 'telegram', + events: ['run_failed'], }; const issues = validateWebhook(webhook); - expect(issues).toContain("Missing botToken"); - expect(issues).toContain("Missing chatId"); + expect(issues).toContain('Missing botToken'); + expect(issues).toContain('Missing chatId'); }); - it("should fail with no events configured", () => { + it('should fail with no events configured', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://hooks.slack.com/services/T00/B00/xxx", + type: 'slack', + url: 'https://hooks.slack.com/services/T00/B00/xxx', events: [], }; const issues = validateWebhook(webhook); - expect(issues).toContain("No events configured"); + expect(issues).toContain('No events configured'); }); - it("should fail with invalid event name", () => { + it('should fail with invalid event name', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://hooks.slack.com/services/T00/B00/xxx", - events: ["invalid_event" as any], + type: 'slack', + url: 'https://hooks.slack.com/services/T00/B00/xxx', + events: ['invalid_event' as any], }; const issues = validateWebhook(webhook); expect(issues.length).toBeGreaterThan(0); - expect(issues[0]).toContain("Invalid event"); + expect(issues[0]).toContain('Invalid event'); }); - it("should fail with unknown webhook type", () => { + it('should fail with unknown webhook type', () => { const webhook: IWebhookConfig = { - type: "teams" as any, - url: "https://example.com/webhook", - events: ["run_failed"], + type: 'teams' as any, + url: 'https://example.com/webhook', + events: ['run_failed'], }; const issues = validateWebhook(webhook); expect(issues.length).toBeGreaterThan(0); - expect(issues[0]).toContain("Unknown webhook type"); + expect(issues[0]).toContain('Unknown webhook type'); }); - it("should report multiple issues at once", () => { + it('should report multiple issues at once', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://example.com/bad-url", - events: ["invalid_event" as any], + type: 'slack', + url: 'https://example.com/bad-url', + events: ['invalid_event' as any], }; const issues = validateWebhook(webhook); // Should have both an invalid event issue and a bad URL issue expect(issues.length).toBe(2); - expect(issues.some((i) => i.includes("Invalid event"))).toBe(true); - expect(issues.some((i) => i.includes("hooks.slack.com"))).toBe(true); + expect(issues.some((i) => i.includes('Invalid event'))).toBe(true); + expect(issues.some((i) => i.includes('hooks.slack.com'))).toBe(true); }); - it("should accept all valid event types", () => { + it('should accept all valid event types', () => { const webhook: IWebhookConfig = { - type: "slack", - url: "https://hooks.slack.com/services/T00/B00/xxx", + type: 'slack', + url: 'https://hooks.slack.com/services/T00/B00/xxx', events: [ - "run_started", - "run_succeeded", - "run_failed", - "run_timeout", - "review_completed", - "pr_auto_merged", - "rate_limit_fallback", - "qa_completed", + 'run_started', + 'run_succeeded', + 'run_failed', + 'run_timeout', + 'review_completed', + 'pr_auto_merged', + 'rate_limit_fallback', + 'qa_completed', ], }; expect(validateWebhook(webhook)).toEqual([]); }); }); - describe("CLI", () => { - it("should show doctor command in help", () => { + describe('CLI', () => { + it('should show doctor command in help', () => { const output = getMainHelp(); - expect(output).toContain("doctor"); + expect(output).toContain('doctor'); }); - it("should show help text with --fix option", () => { + it('should show help text with --fix option', () => { const output = getDoctorHelp(); - expect(output).toContain("Check Night Watch configuration"); - expect(output).toContain("--fix"); + expect(output).toContain('Check Night Watch configuration'); + expect(output).toContain('--fix'); }); - it("should run all checks and show pass/fail indicators", () => { + it('should run all checks and show pass/fail indicators', () => { const output = getDoctorOutput(); // Should show check names - expect(output).toContain("Node.js version"); - expect(output).toContain("git repository"); - expect(output).toContain("GitHub CLI"); - expect(output).toContain("provider CLI"); - expect(output).toContain("config file"); - expect(output).toContain("logs directory"); - expect(output).toContain("webhook configuration"); + expect(output).toContain('Node.js version'); + expect(output).toContain('git repository'); + expect(output).toContain('GitHub CLI'); + expect(output).toContain('provider CLI'); + expect(output).toContain('config file'); + expect(output).toContain('logs directory'); + expect(output).toContain('webhook configuration'); // Should show summary - expect(output).toContain("Summary"); - expect(output).toContain("Checks passed"); + expect(output).toContain('Summary'); + expect(output).toContain('Checks passed'); }); - it("should show git repo check success in project dir", () => { + it('should show git repo check success in project dir', () => { const output = getDoctorOutput(); // This project IS a git repo, so should pass - expect(output).toContain("Git repository found"); + expect(output).toContain('Git repository found'); }); }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 59dccf6e..67bd87be 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -917,12 +917,14 @@ export function initCommand(program: Command): void { filesTable.push(['Config File', CONFIG_FILE_NAME]); filesTable.push(['Board Setup', boardSetupStatus]); filesTable.push(['Global Registry', '~/.night-watch/projects.json']); - const skillsSummary = - skillsResult.installed > 0 - ? `${skillsResult.installed} skills → ${skillsResult.location}` - : skillsResult.skipped > 0 - ? `Already installed (${skillsResult.location})` - : 'Skipped'; + let skillsSummary: string; + if (skillsResult.installed > 0) { + skillsSummary = `${skillsResult.installed} skills → ${skillsResult.location}`; + } else if (skillsResult.skipped > 0) { + skillsSummary = `Already installed (${skillsResult.location})`; + } else { + skillsSummary = 'Skipped'; + } filesTable.push(['Skills', skillsSummary]); console.log(filesTable.toString()); diff --git a/packages/core/src/__tests__/config-normalize.test.ts b/packages/core/src/__tests__/config-normalize.test.ts index 9ff20c22..f90bcfec 100644 --- a/packages/core/src/__tests__/config-normalize.test.ts +++ b/packages/core/src/__tests__/config-normalize.test.ts @@ -186,6 +186,64 @@ describe('config-normalize', () => { // Both should be skipped expect(normalized.providerPresets).toBeUndefined(); }); + }); +}); + +describe('normalizeConfig - registry-driven job configs', () => { + it('normalizes qa config via registry', () => { + const normalized = normalizeConfig({ + qa: { + enabled: false, + schedule: '0 12 * * *', + maxRuntime: 1800, + artifacts: 'screenshot', + skipLabel: 'no-qa', + autoInstallPlaywright: false, + branchPatterns: ['feat/'], + }, + }); + expect(normalized.qa?.enabled).toBe(false); + expect(normalized.qa?.schedule).toBe('0 12 * * *'); + expect(normalized.qa?.maxRuntime).toBe(1800); + expect((normalized.qa as Record)?.artifacts).toBe('screenshot'); + expect((normalized.qa as Record)?.skipLabel).toBe('no-qa'); + expect((normalized.qa as Record)?.autoInstallPlaywright).toBe(false); + expect((normalized.qa as Record)?.branchPatterns).toEqual(['feat/']); + }); + + it('normalizes audit config via registry', () => { + const normalized = normalizeConfig({ + audit: { enabled: false, schedule: '0 4 * * 0', maxRuntime: 900 }, + }); + expect(normalized.audit?.enabled).toBe(false); + expect(normalized.audit?.schedule).toBe('0 4 * * 0'); + expect(normalized.audit?.maxRuntime).toBe(900); + }); + + it('normalizes analytics config via registry', () => { + const normalized = normalizeConfig({ + analytics: { + enabled: true, + schedule: '0 8 * * 1', + maxRuntime: 600, + lookbackDays: 14, + targetColumn: 'Ready', + }, + }); + expect(normalized.analytics?.enabled).toBe(true); + expect((normalized.analytics as Record)?.lookbackDays).toBe(14); + expect((normalized.analytics as Record)?.targetColumn).toBe('Ready'); + }); + + it('applies qa defaults for missing fields', () => { + const normalized = normalizeConfig({ qa: {} }); + expect(normalized.qa?.enabled).toBe(true); + expect((normalized.qa as Record)?.artifacts).toBe('both'); + expect((normalized.qa as Record)?.autoInstallPlaywright).toBe(true); + }); + it('rejects invalid qa artifacts enum value', () => { + const normalized = normalizeConfig({ qa: { artifacts: 'invalid' } }); + expect((normalized.qa as Record)?.artifacts).toBe('both'); // falls back to default }); }); diff --git a/packages/core/src/__tests__/jobs/job-registry.test.ts b/packages/core/src/__tests__/jobs/job-registry.test.ts new file mode 100644 index 00000000..857a4ae0 --- /dev/null +++ b/packages/core/src/__tests__/jobs/job-registry.test.ts @@ -0,0 +1,415 @@ +/** + * Tests for the Job Registry + */ + +import { afterEach, describe, it, expect } from 'vitest'; +import { + JOB_REGISTRY, + getJobDef, + getJobDefByCommand, + getJobDefByLogName, + getValidJobTypes, + getDefaultQueuePriority, + getLogFileNames, + getLockSuffix, + getAllJobDefs, + normalizeJobConfig, + buildJobEnvOverrides, + camelToUpperSnake, +} from '../../jobs/job-registry.js'; +import { VALID_JOB_TYPES, DEFAULT_QUEUE_PRIORITY, LOG_FILE_NAMES } from '../../constants.js'; + +describe('JOB_REGISTRY', () => { + it('should define all 6 job types', () => { + expect(JOB_REGISTRY).toHaveLength(6); + }); + + it('should include executor, reviewer, qa, audit, slicer, analytics', () => { + const ids = JOB_REGISTRY.map((j) => j.id); + expect(ids).toContain('executor'); + expect(ids).toContain('reviewer'); + expect(ids).toContain('qa'); + expect(ids).toContain('audit'); + expect(ids).toContain('slicer'); + expect(ids).toContain('analytics'); + }); + + it('each job definition has required fields', () => { + for (const job of JOB_REGISTRY) { + expect(typeof job.id).toBe('string'); + expect(typeof job.name).toBe('string'); + expect(typeof job.description).toBe('string'); + expect(typeof job.cliCommand).toBe('string'); + expect(typeof job.logName).toBe('string'); + expect(typeof job.lockSuffix).toBe('string'); + expect(typeof job.queuePriority).toBe('number'); + expect(typeof job.envPrefix).toBe('string'); + expect(job.defaultConfig).toBeDefined(); + expect(typeof job.defaultConfig.enabled).toBe('boolean'); + expect(typeof job.defaultConfig.schedule).toBe('string'); + expect(typeof job.defaultConfig.maxRuntime).toBe('number'); + } + }); +}); + +describe('getJobDef', () => { + it('returns correct definition for executor', () => { + const def = getJobDef('executor'); + expect(def).toBeDefined(); + expect(def!.name).toBe('Executor'); + expect(def!.cliCommand).toBe('run'); + }); + + it('returns correct definition for qa', () => { + const def = getJobDef('qa'); + expect(def).toBeDefined(); + expect(def!.name).toBe('QA'); + }); + + it('returns correct definition for slicer', () => { + const def = getJobDef('slicer'); + expect(def).toBeDefined(); + expect(def!.cliCommand).toBe('planner'); + }); + + it('returns undefined for unknown job type', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(getJobDef('unknown' as any)).toBeUndefined(); + }); +}); + +describe('getJobDefByCommand', () => { + it('finds executor by "run" command', () => { + const def = getJobDefByCommand('run'); + expect(def?.id).toBe('executor'); + }); + + it('finds slicer by "planner" command', () => { + const def = getJobDefByCommand('planner'); + expect(def?.id).toBe('slicer'); + }); + + it('returns undefined for unknown command', () => { + expect(getJobDefByCommand('unknown')).toBeUndefined(); + }); +}); + +describe('getJobDefByLogName', () => { + it('finds qa by log name "night-watch-qa"', () => { + const def = getJobDefByLogName('night-watch-qa'); + expect(def?.id).toBe('qa'); + }); + + it('finds executor by log name "executor"', () => { + const def = getJobDefByLogName('executor'); + expect(def?.id).toBe('executor'); + }); +}); + +describe('getValidJobTypes', () => { + it('returns all 6 job types', () => { + const types = getValidJobTypes(); + expect(types).toHaveLength(6); + expect(types).toContain('executor'); + expect(types).toContain('reviewer'); + expect(types).toContain('qa'); + expect(types).toContain('audit'); + expect(types).toContain('slicer'); + expect(types).toContain('analytics'); + }); +}); + +describe('getDefaultQueuePriority', () => { + it('returns priority for all job types', () => { + const priority = getDefaultQueuePriority(); + expect(typeof priority.executor).toBe('number'); + expect(typeof priority.reviewer).toBe('number'); + expect(typeof priority.qa).toBe('number'); + expect(typeof priority.audit).toBe('number'); + expect(typeof priority.slicer).toBe('number'); + expect(typeof priority.analytics).toBe('number'); + }); + + it('executor has highest priority', () => { + const priority = getDefaultQueuePriority(); + expect(priority.executor).toBeGreaterThan(priority.reviewer); + expect(priority.reviewer).toBeGreaterThan(priority.qa); + }); +}); + +describe('getLogFileNames', () => { + it('maps executor id and cliCommand to logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.executor).toBe('executor'); + }); + + it('maps slicer id to "slicer" logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.slicer).toBe('slicer'); + }); + + it('maps planner (slicer cliCommand) to "slicer" logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.planner).toBe('slicer'); + }); + + it('maps qa to "night-watch-qa"', () => { + const logFiles = getLogFileNames(); + expect(logFiles.qa).toBe('night-watch-qa'); + }); +}); + +describe('getLockSuffix', () => { + it('returns correct lock suffix for executor', () => { + expect(getLockSuffix('executor')).toBe('.lock'); + }); + + it('returns correct lock suffix for reviewer', () => { + expect(getLockSuffix('reviewer')).toBe('-r.lock'); + }); + + it('returns correct lock suffix for qa', () => { + expect(getLockSuffix('qa')).toBe('-qa.lock'); + }); +}); + +describe('getAllJobDefs', () => { + it('returns a copy of the registry array', () => { + const defs = getAllJobDefs(); + expect(defs).toHaveLength(JOB_REGISTRY.length); + // Should be a copy, not the same reference + expect(defs).not.toBe(JOB_REGISTRY); + }); +}); + +describe('derived constants match expected values', () => { + it('VALID_JOB_TYPES from constants matches getValidJobTypes()', () => { + const fromRegistry = getValidJobTypes(); + expect(VALID_JOB_TYPES).toEqual(fromRegistry); + }); + + it('DEFAULT_QUEUE_PRIORITY from constants matches getDefaultQueuePriority()', () => { + const fromRegistry = getDefaultQueuePriority(); + expect(DEFAULT_QUEUE_PRIORITY).toEqual(fromRegistry); + }); + + it('LOG_FILE_NAMES from constants matches getLogFileNames()', () => { + const fromRegistry = getLogFileNames(); + expect(LOG_FILE_NAMES).toEqual(fromRegistry); + }); +}); + +describe('camelToUpperSnake', () => { + it('converts camelCase to UPPER_SNAKE_CASE', () => { + expect(camelToUpperSnake('lookbackDays')).toBe('LOOKBACK_DAYS'); + expect(camelToUpperSnake('branchPatterns')).toBe('BRANCH_PATTERNS'); + expect(camelToUpperSnake('autoInstallPlaywright')).toBe('AUTO_INSTALL_PLAYWRIGHT'); + expect(camelToUpperSnake('skipLabel')).toBe('SKIP_LABEL'); + expect(camelToUpperSnake('targetColumn')).toBe('TARGET_COLUMN'); + }); + + it('handles single-word names', () => { + expect(camelToUpperSnake('enabled')).toBe('ENABLED'); + expect(camelToUpperSnake('schedule')).toBe('SCHEDULE'); + expect(camelToUpperSnake('artifacts')).toBe('ARTIFACTS'); + }); +}); + +describe('normalizeJobConfig', () => { + it('normalizes qa config with all base fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig( + { enabled: false, schedule: '0 12 * * *', maxRuntime: 1800 }, + qaDef, + ); + expect(result.enabled).toBe(false); + expect(result.schedule).toBe('0 12 * * *'); + expect(result.maxRuntime).toBe(1800); + }); + + it('applies qa defaults for missing fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig({}, qaDef); + expect(result.enabled).toBe(true); + expect(result.artifacts).toBe('both'); + expect(result.skipLabel).toBe('skip-qa'); + expect(result.autoInstallPlaywright).toBe(true); + expect(result.branchPatterns).toEqual([]); + }); + + it('normalizes qa extra fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig( + { + enabled: true, + artifacts: 'screenshot', + skipLabel: 'no-qa', + autoInstallPlaywright: false, + branchPatterns: ['feat/', 'fix/'], + }, + qaDef, + ); + expect(result.artifacts).toBe('screenshot'); + expect(result.skipLabel).toBe('no-qa'); + expect(result.autoInstallPlaywright).toBe(false); + expect(result.branchPatterns).toEqual(['feat/', 'fix/']); + }); + + it('rejects invalid enum value and falls back to default', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig({ artifacts: 'invalid-value' }, qaDef); + expect(result.artifacts).toBe('both'); + }); + + it('normalizes audit config with no extra fields', () => { + const auditDef = getJobDef('audit')!; + const result = normalizeJobConfig( + { enabled: false, schedule: '0 4 * * 0', maxRuntime: 900 }, + auditDef, + ); + expect(result.enabled).toBe(false); + expect(result.schedule).toBe('0 4 * * 0'); + expect(result.maxRuntime).toBe(900); + }); + + it('normalizes analytics extra fields', () => { + const analyticsDef = getJobDef('analytics')!; + const result = normalizeJobConfig( + { enabled: true, lookbackDays: 14, targetColumn: 'Ready', analysisPrompt: 'test prompt' }, + analyticsDef, + ); + expect(result.lookbackDays).toBe(14); + expect(result.targetColumn).toBe('Ready'); + expect(result.analysisPrompt).toBe('test prompt'); + }); + + it('rejects invalid analytics targetColumn and falls back to default', () => { + const analyticsDef = getJobDef('analytics')!; + const result = normalizeJobConfig({ targetColumn: 'NotAColumn' }, analyticsDef); + expect(result.targetColumn).toBe('Draft'); + }); +}); + +describe('buildJobEnvOverrides', () => { + afterEach(() => { + // Clean up env vars set in tests + delete process.env.NW_QA_ENABLED; + delete process.env.NW_QA_SCHEDULE; + delete process.env.NW_QA_MAX_RUNTIME; + delete process.env.NW_QA_ARTIFACTS; + delete process.env.NW_QA_SKIP_LABEL; + delete process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT; + delete process.env.NW_QA_BRANCH_PATTERNS; + delete process.env.NW_AUDIT_ENABLED; + delete process.env.NW_AUDIT_SCHEDULE; + delete process.env.NW_AUDIT_MAX_RUNTIME; + delete process.env.NW_ANALYTICS_ENABLED; + delete process.env.NW_ANALYTICS_LOOKBACK_DAYS; + delete process.env.NW_ANALYTICS_TARGET_COLUMN; + }); + + it('returns null when no env vars are set', () => { + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).toBeNull(); + }); + + it('overrides enabled via NW_QA_ENABLED', () => { + process.env.NW_QA_ENABLED = 'false'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.enabled).toBe(false); + }); + + it('overrides schedule via NW_QA_SCHEDULE', () => { + process.env.NW_QA_SCHEDULE = '0 6 * * *'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.schedule).toBe('0 6 * * *'); + }); + + it('overrides maxRuntime via NW_QA_MAX_RUNTIME', () => { + process.env.NW_QA_MAX_RUNTIME = '1200'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.maxRuntime).toBe(1200); + }); + + it('overrides artifacts via NW_QA_ARTIFACTS', () => { + process.env.NW_QA_ARTIFACTS = 'video'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.artifacts).toBe('video'); + }); + + it('ignores invalid enum value for NW_QA_ARTIFACTS', () => { + process.env.NW_QA_ARTIFACTS = 'invalid'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).toBeNull(); + }); + + it('overrides branchPatterns via NW_QA_BRANCH_PATTERNS (comma-separated)', () => { + process.env.NW_QA_BRANCH_PATTERNS = 'feat/,fix/,hotfix/'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.branchPatterns).toEqual(['feat/', 'fix/', 'hotfix/']); + }); + + it('overrides analytics lookbackDays via NW_ANALYTICS_LOOKBACK_DAYS', () => { + process.env.NW_ANALYTICS_LOOKBACK_DAYS = '30'; + const analyticsDef = getJobDef('analytics')!; + const result = buildJobEnvOverrides( + analyticsDef.envPrefix, + analyticsDef.defaultConfig as Record, + analyticsDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.lookbackDays).toBe(30); + }); + + it('overrides audit enabled via NW_AUDIT_ENABLED', () => { + process.env.NW_AUDIT_ENABLED = '1'; + const auditDef = getJobDef('audit')!; + const result = buildJobEnvOverrides( + auditDef.envPrefix, + auditDef.defaultConfig as Record, + auditDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.enabled).toBe(true); + }); +}); diff --git a/packages/core/src/config-env.ts b/packages/core/src/config-env.ts index ba17752c..6bfc2f39 100644 --- a/packages/core/src/config-env.ts +++ b/packages/core/src/config-env.ts @@ -5,22 +5,15 @@ import { ClaudeModel, - IAnalyticsConfig, - IAuditConfig, IJobProviders, INightWatchConfig, INotificationConfig, - IQaConfig, IQueueConfig, IRoadmapScannerConfig, JobType, Provider, - QaArtifacts, } from './types.js'; import { - DEFAULT_ANALYTICS, - DEFAULT_AUDIT, - DEFAULT_QA, DEFAULT_QUEUE, DEFAULT_ROADMAP_SCANNER, VALID_CLAUDE_MODELS, @@ -28,6 +21,7 @@ import { VALID_MERGE_METHODS, } from './constants.js'; import { validateProvider } from './config-normalize.js'; +import { buildJobEnvOverrides, getJobDef } from './jobs/job-registry.js'; function parseBoolean(value: string): boolean | null { const v = value.toLowerCase().trim(); @@ -197,74 +191,23 @@ export function buildEnvOverrideConfig( } } - // QA env vars - const qaBase = (): IQaConfig => env.qa ?? fileConfig?.qa ?? DEFAULT_QA; - - if (process.env.NW_QA_ENABLED) { - const v = parseBoolean(process.env.NW_QA_ENABLED); - if (v !== null) env.qa = { ...qaBase(), enabled: v }; - } - if (process.env.NW_QA_SCHEDULE) { - env.qa = { ...qaBase(), schedule: process.env.NW_QA_SCHEDULE }; - } - if (process.env.NW_QA_MAX_RUNTIME) { - const v = parseInt(process.env.NW_QA_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.qa = { ...qaBase(), maxRuntime: v }; - } - if (process.env.NW_QA_ARTIFACTS) { - const a = process.env.NW_QA_ARTIFACTS; - if (['screenshot', 'video', 'both'].includes(a)) { - env.qa = { ...qaBase(), artifacts: a as QaArtifacts }; + // Registry-driven env overrides for nested job configs (qa, audit, analytics) + // Executor/reviewer use flat top-level fields; slicer/roadmapScanner handled above + for (const jobId of ['qa', 'audit', 'analytics'] as const) { + const jobDef = getJobDef(jobId); + if (!jobDef) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentBase = (env as any)[jobId] ?? (fileConfig as any)?.[jobId] ?? jobDef.defaultConfig; + const overrides = buildJobEnvOverrides( + jobDef.envPrefix, + currentBase as Record, + jobDef.extraFields, + ); + if (overrides) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (env as any)[jobId] = overrides; } } - if (process.env.NW_QA_SKIP_LABEL) { - env.qa = { ...qaBase(), skipLabel: process.env.NW_QA_SKIP_LABEL }; - } - if (process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT) { - const v = parseBoolean(process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT); - if (v !== null) env.qa = { ...qaBase(), autoInstallPlaywright: v }; - } - if (process.env.NW_QA_BRANCH_PATTERNS) { - const patterns = process.env.NW_QA_BRANCH_PATTERNS.split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (patterns.length > 0) env.qa = { ...qaBase(), branchPatterns: patterns }; - } - - // Audit env vars - const auditBase = (): IAuditConfig => env.audit ?? fileConfig?.audit ?? DEFAULT_AUDIT; - - if (process.env.NW_AUDIT_ENABLED) { - const v = parseBoolean(process.env.NW_AUDIT_ENABLED); - if (v !== null) env.audit = { ...auditBase(), enabled: v }; - } - if (process.env.NW_AUDIT_SCHEDULE) { - env.audit = { ...auditBase(), schedule: process.env.NW_AUDIT_SCHEDULE }; - } - if (process.env.NW_AUDIT_MAX_RUNTIME) { - const v = parseInt(process.env.NW_AUDIT_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.audit = { ...auditBase(), maxRuntime: v }; - } - - // Analytics env vars - const analyticsBase = (): IAnalyticsConfig => - env.analytics ?? fileConfig?.analytics ?? DEFAULT_ANALYTICS; - - if (process.env.NW_ANALYTICS_ENABLED) { - const v = parseBoolean(process.env.NW_ANALYTICS_ENABLED); - if (v !== null) env.analytics = { ...analyticsBase(), enabled: v }; - } - if (process.env.NW_ANALYTICS_SCHEDULE) { - env.analytics = { ...analyticsBase(), schedule: process.env.NW_ANALYTICS_SCHEDULE }; - } - if (process.env.NW_ANALYTICS_MAX_RUNTIME) { - const v = parseInt(process.env.NW_ANALYTICS_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.analytics = { ...analyticsBase(), maxRuntime: v }; - } - if (process.env.NW_ANALYTICS_LOOKBACK_DAYS) { - const v = parseInt(process.env.NW_ANALYTICS_LOOKBACK_DAYS, 10); - if (!isNaN(v) && v > 0) env.analytics = { ...analyticsBase(), lookbackDays: v }; - } // Per-job provider overrides (NW_JOB_PROVIDER_) const jobProvidersEnv: IJobProviders = {}; diff --git a/packages/core/src/config-normalize.ts b/packages/core/src/config-normalize.ts index e0134a29..b8ed0c6d 100644 --- a/packages/core/src/config-normalize.ts +++ b/packages/core/src/config-normalize.ts @@ -3,21 +3,13 @@ * Handles legacy nested keys and validates field values. */ -import { - BOARD_COLUMNS, - BoardColumnName, - BoardProviderType, - IBoardProviderConfig, -} from './board/types.js'; +import { BoardProviderType, IBoardProviderConfig } from './board/types.js'; import { ClaudeModel, - IAnalyticsConfig, - IAuditConfig, IJobProviders, INightWatchConfig, IProviderBucketConfig, IProviderPreset, - IQaConfig, IQueueConfig, IRoadmapScannerConfig, IWebhookConfig, @@ -25,21 +17,18 @@ import { MergeMethod, NotificationEvent, Provider, - QaArtifacts, QueueMode, WebhookType, } from './types.js'; import { - DEFAULT_ANALYTICS, - DEFAULT_AUDIT, DEFAULT_BOARD_PROVIDER, - DEFAULT_QA, DEFAULT_QUEUE, DEFAULT_ROADMAP_SCANNER, VALID_CLAUDE_MODELS, VALID_JOB_TYPES, VALID_MERGE_METHODS, } from './constants.js'; +import { getJobDef, normalizeJobConfig } from './jobs/job-registry.js'; export function validateProvider(value: string): Provider | null { // Accept any non-empty string as a preset ID (backward compat with 'claude'/'codex') @@ -266,54 +255,16 @@ export function normalizeConfig(rawConfig: Record): Partial = { - executor: EXECUTOR_LOG_NAME, - reviewer: REVIEWER_LOG_NAME, - qa: QA_LOG_NAME, - audit: AUDIT_LOG_NAME, - planner: PLANNER_LOG_NAME, - analytics: ANALYTICS_LOG_NAME, -}; +// Mapping from logical API names to actual file names (derived from JOB_REGISTRY) +export const LOG_FILE_NAMES: Record = getLogFileNames(); // Global Registry export const GLOBAL_CONFIG_DIR = '.night-watch'; @@ -296,14 +283,8 @@ export const DEFAULT_QUEUE_ENABLED = true; export const DEFAULT_QUEUE_MODE: QueueMode = 'conservative'; export const DEFAULT_QUEUE_MAX_CONCURRENCY = 1; export const DEFAULT_QUEUE_MAX_WAIT_TIME = 7200; // 2 hours in seconds -export const DEFAULT_QUEUE_PRIORITY: Record = { - executor: 50, - reviewer: 40, - slicer: 30, - qa: 20, - audit: 10, - analytics: 10, -}; +// Default per-job queue priority mapping (derived from JOB_REGISTRY) +export const DEFAULT_QUEUE_PRIORITY: Record = getDefaultQueuePriority(); export const DEFAULT_QUEUE: IQueueConfig = { enabled: DEFAULT_QUEUE_ENABLED, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73a1f53f..c6fecd16 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,3 +49,4 @@ export * from './templates/slicer-prompt.js'; // Note: shared/types are re-exported selectively through types.ts to avoid duplicates. // Import directly from '@night-watch/core/shared/types.js' if you need the full shared API contract. export type { IRoadmapContextOptions } from './shared/types.js'; +export * from './jobs/index.js'; diff --git a/packages/core/src/jobs/index.ts b/packages/core/src/jobs/index.ts new file mode 100644 index 00000000..214e937c --- /dev/null +++ b/packages/core/src/jobs/index.ts @@ -0,0 +1,17 @@ +// Jobs module — Job Registry pattern for scalable job architecture + +export type { IBaseJobConfig, IExtraFieldDef, IJobDefinition } from './job-registry.js'; +export { + JOB_REGISTRY, + getAllJobDefs, + getJobDef, + getJobDefByCommand, + getJobDefByLogName, + getValidJobTypes, + getDefaultQueuePriority, + getLogFileNames, + getLockSuffix, + normalizeJobConfig, + camelToUpperSnake, + buildJobEnvOverrides, +} from './job-registry.js'; diff --git a/packages/core/src/jobs/job-registry.ts b/packages/core/src/jobs/job-registry.ts new file mode 100644 index 00000000..73b97236 --- /dev/null +++ b/packages/core/src/jobs/job-registry.ts @@ -0,0 +1,413 @@ +/** + * Job Registry — Single source of truth for job metadata, defaults, and config patterns. + * Adding a new job type only requires adding an entry to JOB_REGISTRY. + */ + +import { JobType } from '../types.js'; + +/** + * Base configuration interface that all job configs extend. + * Provides uniform access patterns for enabled/schedule/maxRuntime. + */ +export interface IBaseJobConfig { + /** Whether the job is enabled */ + enabled: boolean; + /** Cron schedule for the job */ + schedule: string; + /** Maximum runtime in seconds */ + maxRuntime: number; +} + +/** + * Definition for extra fields beyond the base { enabled, schedule, maxRuntime } + */ +export interface IExtraFieldDef { + /** Field name in the config object */ + name: string; + /** Type of the field for validation */ + type: 'string' | 'number' | 'boolean' | 'string[]' | 'enum'; + /** Valid values for enum type */ + enumValues?: string[]; + /** Default value if not specified */ + defaultValue: unknown; +} + +/** + * Complete definition for a job type in the registry. + */ +export interface IJobDefinition { + /** Job type identifier (matches JobType union) */ + id: JobType; + /** Human-readable name (e.g., "Executor", "QA", "Auditor") */ + name: string; + /** Short description of what the job does */ + description: string; + /** CLI command to invoke this job (e.g., "run", "review", "qa") */ + cliCommand: string; + /** Log file name without extension (e.g., "executor", "night-watch-qa") */ + logName: string; + /** Lock file suffix (e.g., ".lock", "-r.lock", "-qa.lock") */ + lockSuffix: string; + /** Queue priority (higher = runs first) */ + queuePriority: number; + /** Env var prefix for NW_* overrides (e.g., "NW_EXECUTOR", "NW_QA") */ + envPrefix: string; + /** Extra config fields beyond base (e.g., QA's branchPatterns, artifacts) */ + extraFields?: IExtraFieldDef[]; + /** Default configuration values */ + defaultConfig: TConfig; +} + +/** + * Job registry containing all job type definitions. + * This is the single source of truth for job metadata. + */ +export const JOB_REGISTRY: IJobDefinition[] = [ + { + id: 'executor', + name: 'Executor', + description: 'Creates implementation PRs from PRDs', + cliCommand: 'run', + logName: 'executor', + lockSuffix: '.lock', + queuePriority: 50, + envPrefix: 'NW_EXECUTOR', + defaultConfig: { + enabled: true, + schedule: '5 */2 * * *', + maxRuntime: 7200, + }, + }, + { + id: 'reviewer', + name: 'Reviewer', + description: 'Reviews and improves PRs on night-watch branches', + cliCommand: 'review', + logName: 'reviewer', + lockSuffix: '-r.lock', + queuePriority: 40, + envPrefix: 'NW_REVIEWER', + defaultConfig: { + enabled: true, + schedule: '25 */3 * * *', + maxRuntime: 3600, + }, + }, + { + id: 'slicer', + name: 'Slicer', + description: 'Generates PRDs from roadmap items', + cliCommand: 'planner', + logName: 'slicer', + lockSuffix: '-slicer.lock', + queuePriority: 30, + envPrefix: 'NW_SLICER', + defaultConfig: { + enabled: true, + schedule: '35 */6 * * *', + maxRuntime: 600, + }, + }, + { + id: 'qa', + name: 'QA', + description: 'Runs end-to-end tests on PRs', + cliCommand: 'qa', + logName: 'night-watch-qa', + lockSuffix: '-qa.lock', + queuePriority: 20, + envPrefix: 'NW_QA', + extraFields: [ + { name: 'branchPatterns', type: 'string[]', defaultValue: [] }, + { + name: 'artifacts', + type: 'enum', + enumValues: ['screenshot', 'video', 'both'], + defaultValue: 'both', + }, + { name: 'skipLabel', type: 'string', defaultValue: 'skip-qa' }, + { name: 'autoInstallPlaywright', type: 'boolean', defaultValue: true }, + ], + defaultConfig: { + enabled: true, + schedule: '45 2,10,18 * * *', + maxRuntime: 3600, + branchPatterns: [], + artifacts: 'both', + skipLabel: 'skip-qa', + autoInstallPlaywright: true, + } as IBaseJobConfig & { + branchPatterns: string[]; + artifacts: string; + skipLabel: string; + autoInstallPlaywright: boolean; + }, + }, + { + id: 'audit', + name: 'Auditor', + description: 'Performs code audits and creates issues for findings', + cliCommand: 'audit', + logName: 'audit', + lockSuffix: '-audit.lock', + queuePriority: 10, + envPrefix: 'NW_AUDIT', + defaultConfig: { + enabled: true, + schedule: '50 3 * * 1', + maxRuntime: 1800, + }, + }, + { + id: 'analytics', + name: 'Analytics', + description: 'Analyzes product analytics and creates issues for trends', + cliCommand: 'analytics', + logName: 'analytics', + lockSuffix: '-analytics.lock', + queuePriority: 10, + envPrefix: 'NW_ANALYTICS', + extraFields: [ + { name: 'lookbackDays', type: 'number', defaultValue: 7 }, + { + name: 'targetColumn', + type: 'enum', + enumValues: ['Draft', 'Ready', 'In Progress', 'Done', 'Closed'], + defaultValue: 'Draft', + }, + { name: 'analysisPrompt', type: 'string', defaultValue: '' }, + ], + defaultConfig: { + enabled: false, + schedule: '0 6 * * 1', + maxRuntime: 900, + lookbackDays: 7, + targetColumn: 'Draft', + analysisPrompt: '', + } as IBaseJobConfig & { lookbackDays: number; targetColumn: string; analysisPrompt: string }, + }, +]; + +/** + * Map of job ID to job definition for O(1) lookup. + */ +const JOB_MAP: Map = new Map(JOB_REGISTRY.map((job) => [job.id, job])); + +/** + * Get a job definition by its ID. + */ +export function getJobDef(id: JobType): IJobDefinition | undefined { + return JOB_MAP.get(id); +} + +/** + * Get all job definitions. + */ +export function getAllJobDefs(): IJobDefinition[] { + return [...JOB_REGISTRY]; +} + +/** + * Get a job definition by its CLI command. + */ +export function getJobDefByCommand(command: string): IJobDefinition | undefined { + return JOB_REGISTRY.find((job) => job.cliCommand === command); +} + +/** + * Get a job definition by its log name. + */ +export function getJobDefByLogName(logName: string): IJobDefinition | undefined { + return JOB_REGISTRY.find((job) => job.logName === logName); +} + +/** + * Get all valid job types (derived from registry). + */ +export function getValidJobTypes(): JobType[] { + return JOB_REGISTRY.map((job) => job.id); +} + +/** + * Get the default queue priority mapping (derived from registry). + */ +export function getDefaultQueuePriority(): Record { + const result: Record = {}; + for (const job of JOB_REGISTRY) { + result[job.id] = job.queuePriority; + } + return result; +} + +/** + * Get the log file names mapping (derived from registry). + * Maps from CLI command / API name to actual log file name. + */ +export function getLogFileNames(): Record { + const result: Record = {}; + for (const job of JOB_REGISTRY) { + // Map both id and cliCommand to logName for backward compat + result[job.id] = job.logName; + if (job.cliCommand !== job.id) { + result[job.cliCommand] = job.logName; + } + } + return result; +} + +/** + * Get the lock file suffix for a job. + */ +export function getLockSuffix(jobId: JobType): string { + return getJobDef(jobId)?.lockSuffix ?? '.lock'; +} + +/** + * Normalize a raw job config object using the job definition's schema. + * Applies base fields (enabled, schedule, maxRuntime) + extra fields with type validation. + */ +export function normalizeJobConfig( + raw: Record, + jobDef: IJobDefinition, +): Record { + const readBoolean = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined); + const readString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + const readNumber = (v: unknown): number | undefined => + typeof v === 'number' && !Number.isNaN(v) ? v : undefined; + const readStringArray = (v: unknown): string[] | undefined => + Array.isArray(v) && v.every((s) => typeof s === 'string') ? (v as string[]) : undefined; + + const defaults = jobDef.defaultConfig as unknown as Record; + const result: Record = { + enabled: readBoolean(raw.enabled) ?? defaults.enabled, + schedule: readString(raw.schedule) ?? defaults.schedule, + maxRuntime: readNumber(raw.maxRuntime) ?? defaults.maxRuntime, + }; + + for (const field of jobDef.extraFields ?? []) { + switch (field.type) { + case 'boolean': + result[field.name] = readBoolean(raw[field.name]) ?? field.defaultValue; + break; + case 'string': + result[field.name] = readString(raw[field.name]) ?? field.defaultValue; + break; + case 'number': + result[field.name] = readNumber(raw[field.name]) ?? field.defaultValue; + break; + case 'string[]': + result[field.name] = readStringArray(raw[field.name]) ?? field.defaultValue; + break; + case 'enum': { + const val = readString(raw[field.name]); + result[field.name] = val && field.enumValues?.includes(val) ? val : field.defaultValue; + break; + } + } + } + + return result; +} + +/** + * Convert a camelCase field name to UPPER_SNAKE_CASE for env var lookup. + * e.g., "lookbackDays" → "LOOKBACK_DAYS" + */ +export function camelToUpperSnake(name: string): string { + return name.replace(/([A-Z])/g, '_$1').toUpperCase(); +} + +/** + * Build env variable overrides for a job from NW_* environment variables. + * Returns null if no env vars were set for this job. + * + * Naming convention: {envPrefix}_{FIELD_UPPER_SNAKE} + * e.g., envPrefix='NW_QA', field='branchPatterns' → 'NW_QA_BRANCH_PATTERNS' + */ +export function buildJobEnvOverrides( + envPrefix: string, + currentBase: Record, + extraFields?: IExtraFieldDef[], +): Record | null { + const parseBoolean = (value: string): boolean | null => { + const v = value.toLowerCase().trim(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0') return false; + return null; + }; + + const result = { ...currentBase }; + let changed = false; + + // Base fields + const enabledVal = process.env[`${envPrefix}_ENABLED`]; + if (enabledVal) { + const v = parseBoolean(enabledVal); + if (v !== null) { + result.enabled = v; + changed = true; + } + } + const scheduleVal = process.env[`${envPrefix}_SCHEDULE`]; + if (scheduleVal) { + result.schedule = scheduleVal; + changed = true; + } + const maxRuntimeVal = process.env[`${envPrefix}_MAX_RUNTIME`]; + if (maxRuntimeVal) { + const v = parseInt(maxRuntimeVal, 10); + if (!isNaN(v) && v > 0) { + result.maxRuntime = v; + changed = true; + } + } + + // Extra fields + for (const field of extraFields ?? []) { + const envKey = `${envPrefix}_${camelToUpperSnake(field.name)}`; + const envVal = process.env[envKey]; + if (!envVal) continue; + + switch (field.type) { + case 'boolean': { + const v = parseBoolean(envVal); + if (v !== null) { + result[field.name] = v; + changed = true; + } + break; + } + case 'string': + result[field.name] = envVal; + changed = true; + break; + case 'number': { + const v = parseInt(envVal, 10); + if (!isNaN(v) && v > 0) { + result[field.name] = v; + changed = true; + } + break; + } + case 'string[]': { + const patterns = envVal + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (patterns.length > 0) { + result[field.name] = patterns; + changed = true; + } + break; + } + case 'enum': + if (field.enumValues?.includes(envVal)) { + result[field.name] = envVal; + changed = true; + } + break; + } + } + + return changed ? result : null; +} diff --git a/packages/core/src/utils/registry.ts b/packages/core/src/utils/registry.ts index e0a07c00..e19ba99d 100644 --- a/packages/core/src/utils/registry.ts +++ b/packages/core/src/utils/registry.ts @@ -55,9 +55,10 @@ function loadRegistryEntriesWithLegacyFallback(): IRegistryEntry[] { const db = getDb(); const alreadyHydrated = db - .prepare<[], { value: string }>( - "SELECT value FROM schema_meta WHERE key = 'legacy_projects_json_hydrated'", - ) + .prepare< + [], + { value: string } + >("SELECT value FROM schema_meta WHERE key = 'legacy_projects_json_hydrated'") .get(); if (alreadyHydrated) { return []; diff --git a/packages/server/src/routes/action.routes.ts b/packages/server/src/routes/action.routes.ts index 9623f0dd..560d8bd8 100644 --- a/packages/server/src/routes/action.routes.ts +++ b/packages/server/src/routes/action.routes.ts @@ -11,6 +11,7 @@ import { Request, Response, Router } from 'express'; import { CLAIM_FILE_EXTENSION, INightWatchConfig, + JOB_REGISTRY, checkLockFile, executorLockPath, fetchStatusSnapshot, @@ -182,21 +183,15 @@ function createActionRouteHandlers(ctx: IActionRouteContext): Router { spawnAction(ctx.getProjectDir(req), ['review'], req, res); }); - router.post(`/${p}qa`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['qa'], req, res); - }); - - router.post(`/${p}audit`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['audit'], req, res); - }); - - router.post(`/${p}analytics`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['analytics'], req, res); - }); - - router.post(`/${p}planner`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['planner'], req, res); - }); + // Registry-driven job routes (all jobs except executor and reviewer which are handled above) + for (const jobDef of JOB_REGISTRY) { + if (jobDef.id === 'executor') continue; // handled above (has SSE broadcast) + if (jobDef.id === 'reviewer') continue; // handled above + const cmd = jobDef.cliCommand; + router.post(`/${p}${cmd}`, (req: Request, res: Response): void => { + spawnAction(ctx.getProjectDir(req), [cmd], req, res); + }); + } router.post(`/${p}install-cron`, (req: Request, res: Response): void => { const projectDir = ctx.getProjectDir(req); diff --git a/templates/prd-executor.md b/templates/prd-executor.md index d34a0c04..e0bf25bc 100644 --- a/templates/prd-executor.md +++ b/templates/prd-executor.md @@ -233,3 +233,4 @@ If two parallel agents modify the same file: - **Full context** - each agent gets the complete phase spec + project rules - **Verify always** - never skip verification between waves - **Task tracking** - every phase is a tracked task with status updates +- **Clean up worktrees** - after opening a PR for a worktree branch, immediately remove the worktree to avoid blocking future `git checkout` operations diff --git a/templates/skills/_codex-block.md b/templates/skills/_codex-block.md index 9497fcaa..fdb6c99a 100644 --- a/templates/skills/_codex-block.md +++ b/templates/skills/_codex-block.md @@ -9,15 +9,18 @@ Create a new PRD at `docs/prds/.md`. Ask for feature title, assess complex ### Add Issue to Board (`/nw-add-issue`) Add a GitHub issue to the Night Watch board: + ``` night-watch board add-issue night-watch board add-issue --column "In Progress" ``` + Available columns: Draft, Ready, In Progress, Review, Done. ### Run Next PRD (`/nw-run`) Manually trigger Night Watch to execute the next PRD: + ``` night-watch prd list # see what's queued night-watch run # execute next PRD @@ -28,14 +31,17 @@ night-watch logs --follow # monitor progress ### Slice Roadmap (`/nw-slice`) Break a high-level feature into multiple PRD files: + ``` night-watch slice ``` + Reads `docs/roadmap.md` and generates PRDs. Review and adjust output in `docs/prds/`. ### Sync Board (`/nw-board-sync`) Sync the GitHub board with PRD/PR state: + ``` night-watch board status night-watch board sync @@ -44,6 +50,7 @@ night-watch board sync ### Review PRs (`/nw-review`) Run automated PR review: + ``` night-watch review night-watch review --pr diff --git a/templates/skills/nw-add-issue.md b/templates/skills/nw-add-issue.md index df2f3ed2..30f73c8b 100644 --- a/templates/skills/nw-add-issue.md +++ b/templates/skills/nw-add-issue.md @@ -9,17 +9,21 @@ When the user wants to add a GitHub issue to the Night Watch Kanban board. ## Steps 1. **Check for issue number** — if not provided, list open issues: + ``` gh issue list --state open --limit 20 ``` + Ask the user which issue(s) to add. 2. **Add the issue to the board**: + ``` night-watch board add-issue ``` 3. **Optionally specify a column** (default: "Ready"): + ``` night-watch board add-issue --column "In Progress" ``` diff --git a/templates/skills/nw-board-sync.md b/templates/skills/nw-board-sync.md index 4867be3d..3f5dd486 100644 --- a/templates/skills/nw-board-sync.md +++ b/templates/skills/nw-board-sync.md @@ -9,16 +9,19 @@ When the board is out of sync with PRD/PR status, or after manual changes to iss ## Steps 1. **View current board state**: + ``` night-watch board status ``` 2. **Sync board with PRD filesystem**: + ``` night-watch board sync ``` 3. **List open issues that may need board assignment**: + ``` gh issue list --state open --label "night-watch" ``` @@ -30,13 +33,13 @@ When the board is out of sync with PRD/PR status, or after manual changes to iss ## Board Columns -| Column | Meaning | -|--------|---------| -| Draft | Not ready for execution | -| Ready | Queued for Night Watch — picked up automatically | -| In Progress | Currently being implemented | -| Review | PR opened, awaiting review | -| Done | Merged and complete | +| Column | Meaning | +| ----------- | ------------------------------------------------ | +| Draft | Not ready for execution | +| Ready | Queued for Night Watch — picked up automatically | +| In Progress | Currently being implemented | +| Review | PR opened, awaiting review | +| Done | Merged and complete | ## Notes diff --git a/templates/skills/nw-review.md b/templates/skills/nw-review.md index f8fa4eec..f6159758 100644 --- a/templates/skills/nw-review.md +++ b/templates/skills/nw-review.md @@ -9,16 +9,19 @@ When the user wants to trigger automated code review on Night Watch PRs, or when ## Steps 1. **List open Night Watch PRs**: + ``` gh pr list --state open --json number,title,headRefName --jq '.[] | select(.headRefName | startswith("night-watch/"))' ``` 2. **Run the reviewer** (processes all eligible PRs): + ``` night-watch review ``` 3. **Review a specific PR**: + ``` night-watch review --pr ``` diff --git a/templates/skills/nw-run.md b/templates/skills/nw-run.md index e1df58d4..f5c3b8a0 100644 --- a/templates/skills/nw-run.md +++ b/templates/skills/nw-run.md @@ -9,16 +9,19 @@ When the user wants to run Night Watch now instead of waiting for the cron sched ## Steps 1. **Check what's queued**: + ``` night-watch prd list ``` 2. **Run the executor**: + ``` night-watch run ``` 3. **To run a specific PRD** by filename: + ``` night-watch run --prd ``` diff --git a/templates/skills/nw-slice.md b/templates/skills/nw-slice.md index 146620fc..51f04d8b 100644 --- a/templates/skills/nw-slice.md +++ b/templates/skills/nw-slice.md @@ -12,9 +12,11 @@ independently-executable pieces. 1. **Ask for the epic description** if not provided. 2. **Run the slicer** to auto-generate PRDs from a roadmap file: + ``` night-watch slice ``` + This reads `docs/roadmap.md` (or configured roadmap path) and generates PRD files. 3. **To slice a specific feature**, first add it to `docs/roadmap.md`, then run slice. diff --git a/templates/skills/skills/_codex-block.md b/templates/skills/skills/_codex-block.md index 9497fcaa..fdb6c99a 100644 --- a/templates/skills/skills/_codex-block.md +++ b/templates/skills/skills/_codex-block.md @@ -9,15 +9,18 @@ Create a new PRD at `docs/prds/.md`. Ask for feature title, assess complex ### Add Issue to Board (`/nw-add-issue`) Add a GitHub issue to the Night Watch board: + ``` night-watch board add-issue night-watch board add-issue --column "In Progress" ``` + Available columns: Draft, Ready, In Progress, Review, Done. ### Run Next PRD (`/nw-run`) Manually trigger Night Watch to execute the next PRD: + ``` night-watch prd list # see what's queued night-watch run # execute next PRD @@ -28,14 +31,17 @@ night-watch logs --follow # monitor progress ### Slice Roadmap (`/nw-slice`) Break a high-level feature into multiple PRD files: + ``` night-watch slice ``` + Reads `docs/roadmap.md` and generates PRDs. Review and adjust output in `docs/prds/`. ### Sync Board (`/nw-board-sync`) Sync the GitHub board with PRD/PR state: + ``` night-watch board status night-watch board sync @@ -44,6 +50,7 @@ night-watch board sync ### Review PRs (`/nw-review`) Run automated PR review: + ``` night-watch review night-watch review --pr diff --git a/templates/skills/skills/nw-add-issue.md b/templates/skills/skills/nw-add-issue.md index df2f3ed2..30f73c8b 100644 --- a/templates/skills/skills/nw-add-issue.md +++ b/templates/skills/skills/nw-add-issue.md @@ -9,17 +9,21 @@ When the user wants to add a GitHub issue to the Night Watch Kanban board. ## Steps 1. **Check for issue number** — if not provided, list open issues: + ``` gh issue list --state open --limit 20 ``` + Ask the user which issue(s) to add. 2. **Add the issue to the board**: + ``` night-watch board add-issue ``` 3. **Optionally specify a column** (default: "Ready"): + ``` night-watch board add-issue --column "In Progress" ``` diff --git a/templates/skills/skills/nw-board-sync.md b/templates/skills/skills/nw-board-sync.md index 4867be3d..3f5dd486 100644 --- a/templates/skills/skills/nw-board-sync.md +++ b/templates/skills/skills/nw-board-sync.md @@ -9,16 +9,19 @@ When the board is out of sync with PRD/PR status, or after manual changes to iss ## Steps 1. **View current board state**: + ``` night-watch board status ``` 2. **Sync board with PRD filesystem**: + ``` night-watch board sync ``` 3. **List open issues that may need board assignment**: + ``` gh issue list --state open --label "night-watch" ``` @@ -30,13 +33,13 @@ When the board is out of sync with PRD/PR status, or after manual changes to iss ## Board Columns -| Column | Meaning | -|--------|---------| -| Draft | Not ready for execution | -| Ready | Queued for Night Watch — picked up automatically | -| In Progress | Currently being implemented | -| Review | PR opened, awaiting review | -| Done | Merged and complete | +| Column | Meaning | +| ----------- | ------------------------------------------------ | +| Draft | Not ready for execution | +| Ready | Queued for Night Watch — picked up automatically | +| In Progress | Currently being implemented | +| Review | PR opened, awaiting review | +| Done | Merged and complete | ## Notes diff --git a/templates/skills/skills/nw-review.md b/templates/skills/skills/nw-review.md index f8fa4eec..f6159758 100644 --- a/templates/skills/skills/nw-review.md +++ b/templates/skills/skills/nw-review.md @@ -9,16 +9,19 @@ When the user wants to trigger automated code review on Night Watch PRs, or when ## Steps 1. **List open Night Watch PRs**: + ``` gh pr list --state open --json number,title,headRefName --jq '.[] | select(.headRefName | startswith("night-watch/"))' ``` 2. **Run the reviewer** (processes all eligible PRs): + ``` night-watch review ``` 3. **Review a specific PR**: + ``` night-watch review --pr ``` diff --git a/templates/skills/skills/nw-run.md b/templates/skills/skills/nw-run.md index e1df58d4..f5c3b8a0 100644 --- a/templates/skills/skills/nw-run.md +++ b/templates/skills/skills/nw-run.md @@ -9,16 +9,19 @@ When the user wants to run Night Watch now instead of waiting for the cron sched ## Steps 1. **Check what's queued**: + ``` night-watch prd list ``` 2. **Run the executor**: + ``` night-watch run ``` 3. **To run a specific PRD** by filename: + ``` night-watch run --prd ``` diff --git a/templates/skills/skills/nw-slice.md b/templates/skills/skills/nw-slice.md index 146620fc..51f04d8b 100644 --- a/templates/skills/skills/nw-slice.md +++ b/templates/skills/skills/nw-slice.md @@ -12,9 +12,11 @@ independently-executable pieces. 1. **Ask for the epic description** if not provided. 2. **Run the slicer** to auto-generate PRDs from a roadmap file: + ``` night-watch slice ``` + This reads `docs/roadmap.md` (or configured roadmap path) and generates PRD files. 3. **To slice a specific feature**, first add it to `docs/roadmap.md`, then run slice. diff --git a/web/api.ts b/web/api.ts index 0c655636..8aedd7e4 100644 --- a/web/api.ts +++ b/web/api.ts @@ -27,6 +27,7 @@ import type { QaArtifacts, } from '@shared/types'; import { DependencyList, useEffect, useRef, useState } from 'react'; +import { getWebJobDef } from './utils/jobs'; // Re-export shared types so consumers can import from either place export type { @@ -325,6 +326,18 @@ export function triggerPlanner(): Promise { }); } +/** + * Generic job trigger using the web job registry. + * Prefer this over per-job triggerRun/triggerReview/etc. for new code. + */ +export function triggerJob(jobId: string): Promise { + const jobDef = getWebJobDef(jobId); + if (!jobDef) { + return Promise.reject(new Error(`Unknown job ID: ${jobId}`)); + } + return apiFetch(apiPath(jobDef.triggerEndpoint), { method: 'POST' }); +} + export function triggerInstallCron(): Promise { return apiFetch(apiPath('/api/actions/install-cron'), { method: 'POST', diff --git a/web/pages/Scheduling.tsx b/web/pages/Scheduling.tsx index f31973bf..50d122ad 100644 --- a/web/pages/Scheduling.tsx +++ b/web/pages/Scheduling.tsx @@ -29,12 +29,7 @@ import { updateConfig, triggerInstallCron, triggerUninstallCron, - triggerRun, - triggerReview, - triggerQa, - triggerAudit, - triggerPlanner, - triggerAnalytics, + triggerJob, useApi, } from '../api'; import { @@ -46,6 +41,7 @@ import { isWithin30Minutes, } from '../utils/cron'; import type { IScheduleTemplate } from '../utils/cron.js'; +import { getWebJobDef } from '../utils/jobs'; interface IScheduleEditState { form: IScheduleConfigForm; @@ -199,27 +195,17 @@ const Scheduling: React.FC = () => { } }; const handleJobToggle = async ( - job: 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics', + jobId: string, enabled: boolean, ) => { if (!config) return; - setUpdatingJob(job); + // Map legacy 'planner' ID to registry 'slicer' ID + const registryId = jobId === 'planner' ? 'slicer' : jobId; + const jobDef = getWebJobDef(registryId); + if (!jobDef) return; + setUpdatingJob(jobId); try { - if (job === 'executor') { - await updateConfig({ executorEnabled: enabled }); - } else if (job === 'reviewer') { - await updateConfig({ reviewerEnabled: enabled }); - } else if (job === 'qa') { - await updateConfig({ qa: { ...config.qa, enabled } }); - } else if (job === 'audit') { - await updateConfig({ audit: { ...config.audit, enabled } }); - } else if (job === 'analytics') { - await updateConfig({ analytics: { ...config.analytics, enabled } }); - } else { - await updateConfig({ - roadmapScanner: { ...config.roadmapScanner, enabled }, - }); - } + await updateConfig(jobDef.buildEnabledPatch(enabled, config)); let cronInstallFailedMessage = ''; try { await triggerInstallCron(); @@ -239,7 +225,7 @@ const Scheduling: React.FC = () => { } : { title: 'Job Updated', - message: `${job[0].toUpperCase() + job.slice(1)} ${enabled ? 'enabled' : 'disabled'}.`, + message: `${jobId[0].toUpperCase() + jobId.slice(1)} ${enabled ? 'enabled' : 'disabled'}.`, type: 'success', }, ); @@ -253,28 +239,22 @@ const Scheduling: React.FC = () => { setUpdatingJob(null); } }; - const handleTriggerJob = async (job: 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics') => { - setTriggeringJob(job); + const handleTriggerJob = async (jobId: string) => { + // Map legacy 'planner' ID to registry 'slicer' ID + const registryId = jobId === 'planner' ? 'slicer' : jobId; + setTriggeringJob(jobId); try { - const triggerMap = { - executor: triggerRun, - reviewer: triggerReview, - qa: triggerQa, - audit: triggerAudit, - planner: triggerPlanner, - analytics: triggerAnalytics, - }; - await triggerMap[job](); + await triggerJob(registryId); addToast({ title: 'Job Triggered', - message: `${job[0].toUpperCase() + job.slice(1)} job has been queued.`, + message: `${jobId[0].toUpperCase() + jobId.slice(1)} job has been queued.`, type: 'success', }); refetchSchedule(); } catch (error) { addToast({ title: 'Trigger Failed', - message: formatErrorMessage(error, `Failed to trigger ${job} job`), + message: formatErrorMessage(error, `Failed to trigger ${jobId} job`), type: 'error', }); } finally { @@ -632,7 +612,7 @@ const Scheduling: React.FC = () => { checked={agent.enabled} disabled={updatingJob !== null} aria-label={`Toggle ${agent.name.toLowerCase()} automation`} - onChange={(checked) => handleJobToggle(agent.id as 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics', checked)} + onChange={(checked) => handleJobToggle(agent.id, checked)} />
@@ -676,7 +656,7 @@ const Scheduling: React.FC = () => {