From 6571324fb3e6655653dae042d77c7b57708d69e5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 10:01:51 +0300 Subject: [PATCH 1/5] feat(cli): add --notify-level to control watch round noise (#803) - Default 'important': only print rounds with actual work items - 'all': old behavior (every round including empty) - 'none': suppress all round output - Add machine name + repo name to round headers for attribution - Configurable via .squad/config.json watch.notifyLevel - 5 new tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/quiet-notifications.md | 12 +++ packages/squad-cli/src/cli-entry.ts | 7 ++ .../src/cli/commands/watch/config.ts | 18 ++++- .../squad-cli/src/cli/commands/watch/index.ts | 29 ++++++- test/watch-notify-level.test.ts | 81 +++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-notifications.md create mode 100644 test/watch-notify-level.test.ts diff --git a/.changeset/quiet-notifications.md b/.changeset/quiet-notifications.md new file mode 100644 index 000000000..00a39c3dc --- /dev/null +++ b/.changeset/quiet-notifications.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add `--notify-level` to control watch round reporting noise (#803) + +- `--notify-level important` (default): only print rounds with actual work items +- `--notify-level all`: print every round including empty (old behavior) +- `--notify-level none`: suppress all round output +- Add machine name (`os.hostname()`) and repo name to round headers for attribution +- Configurable via `.squad/config.json` watch section: `"notifyLevel": "important"` +- Empty rounds silenced by default โ€” no more "Round 160, Round 161" spam diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 3c7e970c4..b6a03689f 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -386,6 +386,12 @@ async function main(): Promise { : { projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) }; } + // Notify level: --notify-level all|important|none (default: important) + const notifyLevelIdx = args.indexOf('--notify-level'); + const notifyLevelArg = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1]) ? args[notifyLevelIdx + 1] : undefined; + const notifyLevel = (notifyLevelArg === 'all' || notifyLevelArg === 'important' || notifyLevelArg === 'none') + ? notifyLevelArg : undefined; + // Load config: .squad/config.json merged with CLI overrides const config = loadWatchConfig(process.cwd(), { interval, @@ -394,6 +400,7 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + notifyLevel, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index ac7509d72..36633152a 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -20,6 +20,15 @@ export interface WatchConfig { agentCmd?: string; /** Per-capability config: `true` / `false` / object with sub-options. */ capabilities: Record>; + /** + * Controls how verbose watch round reporting is. + * - 'all' โ€” print every round (current behavior) + * - 'important' โ€” only print rounds with actual work (default) + * - 'none' โ€” suppress all round reporting + */ + notifyLevel?: 'all' | 'important' | 'none'; + /** Verbose diagnostics (--verbose flag). */ + verbose?: boolean; } const DEFAULTS: WatchConfig = { @@ -27,6 +36,7 @@ const DEFAULTS: WatchConfig = { execute: false, maxConcurrent: 1, timeout: 30, + notifyLevel: 'important', capabilities: {}, }; @@ -63,6 +73,8 @@ export function loadWatchConfig( timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout, copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags, agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd, + notifyLevel: cliOverrides.notifyLevel ?? fileConfig.notifyLevel ?? DEFAULTS.notifyLevel, + verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false, capabilities: { ...DEFAULTS.capabilities, ...(fileConfig.capabilities ?? {}), @@ -83,10 +95,14 @@ function normalizeFileConfig(raw: Record): Partial if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout']; if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags']; if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd']; + if (raw['notifyLevel'] === 'all' || raw['notifyLevel'] === 'important' || raw['notifyLevel'] === 'none') { + result.notifyLevel = raw['notifyLevel']; + } + if (typeof raw['verbose'] === 'boolean') result.verbose = raw['verbose']; // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'notifyLevel', 'verbose']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index ea4e53204..154b8e152 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -7,6 +7,7 @@ */ import path from 'node:path'; +import os from 'node:os'; import { execFile, execFileSync } from 'node:child_process'; import { promisify } from 'node:util'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; @@ -164,13 +165,31 @@ export interface BoardState { executed: number; } -export function reportBoard(state: BoardState, round: number): void { +export interface ReportOptions { + notifyLevel: 'all' | 'important' | 'none'; + machineName?: string; + repoName?: string; +} + +export function reportBoard(state: BoardState, round: number, options?: ReportOptions): void { + const level = options?.notifyLevel ?? 'important'; + if (level === 'none') return; + const total = Object.values(state).reduce((a, b) => a + b, 0); + + // In 'important' mode, suppress empty rounds entirely + if (total === 0 && level === 'important') return; + if (total === 0) { console.log(`${DIM}๐Ÿ“‹ Board is clear โ€” Ralph is idling${RESET}`); return; } - console.log(`\n${BOLD}๐Ÿ”„ Ralph โ€” Round ${round}${RESET}`); + + // Build context tag for attribution + const ctx = [options?.machineName, options?.repoName].filter(Boolean).join(' ยท '); + const ctxSuffix = ctx ? ` ${DIM}(${ctx})${RESET}` : ''; + + console.log(`\n${BOLD}๐Ÿ”„ Ralph โ€” Round ${round}${RESET}${ctxSuffix}`); console.log('โ”'.repeat(30)); if (state.untriaged > 0) console.log(` ๐Ÿ”ด Untriaged: ${state.untriaged}`); if (state.assigned > 0) console.log(` ๐ŸŸก Assigned: ${state.assigned}`); @@ -803,7 +822,11 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig timestamp: new Date(), }); await monitor.healthCheck(); - reportBoard(roundState, round); + reportBoard(roundState, round, { + notifyLevel: config.notifyLevel ?? 'important', + machineName: os.hostname(), + repoName: path.basename(teamRoot), + }); // Post-round: update circuit breaker on success if (cbState.status === 'half-open') { diff --git a/test/watch-notify-level.test.ts b/test/watch-notify-level.test.ts new file mode 100644 index 000000000..d8a91ad35 --- /dev/null +++ b/test/watch-notify-level.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { reportBoard } from '../packages/squad-cli/src/cli/commands/watch/index.js'; +import type { BoardState } from '../packages/squad-cli/src/cli/commands/watch/index.js'; + +function emptyState(): BoardState { + return { untriaged: 0, assigned: 0, drafts: 0, needsReview: 0, changesRequested: 0, ciFailures: 0, readyToMerge: 0, executed: 0 }; +} + +describe('reportBoard notifyLevel', () => { + it('suppresses empty rounds in important mode (default)', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(' ')); + try { + reportBoard(emptyState(), 42, { notifyLevel: 'important' }); + expect(logs).toHaveLength(0); + } finally { + console.log = origLog; + } + }); + + it('prints empty rounds in all mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(' ')); + try { + reportBoard(emptyState(), 42, { notifyLevel: 'all' }); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(l => l.includes('Board is clear'))).toBe(true); + } finally { + console.log = origLog; + } + }); + + it('suppresses everything in none mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(' ')); + try { + const busy = { ...emptyState(), untriaged: 3, ciFailures: 1 }; + reportBoard(busy, 10, { notifyLevel: 'none' }); + expect(logs).toHaveLength(0); + } finally { + console.log = origLog; + } + }); + + it('prints busy rounds in important mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(' ')); + try { + const busy = { ...emptyState(), untriaged: 2, readyToMerge: 1 }; + reportBoard(busy, 5, { notifyLevel: 'important' }); + expect(logs.some(l => l.includes('Round 5'))).toBe(true); + expect(logs.some(l => l.includes('Untriaged'))).toBe(true); + } finally { + console.log = origLog; + } + }); + + it('includes machine and repo in output when provided', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(' ')); + try { + const busy = { ...emptyState(), assigned: 1 }; + reportBoard(busy, 3, { + notifyLevel: 'all', + machineName: 'CPC-tamir-WCBED', + repoName: 'my-project', + }); + const roundLine = logs.find(l => l.includes('Round 3')); + expect(roundLine).toBeDefined(); + expect(roundLine).toContain('CPC-tamir-WCBED'); + expect(roundLine).toContain('my-project'); + } finally { + console.log = origLog; + } + }); +}); From 8cb4a3411a2c64ef690ff3d2560d07f2ca0c9231 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 10:07:47 +0300 Subject: [PATCH 2/5] docs: add notification-level feature doc + v0.9.2 blog post - notification-level.md: full feature doc for --notify-level - ralph.md: add --notify-level + --cleanup to capabilities table - 030-v092-whats-coming.md: blog post covering all 10 features in next release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/content/blog/030-v092-whats-coming.md | 158 ++++++++++++++++++ .../docs/features/notification-level.md | 89 ++++++++++ docs/src/content/docs/features/ralph.md | 2 + 3 files changed, 249 insertions(+) create mode 100644 docs/src/content/blog/030-v092-whats-coming.md create mode 100644 docs/src/content/docs/features/notification-level.md diff --git a/docs/src/content/blog/030-v092-whats-coming.md b/docs/src/content/blog/030-v092-whats-coming.md new file mode 100644 index 000000000..20d3c09d0 --- /dev/null +++ b/docs/src/content/blog/030-v092-whats-coming.md @@ -0,0 +1,158 @@ +--- +title: "What's Coming in v0.9.2 โ€” 10 Features, Zero Breaking Changes" +date: 2026-04-04 +author: "Tamir" +tags: [squad, release, features, watch, cleanup, fact-checker, external-state, self-upgrade, fleet, verbose, skills, scratch-dir, triage, notifications] +status: draft +hero: "The next Squad release ships 10 new features, 4 bug fixes, and 60+ tests โ€” all backward-compatible. Here's what to expect." +--- + +# What's Coming in v0.9.2 โ€” 10 Features, Zero Breaking Changes + +> โš ๏ธ **Experimental** โ€” Squad is alpha software. APIs, commands, and behavior may change between releases. + +> _The next Squad release ships 10 new features, 4 bug fixes, and 60+ tests โ€” all backward-compatible. Here's what to expect._ + +## The Noise Problem + +If you've been running `squad watch` in production with output forwarded to Teams or Slack, you've probably noticed this: + +``` +Squad Monitor Round 158 +Squad Monitor Round 159 +Squad Monitor Round 160 +Squad Monitor Round 161 +``` + +Hundreds of messages. No useful information. No way to tell which machine or repo they came from. + +This release fixes that โ€” and ships 9 other features while we're at it. + +## New Features + +### 1. Quiet Notifications (`--notify-level`) + +The biggest quality-of-life improvement: watch rounds are now **silent by default when the board is empty**. Only rounds with actual work produce output. + +```bash +squad watch --notify-level important # default โ€” only meaningful rounds +squad watch --notify-level all # old behavior (every round) +squad watch --notify-level none # fully silent +``` + +Round headers now include **machine name and repo** for attribution: + +``` +๐Ÿ”„ Ralph โ€” Round 5 (CPC-tamir-WCBED ยท my-project) +``` + +Persistent config: `{ "watch": { "notifyLevel": "important" } }` in `.squad/config.json`. + +### 2. Fleet Hybrid Dispatch (`--dispatch-mode`) + +Parallel issue processing via Copilot CLI `/fleet`. Benchmarked at **2.9x faster** for read-heavy workloads. + +```bash +squad watch --execute --dispatch-mode hybrid +``` + +Issues are auto-classified as read (research, reviews โ†’ fleet) or write (implementations, fixes โ†’ local). Fleet batches reads in parallel while writes execute sequentially. + +### 3. Verbose Debugging (`--verbose`) + +When watch seems stuck on "Board is clear" but you know there's work, `--verbose` shows you exactly what's happening: + +```bash +squad watch --verbose +``` + +Prints: issue counts, label matches, auth status, PR review states, round timing, capability execution paths. + +### 4. Fact-Checker Agent Role (๐Ÿ”) + +New built-in role for output verification. Validates claims, detects hallucinations, runs counter-hypotheses. + +``` +squad new agent fact-checker +``` + +Confidence ratings: โœ… Verified, โš ๏ธ Unverified, โŒ Contradicted. Routes automatically on "fact-check", "verify", "audit" keywords. + +### 5. 8 Built-in Skills + +`squad init` and `squad upgrade` now ship 8 curated skills: + +| Skill | What it teaches | +|-------|-----------------| +| squad-conventions | Core patterns and file layout | +| error-recovery | Graceful failure handling | +| secret-handling | Credential safety | +| git-workflow | Branch and commit conventions | +| session-recovery | Checkpoint and resume | +| reviewer-protocol | Code review gates | +| test-discipline | Test-first discipline | +| agent-collaboration | Multi-agent handoffs | + +### 6. Scratch Directory (`.squad/.scratch/`) + +Agents no longer dump temp files in the repo root. The new `scratchDir()` and `scratchFile()` SDK APIs route all ephemeral files to `.squad/.scratch/` โ€” gitignored and auto-cleaned. + +### 7. Cleanup Watch Capability + +Automated housekeeping during `squad watch`: +- Clears `.squad/.scratch/` every 10 rounds +- Archives orchestration-log and session-log entries older than 30 days +- Warns about stale decision inbox files (>7 days) + +### 8. External State Storage + +Move `.squad/` state outside the working tree so it survives branch switches: + +```bash +squad externalize # state moves to ~/.squad/projects/{repo}/ +squad internalize # move it back +``` + +### 9. Self-Upgrade (`squad upgrade --self`) + +Update the CLI itself from within squad: + +```bash +squad upgrade --self # latest stable +squad upgrade --self --insider # latest prerelease +``` + +Auto-detects npm/pnpm/yarn. After CLI upgrade, automatically runs repo upgrade to apply new templates. + +### 10. Triage Label Slug Fix + +Multi-word agent names (like "Steve Rogers") now correctly generate `squad:steve-rogers` labels instead of `squad:steve rogers` (which GitHub rejects). Labels are pre-created at watch startup. + +## Bug Fixes + +- **PR contamination** โ€” Scribe now stages only `.squad/` files, not `git add .` (#783) +- **Outdated review threads** โ€” PR readiness check ignores threads where code changed (#780) +- **Filename uniqueness** โ€” `scratchFile()` uses monotonic counter, not just `Date.now()` +- **Cross-platform paths** โ€” `deriveProjectKey()` handles Windows paths on Linux CI + +## By the Numbers + +- **10 features** across SDK, CLI, and watch capabilities +- **4 bug fixes** (1 critical โ€” PR contamination) +- **60+ new tests** (scratch: 8, cleanup: 12, fact-checker: 8, skills: 5, external: 12, self-upgrade: 4, notify: 5, triage: 12) +- **4 new feature docs** + 2 updated + README command table (15โ†’17) +- **0 breaking changes** โ€” all opt-in, all backward-compatible +- **1 behavioral change** โ€” notify default flipped from `all` to `important` (use `--notify-level all` to restore) + +## Upgrade + +```bash +squad upgrade --self # get the latest CLI +squad upgrade # apply new templates to your repo +``` + +Or if you're on insider: + +```bash +squad upgrade --self --insider +``` diff --git a/docs/src/content/docs/features/notification-level.md b/docs/src/content/docs/features/notification-level.md new file mode 100644 index 000000000..7affc9653 --- /dev/null +++ b/docs/src/content/docs/features/notification-level.md @@ -0,0 +1,89 @@ +# Notification Level + +> โš ๏ธ **Experimental** โ€” Squad is alpha software. APIs, commands, and behavior may change between releases. + +**Try this to silence empty rounds:** +``` +squad watch --notify-level important +``` + +**Try this to see everything (debugging):** +``` +squad watch --notify-level all +``` + +**Try this in config for persistent setting:** +```json +{ "watch": { "notifyLevel": "important" } } +``` + +When `squad watch` runs continuously, it prints a board report after every round. In production setups where output is forwarded to Teams, Slack, or email, this creates noise โ€” hundreds of "Round N" messages with no useful content when the board is clear. + +--- + +## Notify Levels + +| Level | Behavior | When to use | +|-------|----------|-------------| +| `important` | Only print rounds with actual work items | **Default.** Best for production/Teams channels | +| `all` | Print every round, including empty ones | Debugging. Old behavior before this feature | +| `none` | Suppress all round output | Headless/CI โ€” only errors matter | + +## Machine and Repo Attribution + +Every round header now includes the machine hostname and repo name: + +``` +๐Ÿ”„ Ralph โ€” Round 5 (CPC-tamir-WCBED ยท my-project) +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + ๐Ÿ”ด Untriaged: 2 + ๐ŸŸข Ready to merge: 1 +``` + +This tells you **where** the message came from โ€” which machine and which repo โ€” so when multiple watch instances report to the same Teams channel, you can distinguish them. + +## Configuration + +### CLI flag (per-run) + +```bash +squad watch --notify-level important # default +squad watch --notify-level all # old behavior +squad watch --notify-level none # silent +``` + +### Config file (persistent) + +In `.squad/config.json`: + +```json +{ + "watch": { + "notifyLevel": "important", + "interval": 10, + "execute": true + } +} +``` + +Config file settings are overridden by CLI flags when both are present. + +## What Counts as "Important" + +A round is considered important (and reported) when **any** board counter is non-zero: + +- Untriaged issues +- Assigned but unstarted work +- Draft PRs +- Changes requested on PRs +- CI failures +- PRs needing review +- PRs ready to merge +- Issues executed this round + +If all counters are zero, the round is silent in `important` mode. + +## See Also + +- [Ralph โ€” Work Monitor](/docs/features/ralph) โ€” full Ralph documentation +- [Watch capabilities](/docs/features/ralph#watch-mode) โ€” how squad watch works diff --git a/docs/src/content/docs/features/ralph.md b/docs/src/content/docs/features/ralph.md index 3a12c2ad7..e6f42e399 100644 --- a/docs/src/content/docs/features/ralph.md +++ b/docs/src/content/docs/features/ralph.md @@ -273,8 +273,10 @@ All new features are **opt-in** and disabled by default. Existing `squad watch` | Flag | Description | Example | |------|-------------|---------| +| `--notify-level LEVEL` | Control round reporting noise: `important` (default), `all`, `none` | `squad watch --notify-level important` | | `--retro` | Enforce retrospective checks (Fridays or when missed >7 days) | `squad watch --retro` | | `--decision-hygiene` | Auto-merge decision inbox when >5 files | `squad watch --decision-hygiene` | +| `--cleanup` | Auto-clear scratch files, archive old logs (every 10 rounds) | `squad watch --cleanup` | | `--channel-routing` | Route notifications to specific Teams channels (requires `.squad/teams-channels.json`) | `squad watch --channel-routing` | ### Common Workflows From 7a623173ab2fb8f7c606d6a3480fa3e62e019181 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 10:14:53 +0300 Subject: [PATCH 3/5] fix: update ralph-board test for notify-level default change captureBoardOutput now defaults to 'all' mode to preserve existing test behavior. The production default is 'important' but tests need to verify clear-board message formatting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/ralph-board.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ralph-board.test.ts b/test/ralph-board.test.ts index d99f3c699..21d2221ce 100644 --- a/test/ralph-board.test.ts +++ b/test/ralph-board.test.ts @@ -8,9 +8,9 @@ function stripAnsi(value: string): string { return value.replace(/\u001b\[[0-9;]*m/g, ''); } -function captureBoardOutput(state: BoardState, round: number): string[] { +function captureBoardOutput(state: BoardState, round: number, notifyLevel: 'all' | 'important' | 'none' = 'all'): string[] { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - reportBoard(state, round); + reportBoard(state, round, { notifyLevel }); return logSpy.mock.calls.map((args) => stripAnsi(args.map((arg) => String(arg ?? '')).join(' '))); } From db03b33293a8021ce9d18263a721677127c1f4ce Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 12:46:51 +0300 Subject: [PATCH 4/5] fix(docs): replace machine-specific hostname with generic example CSpell docs-quality CI was failing because 'WCBED' is an unknown word. Replaced CPC-tamir-WCBED with DEVBOX-01 in the blog post and notification-level feature doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/blog/030-v092-whats-coming.md | 2 +- docs/src/content/docs/features/notification-level.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/blog/030-v092-whats-coming.md b/docs/src/content/blog/030-v092-whats-coming.md index 20d3c09d0..92db0c4a0 100644 --- a/docs/src/content/blog/030-v092-whats-coming.md +++ b/docs/src/content/blog/030-v092-whats-coming.md @@ -43,7 +43,7 @@ squad watch --notify-level none # fully silent Round headers now include **machine name and repo** for attribution: ``` -๐Ÿ”„ Ralph โ€” Round 5 (CPC-tamir-WCBED ยท my-project) +๐Ÿ”„ Ralph โ€” Round 5 (DEVBOX-01 ยท my-project) ``` Persistent config: `{ "watch": { "notifyLevel": "important" } }` in `.squad/config.json`. diff --git a/docs/src/content/docs/features/notification-level.md b/docs/src/content/docs/features/notification-level.md index 7affc9653..d5341722e 100644 --- a/docs/src/content/docs/features/notification-level.md +++ b/docs/src/content/docs/features/notification-level.md @@ -34,7 +34,7 @@ When `squad watch` runs continuously, it prints a board report after every round Every round header now includes the machine hostname and repo name: ``` -๐Ÿ”„ Ralph โ€” Round 5 (CPC-tamir-WCBED ยท my-project) +๐Ÿ”„ Ralph โ€” Round 5 (DEVBOX-01 ยท my-project) โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” ๐Ÿ”ด Untriaged: 2 ๐ŸŸข Ready to merge: 1 From a6436f2586ee24229aec4f8595404e5179387428 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 19:28:36 +0300 Subject: [PATCH 5/5] fix: address Copilot review comments - Add JSDoc to reportBoard() documenting default notifyLevel='important' - Include machine/repo context in 'Board is clear' message for all modes - Add comment noting verbose flag parsing is in PR #782 - Replace console.log direct mutation with vi.spyOn in tests - Update changeset to clarify attribution is in round headers and clear message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/quiet-notifications.md | 1 + packages/squad-cli/src/cli-entry.ts | 2 + .../squad-cli/src/cli/commands/watch/index.ts | 18 +++- test/watch-notify-level.test.ts | 92 +++++++------------ 4 files changed, 50 insertions(+), 63 deletions(-) diff --git a/.changeset/quiet-notifications.md b/.changeset/quiet-notifications.md index 00a39c3dc..5a8880512 100644 --- a/.changeset/quiet-notifications.md +++ b/.changeset/quiet-notifications.md @@ -8,5 +8,6 @@ Add `--notify-level` to control watch round reporting noise (#803) - `--notify-level all`: print every round including empty (old behavior) - `--notify-level none`: suppress all round output - Add machine name (`os.hostname()`) and repo name to round headers for attribution + (shown in round headers when the board has items, and in "Board is clear" message) - Configurable via `.squad/config.json` watch section: `"notifyLevel": "important"` - Empty rounds silenced by default โ€” no more "Round 160, Round 161" spam diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index b6a03689f..a1aee5ff7 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -392,6 +392,8 @@ async function main(): Promise { const notifyLevel = (notifyLevelArg === 'all' || notifyLevelArg === 'important' || notifyLevelArg === 'none') ? notifyLevelArg : undefined; + // verbose flag parsing is in PR #782 (--verbose) + // Load config: .squad/config.json merged with CLI overrides const config = loadWatchConfig(process.cwd(), { interval, diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 154b8e152..d9e78448f 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -171,6 +171,14 @@ export interface ReportOptions { repoName?: string; } +/** + * Report the current board state for a watch round. + * + * @param state - Current board counts + * @param round - Round number + * @param options - Reporting options. Defaults: `notifyLevel = 'important'` (empty + * rounds are suppressed; only rounds with work items are printed). + */ export function reportBoard(state: BoardState, round: number, options?: ReportOptions): void { const level = options?.notifyLevel ?? 'important'; if (level === 'none') return; @@ -180,15 +188,15 @@ export function reportBoard(state: BoardState, round: number, options?: ReportOp // In 'important' mode, suppress empty rounds entirely if (total === 0 && level === 'important') return; + // Build context tag for attribution (shown in all modes) + const ctx = [options?.machineName, options?.repoName].filter(Boolean).join(' ยท '); + const ctxSuffix = ctx ? ` ${DIM}(${ctx})${RESET}` : ''; + if (total === 0) { - console.log(`${DIM}๐Ÿ“‹ Board is clear โ€” Ralph is idling${RESET}`); + console.log(`${DIM}๐Ÿ“‹ Board is clear โ€” Ralph is idling${ctxSuffix}${RESET}`); return; } - // Build context tag for attribution - const ctx = [options?.machineName, options?.repoName].filter(Boolean).join(' ยท '); - const ctxSuffix = ctx ? ` ${DIM}(${ctx})${RESET}` : ''; - console.log(`\n${BOLD}๐Ÿ”„ Ralph โ€” Round ${round}${RESET}${ctxSuffix}`); console.log('โ”'.repeat(30)); if (state.untriaged > 0) console.log(` ๐Ÿ”ด Untriaged: ${state.untriaged}`); diff --git a/test/watch-notify-level.test.ts b/test/watch-notify-level.test.ts index d8a91ad35..e1588dad2 100644 --- a/test/watch-notify-level.test.ts +++ b/test/watch-notify-level.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { reportBoard } from '../packages/squad-cli/src/cli/commands/watch/index.js'; import type { BoardState } from '../packages/squad-cli/src/cli/commands/watch/index.js'; @@ -7,75 +7,51 @@ function emptyState(): BoardState { } describe('reportBoard notifyLevel', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('suppresses empty rounds in important mode (default)', () => { - const logs: string[] = []; - const origLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(' ')); - try { - reportBoard(emptyState(), 42, { notifyLevel: 'important' }); - expect(logs).toHaveLength(0); - } finally { - console.log = origLog; - } + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 42, { notifyLevel: 'important' }); + expect(spy).not.toHaveBeenCalled(); }); it('prints empty rounds in all mode', () => { - const logs: string[] = []; - const origLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(' ')); - try { - reportBoard(emptyState(), 42, { notifyLevel: 'all' }); - expect(logs.length).toBeGreaterThan(0); - expect(logs.some(l => l.includes('Board is clear'))).toBe(true); - } finally { - console.log = origLog; - } + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 42, { notifyLevel: 'all' }); + expect(spy).toHaveBeenCalled(); + const allOutput = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(allOutput).toContain('Board is clear'); }); it('suppresses everything in none mode', () => { - const logs: string[] = []; - const origLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(' ')); - try { - const busy = { ...emptyState(), untriaged: 3, ciFailures: 1 }; - reportBoard(busy, 10, { notifyLevel: 'none' }); - expect(logs).toHaveLength(0); - } finally { - console.log = origLog; - } + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const busy = { ...emptyState(), untriaged: 3, ciFailures: 1 }; + reportBoard(busy, 10, { notifyLevel: 'none' }); + expect(spy).not.toHaveBeenCalled(); }); it('prints busy rounds in important mode', () => { - const logs: string[] = []; - const origLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(' ')); - try { - const busy = { ...emptyState(), untriaged: 2, readyToMerge: 1 }; - reportBoard(busy, 5, { notifyLevel: 'important' }); - expect(logs.some(l => l.includes('Round 5'))).toBe(true); - expect(logs.some(l => l.includes('Untriaged'))).toBe(true); - } finally { - console.log = origLog; - } + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const busy = { ...emptyState(), untriaged: 2, readyToMerge: 1 }; + reportBoard(busy, 5, { notifyLevel: 'important' }); + const allOutput = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(allOutput).toContain('Round 5'); + expect(allOutput).toContain('Untriaged'); }); it('includes machine and repo in output when provided', () => { - const logs: string[] = []; - const origLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(' ')); - try { - const busy = { ...emptyState(), assigned: 1 }; - reportBoard(busy, 3, { - notifyLevel: 'all', - machineName: 'CPC-tamir-WCBED', - repoName: 'my-project', - }); - const roundLine = logs.find(l => l.includes('Round 3')); - expect(roundLine).toBeDefined(); - expect(roundLine).toContain('CPC-tamir-WCBED'); - expect(roundLine).toContain('my-project'); - } finally { - console.log = origLog; - } + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const busy = { ...emptyState(), assigned: 1 }; + reportBoard(busy, 3, { + notifyLevel: 'all', + machineName: 'CPC-tamir-WCBED', + repoName: 'my-project', + }); + const allOutput = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(allOutput).toContain('Round 3'); + expect(allOutput).toContain('CPC-tamir-WCBED'); + expect(allOutput).toContain('my-project'); }); });