From a98badfc010083e7992d17a3636f8a73a32092b7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Apr 2026 00:27:52 +0000 Subject: [PATCH 1/2] chore(openspec): archive 19 completed changes and sync delta specs Archive all implemented OpenSpec changes, syncing their delta specs to main specs in chronological order before moving to archive. Resolves 13 cross-change spec conflicts by applying older changes first so newer implementations take precedence. Archived changes: - add-discord-init-reminder-dm-support - background-job-execution - channel-ingress-attachments - channels-content-delivery-guarantees - containerize-daemon-and-evals - device-pairing - discord-channel-with-interactions - exposure-modes - graceful-config-restart-drain - hub-auth-framework - inbound-webhooks - mcp-audience-tool-grants - mcp-server-approval-defaults - multi-speaker-attribution - public-audience-security-hardening - session-cwd-tracking - structured-tool-call-metadata - tool-approval-gates - working-context-grounding New main specs created: audience-context-filtering, daemon-container, daemon-exposure, device-pairing, feature-selection-wizard, hub-auth, inbound-webhooks, netclaw-discord-socket, project-instructions, session-cwd, tool-call-metadata Remaining active changes: reminder-delivery-contract (partial), subagent-explicit-model-selection (blocked on #648) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-acl/spec.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-onboarding/spec.md | 0 .../specs/netclaw-scheduling/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/background-job-execution/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/netclaw-slack-socket/spec.md | 0 .../specs/tool-approval-gates/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-slack-socket/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/daemon-container/spec.md | 0 .../specs/netclaw-cli/spec.md | 0 .../tasks.md | 0 .../2026-04-29-device-pairing}/.openspec.yaml | 0 .../2026-04-29-device-pairing}/design.md | 0 .../2026-04-29-device-pairing}/proposal.md | 0 .../specs/device-pairing/spec.md | 0 .../specs/netclaw-gateway-security/spec.md | 0 .../2026-04-29-device-pairing}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-discord-socket/spec.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-testing/spec.md | 0 .../specs/slash-command-dispatch/spec.md | 0 .../specs/tool-approval-gates/spec.md | 0 .../tasks.md | 0 .../2026-04-29-exposure-modes}/.openspec.yaml | 0 .../2026-04-29-exposure-modes}/design.md | 0 .../2026-04-29-exposure-modes}/proposal.md | 0 .../specs/daemon-exposure/spec.md | 0 .../specs/netclaw-gateway-security/spec.md | 0 .../specs/netclaw-onboarding/spec.md | 0 .../2026-04-29-exposure-modes}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-config-hot-reload/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/session-resume/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-04-29-hub-auth-framework}/design.md | 0 .../proposal.md | 0 .../specs/hub-auth/spec.md | 0 .../specs/netclaw-gateway-security/spec.md | 0 .../2026-04-29-hub-auth-framework}/tasks.md | 0 .../.openspec.yaml | 0 .../2026-04-29-inbound-webhooks}/design.md | 0 .../2026-04-29-inbound-webhooks}/proposal.md | 0 .../specs/inbound-webhooks/spec.md | 0 .../specs/netclaw-gateway-security/spec.md | 0 .../2026-04-29-inbound-webhooks}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-acl/spec.md | 0 .../specs/netclaw-cli/spec.md | 0 .../specs/netclaw-mcp/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-acl/spec.md | 0 .../specs/netclaw-cli/spec.md | 0 .../specs/netclaw-mcp/spec.md | 0 .../specs/tool-approval-gates/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-acl/spec.md | 0 .../specs/netclaw-agent-memory/spec.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/slash-command-dispatch/spec.md | 0 .../specs/thread-history-backfill/spec.md | 0 .../specs/tool-approval-gates/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/audience-context-filtering/spec.md | 0 .../specs/feature-selection-wizard/spec.md | 0 .../specs/netclaw-agent-memory/spec.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-mcp/spec.md | 0 .../specs/netclaw-onboarding/spec.md | 0 .../specs/netclaw-scheduling/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/netclaw-subagents/spec.md | 0 .../specs/netclaw-tools/spec.md | 0 .../specs/security-posture-tui/spec.md | 0 .../specs/skill-tools/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/netclaw-tools/spec.md | 0 .../specs/project-instructions/spec.md | 0 .../specs/session-cwd/spec.md | 0 .../2026-04-29-session-cwd-tracking}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/netclaw-tools/spec.md | 0 .../specs/tool-call-metadata/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-04-29-tool-approval-gates}/design.md | 0 .../proposal.md | 0 .../specs/netclaw-acl/spec.md | 0 .../specs/netclaw-cli/spec.md | 0 .../specs/netclaw-input-adapters/spec.md | 0 .../specs/netclaw-session/spec.md | 0 .../specs/netclaw-slack-socket/spec.md | 0 .../specs/netclaw-tools/spec.md | 0 .../specs/tool-approval-gates/spec.md | 0 .../2026-04-29-tool-approval-gates}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/netclaw-session/spec.md | 0 .../tasks.md | 0 .../specs/audience-context-filtering/spec.md | 109 +++ .../specs/background-job-execution/spec.md | 10 +- openspec/specs/daemon-container/spec.md | 327 +++++++ openspec/specs/daemon-exposure/spec.md | 182 ++++ openspec/specs/device-pairing/spec.md | 165 ++++ .../specs/feature-selection-wizard/spec.md | 110 +++ openspec/specs/hub-auth/spec.md | 117 +++ openspec/specs/inbound-webhooks/spec.md | 309 ++++++ openspec/specs/netclaw-acl/spec.md | 157 +-- openspec/specs/netclaw-agent-memory/spec.md | 408 +------- openspec/specs/netclaw-cli/spec.md | 606 ------------ .../specs/netclaw-config-hot-reload/spec.md | 139 +-- openspec/specs/netclaw-discord-socket/spec.md | 98 ++ .../specs/netclaw-gateway-security/spec.md | 213 +---- openspec/specs/netclaw-input-adapters/spec.md | 705 +------------- openspec/specs/netclaw-mcp/spec.md | 283 +----- openspec/specs/netclaw-onboarding/spec.md | 340 +------ openspec/specs/netclaw-scheduling/spec.md | 893 +----------------- openspec/specs/netclaw-session/spec.md | 765 +-------------- openspec/specs/netclaw-slack-socket/spec.md | 302 ++---- openspec/specs/netclaw-subagents/spec.md | 250 +---- openspec/specs/netclaw-testing/spec.md | 31 +- openspec/specs/netclaw-tools/spec.md | 398 +------- openspec/specs/project-instructions/spec.md | 90 ++ openspec/specs/security-posture-tui/spec.md | 71 +- openspec/specs/session-cwd/spec.md | 111 +++ openspec/specs/session-resume/spec.md | 131 +-- openspec/specs/skill-tools/spec.md | 186 +--- openspec/specs/slash-command-dispatch/spec.md | 154 +-- .../specs/thread-history-backfill/spec.md | 124 +-- openspec/specs/tool-approval-gates/spec.md | 202 +--- openspec/specs/tool-call-metadata/spec.md | 209 ++++ 179 files changed, 2251 insertions(+), 5944 deletions(-) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/.openspec.yaml (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/design.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/proposal.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/specs/netclaw-acl/spec.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/specs/netclaw-onboarding/spec.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/specs/netclaw-scheduling/spec.md (100%) rename openspec/changes/{add-discord-init-reminder-dm-support => archive/2026-04-29-add-discord-init-reminder-dm-support}/tasks.md (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/.openspec.yaml (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/design.md (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/proposal.md (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/specs/background-job-execution/spec.md (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{background-job-execution => archive/2026-04-29-background-job-execution}/tasks.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/.openspec.yaml (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/design.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/proposal.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/specs/netclaw-slack-socket/spec.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/specs/tool-approval-gates/spec.md (100%) rename openspec/changes/{channel-ingress-attachments => archive/2026-04-29-channel-ingress-attachments}/tasks.md (100%) rename openspec/changes/{channels-content-delivery-guarantees => archive/2026-04-29-channels-content-delivery-guarantees}/.openspec.yaml (100%) rename openspec/changes/{channels-content-delivery-guarantees => archive/2026-04-29-channels-content-delivery-guarantees}/design.md (100%) rename openspec/changes/{channels-content-delivery-guarantees => archive/2026-04-29-channels-content-delivery-guarantees}/proposal.md (100%) rename openspec/changes/{channels-content-delivery-guarantees => archive/2026-04-29-channels-content-delivery-guarantees}/specs/netclaw-slack-socket/spec.md (100%) rename openspec/changes/{channels-content-delivery-guarantees => archive/2026-04-29-channels-content-delivery-guarantees}/tasks.md (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/.openspec.yaml (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/design.md (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/proposal.md (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/specs/daemon-container/spec.md (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/specs/netclaw-cli/spec.md (100%) rename openspec/changes/{containerize-daemon-and-evals => archive/2026-04-29-containerize-daemon-and-evals}/tasks.md (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/.openspec.yaml (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/design.md (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/proposal.md (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/specs/device-pairing/spec.md (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/specs/netclaw-gateway-security/spec.md (100%) rename openspec/changes/{device-pairing => archive/2026-04-29-device-pairing}/tasks.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/.openspec.yaml (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/design.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/proposal.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/specs/netclaw-discord-socket/spec.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/specs/netclaw-testing/spec.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/specs/slash-command-dispatch/spec.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/specs/tool-approval-gates/spec.md (100%) rename openspec/changes/{discord-channel-with-interactions => archive/2026-04-29-discord-channel-with-interactions}/tasks.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/.openspec.yaml (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/design.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/proposal.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/specs/daemon-exposure/spec.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/specs/netclaw-gateway-security/spec.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/specs/netclaw-onboarding/spec.md (100%) rename openspec/changes/{exposure-modes => archive/2026-04-29-exposure-modes}/tasks.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/.openspec.yaml (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/design.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/proposal.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/specs/netclaw-config-hot-reload/spec.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/specs/session-resume/spec.md (100%) rename openspec/changes/{graceful-config-restart-drain => archive/2026-04-29-graceful-config-restart-drain}/tasks.md (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/.openspec.yaml (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/design.md (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/proposal.md (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/specs/hub-auth/spec.md (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/specs/netclaw-gateway-security/spec.md (100%) rename openspec/changes/{hub-auth-framework => archive/2026-04-29-hub-auth-framework}/tasks.md (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/.openspec.yaml (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/design.md (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/proposal.md (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/specs/inbound-webhooks/spec.md (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/specs/netclaw-gateway-security/spec.md (100%) rename openspec/changes/{inbound-webhooks => archive/2026-04-29-inbound-webhooks}/tasks.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/.openspec.yaml (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/design.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/proposal.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/specs/netclaw-acl/spec.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/specs/netclaw-cli/spec.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/specs/netclaw-mcp/spec.md (100%) rename openspec/changes/{mcp-audience-tool-grants => archive/2026-04-29-mcp-audience-tool-grants}/tasks.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/.openspec.yaml (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/design.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/proposal.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/specs/netclaw-acl/spec.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/specs/netclaw-cli/spec.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/specs/netclaw-mcp/spec.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/specs/tool-approval-gates/spec.md (100%) rename openspec/changes/{mcp-server-approval-defaults => archive/2026-04-29-mcp-server-approval-defaults}/tasks.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/.openspec.yaml (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/design.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/proposal.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/netclaw-acl/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/netclaw-agent-memory/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/slash-command-dispatch/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/thread-history-backfill/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/specs/tool-approval-gates/spec.md (100%) rename openspec/changes/{multi-speaker-attribution => archive/2026-04-29-multi-speaker-attribution}/tasks.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/.openspec.yaml (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/design.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/proposal.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/audience-context-filtering/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/feature-selection-wizard/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-agent-memory/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-mcp/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-onboarding/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-scheduling/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-subagents/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/netclaw-tools/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/security-posture-tui/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/specs/skill-tools/spec.md (100%) rename openspec/changes/{public-audience-security-hardening => archive/2026-04-29-public-audience-security-hardening}/tasks.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/.openspec.yaml (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/design.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/proposal.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/specs/netclaw-tools/spec.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/specs/project-instructions/spec.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/specs/session-cwd/spec.md (100%) rename openspec/changes/{session-cwd-tracking => archive/2026-04-29-session-cwd-tracking}/tasks.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/.openspec.yaml (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/design.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/proposal.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/specs/netclaw-tools/spec.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/specs/tool-call-metadata/spec.md (100%) rename openspec/changes/{structured-tool-call-metadata => archive/2026-04-29-structured-tool-call-metadata}/tasks.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/.openspec.yaml (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/design.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/proposal.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-acl/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-cli/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-input-adapters/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-slack-socket/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/netclaw-tools/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/specs/tool-approval-gates/spec.md (100%) rename openspec/changes/{tool-approval-gates => archive/2026-04-29-tool-approval-gates}/tasks.md (100%) rename openspec/changes/{working-context-grounding => archive/2026-04-29-working-context-grounding}/.openspec.yaml (100%) rename openspec/changes/{working-context-grounding => archive/2026-04-29-working-context-grounding}/design.md (100%) rename openspec/changes/{working-context-grounding => archive/2026-04-29-working-context-grounding}/proposal.md (100%) rename openspec/changes/{working-context-grounding => archive/2026-04-29-working-context-grounding}/specs/netclaw-session/spec.md (100%) rename openspec/changes/{working-context-grounding => archive/2026-04-29-working-context-grounding}/tasks.md (100%) create mode 100644 openspec/specs/audience-context-filtering/spec.md create mode 100644 openspec/specs/daemon-container/spec.md create mode 100644 openspec/specs/daemon-exposure/spec.md create mode 100644 openspec/specs/device-pairing/spec.md create mode 100644 openspec/specs/feature-selection-wizard/spec.md create mode 100644 openspec/specs/hub-auth/spec.md create mode 100644 openspec/specs/inbound-webhooks/spec.md create mode 100644 openspec/specs/netclaw-discord-socket/spec.md create mode 100644 openspec/specs/project-instructions/spec.md create mode 100644 openspec/specs/session-cwd/spec.md create mode 100644 openspec/specs/tool-call-metadata/spec.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/.openspec.yaml b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/.openspec.yaml similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/.openspec.yaml rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/.openspec.yaml diff --git a/openspec/changes/add-discord-init-reminder-dm-support/design.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/design.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/design.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/design.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/proposal.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/proposal.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/proposal.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/proposal.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-acl/spec.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-acl/spec.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-acl/spec.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-acl/spec.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-onboarding/spec.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-onboarding/spec.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-onboarding/spec.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-scheduling/spec.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-scheduling/spec.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/specs/netclaw-scheduling/spec.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/specs/netclaw-scheduling/spec.md diff --git a/openspec/changes/add-discord-init-reminder-dm-support/tasks.md b/openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/tasks.md similarity index 100% rename from openspec/changes/add-discord-init-reminder-dm-support/tasks.md rename to openspec/changes/archive/2026-04-29-add-discord-init-reminder-dm-support/tasks.md diff --git a/openspec/changes/background-job-execution/.openspec.yaml b/openspec/changes/archive/2026-04-29-background-job-execution/.openspec.yaml similarity index 100% rename from openspec/changes/background-job-execution/.openspec.yaml rename to openspec/changes/archive/2026-04-29-background-job-execution/.openspec.yaml diff --git a/openspec/changes/background-job-execution/design.md b/openspec/changes/archive/2026-04-29-background-job-execution/design.md similarity index 100% rename from openspec/changes/background-job-execution/design.md rename to openspec/changes/archive/2026-04-29-background-job-execution/design.md diff --git a/openspec/changes/background-job-execution/proposal.md b/openspec/changes/archive/2026-04-29-background-job-execution/proposal.md similarity index 100% rename from openspec/changes/background-job-execution/proposal.md rename to openspec/changes/archive/2026-04-29-background-job-execution/proposal.md diff --git a/openspec/changes/background-job-execution/specs/background-job-execution/spec.md b/openspec/changes/archive/2026-04-29-background-job-execution/specs/background-job-execution/spec.md similarity index 100% rename from openspec/changes/background-job-execution/specs/background-job-execution/spec.md rename to openspec/changes/archive/2026-04-29-background-job-execution/specs/background-job-execution/spec.md diff --git a/openspec/changes/background-job-execution/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-background-job-execution/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/background-job-execution/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-background-job-execution/specs/netclaw-session/spec.md diff --git a/openspec/changes/background-job-execution/tasks.md b/openspec/changes/archive/2026-04-29-background-job-execution/tasks.md similarity index 100% rename from openspec/changes/background-job-execution/tasks.md rename to openspec/changes/archive/2026-04-29-background-job-execution/tasks.md diff --git a/openspec/changes/channel-ingress-attachments/.openspec.yaml b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/.openspec.yaml similarity index 100% rename from openspec/changes/channel-ingress-attachments/.openspec.yaml rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/.openspec.yaml diff --git a/openspec/changes/channel-ingress-attachments/design.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/design.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/design.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/design.md diff --git a/openspec/changes/channel-ingress-attachments/proposal.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/proposal.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/proposal.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/proposal.md diff --git a/openspec/changes/channel-ingress-attachments/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/channel-ingress-attachments/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-session/spec.md diff --git a/openspec/changes/channel-ingress-attachments/specs/netclaw-slack-socket/spec.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-slack-socket/spec.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/specs/netclaw-slack-socket/spec.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/netclaw-slack-socket/spec.md diff --git a/openspec/changes/channel-ingress-attachments/specs/tool-approval-gates/spec.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/tool-approval-gates/spec.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/specs/tool-approval-gates/spec.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/specs/tool-approval-gates/spec.md diff --git a/openspec/changes/channel-ingress-attachments/tasks.md b/openspec/changes/archive/2026-04-29-channel-ingress-attachments/tasks.md similarity index 100% rename from openspec/changes/channel-ingress-attachments/tasks.md rename to openspec/changes/archive/2026-04-29-channel-ingress-attachments/tasks.md diff --git a/openspec/changes/channels-content-delivery-guarantees/.openspec.yaml b/openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/.openspec.yaml similarity index 100% rename from openspec/changes/channels-content-delivery-guarantees/.openspec.yaml rename to openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/.openspec.yaml diff --git a/openspec/changes/channels-content-delivery-guarantees/design.md b/openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/design.md similarity index 100% rename from openspec/changes/channels-content-delivery-guarantees/design.md rename to openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/design.md diff --git a/openspec/changes/channels-content-delivery-guarantees/proposal.md b/openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/proposal.md similarity index 100% rename from openspec/changes/channels-content-delivery-guarantees/proposal.md rename to openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/proposal.md diff --git a/openspec/changes/channels-content-delivery-guarantees/specs/netclaw-slack-socket/spec.md b/openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/specs/netclaw-slack-socket/spec.md similarity index 100% rename from openspec/changes/channels-content-delivery-guarantees/specs/netclaw-slack-socket/spec.md rename to openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/specs/netclaw-slack-socket/spec.md diff --git a/openspec/changes/channels-content-delivery-guarantees/tasks.md b/openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/tasks.md similarity index 100% rename from openspec/changes/channels-content-delivery-guarantees/tasks.md rename to openspec/changes/archive/2026-04-29-channels-content-delivery-guarantees/tasks.md diff --git a/openspec/changes/containerize-daemon-and-evals/.openspec.yaml b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/.openspec.yaml similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/.openspec.yaml rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/.openspec.yaml diff --git a/openspec/changes/containerize-daemon-and-evals/design.md b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/design.md similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/design.md rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/design.md diff --git a/openspec/changes/containerize-daemon-and-evals/proposal.md b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/proposal.md similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/proposal.md rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/proposal.md diff --git a/openspec/changes/containerize-daemon-and-evals/specs/daemon-container/spec.md b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/specs/daemon-container/spec.md similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/specs/daemon-container/spec.md rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/specs/daemon-container/spec.md diff --git a/openspec/changes/containerize-daemon-and-evals/specs/netclaw-cli/spec.md b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/specs/netclaw-cli/spec.md similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/specs/netclaw-cli/spec.md rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/specs/netclaw-cli/spec.md diff --git a/openspec/changes/containerize-daemon-and-evals/tasks.md b/openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/tasks.md similarity index 100% rename from openspec/changes/containerize-daemon-and-evals/tasks.md rename to openspec/changes/archive/2026-04-29-containerize-daemon-and-evals/tasks.md diff --git a/openspec/changes/device-pairing/.openspec.yaml b/openspec/changes/archive/2026-04-29-device-pairing/.openspec.yaml similarity index 100% rename from openspec/changes/device-pairing/.openspec.yaml rename to openspec/changes/archive/2026-04-29-device-pairing/.openspec.yaml diff --git a/openspec/changes/device-pairing/design.md b/openspec/changes/archive/2026-04-29-device-pairing/design.md similarity index 100% rename from openspec/changes/device-pairing/design.md rename to openspec/changes/archive/2026-04-29-device-pairing/design.md diff --git a/openspec/changes/device-pairing/proposal.md b/openspec/changes/archive/2026-04-29-device-pairing/proposal.md similarity index 100% rename from openspec/changes/device-pairing/proposal.md rename to openspec/changes/archive/2026-04-29-device-pairing/proposal.md diff --git a/openspec/changes/device-pairing/specs/device-pairing/spec.md b/openspec/changes/archive/2026-04-29-device-pairing/specs/device-pairing/spec.md similarity index 100% rename from openspec/changes/device-pairing/specs/device-pairing/spec.md rename to openspec/changes/archive/2026-04-29-device-pairing/specs/device-pairing/spec.md diff --git a/openspec/changes/device-pairing/specs/netclaw-gateway-security/spec.md b/openspec/changes/archive/2026-04-29-device-pairing/specs/netclaw-gateway-security/spec.md similarity index 100% rename from openspec/changes/device-pairing/specs/netclaw-gateway-security/spec.md rename to openspec/changes/archive/2026-04-29-device-pairing/specs/netclaw-gateway-security/spec.md diff --git a/openspec/changes/device-pairing/tasks.md b/openspec/changes/archive/2026-04-29-device-pairing/tasks.md similarity index 100% rename from openspec/changes/device-pairing/tasks.md rename to openspec/changes/archive/2026-04-29-device-pairing/tasks.md diff --git a/openspec/changes/discord-channel-with-interactions/.openspec.yaml b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/.openspec.yaml similarity index 100% rename from openspec/changes/discord-channel-with-interactions/.openspec.yaml rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/.openspec.yaml diff --git a/openspec/changes/discord-channel-with-interactions/design.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/design.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/design.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/design.md diff --git a/openspec/changes/discord-channel-with-interactions/proposal.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/proposal.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/proposal.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/proposal.md diff --git a/openspec/changes/discord-channel-with-interactions/specs/netclaw-discord-socket/spec.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-discord-socket/spec.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/specs/netclaw-discord-socket/spec.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-discord-socket/spec.md diff --git a/openspec/changes/discord-channel-with-interactions/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/discord-channel-with-interactions/specs/netclaw-testing/spec.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-testing/spec.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/specs/netclaw-testing/spec.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/netclaw-testing/spec.md diff --git a/openspec/changes/discord-channel-with-interactions/specs/slash-command-dispatch/spec.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/slash-command-dispatch/spec.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/specs/slash-command-dispatch/spec.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/slash-command-dispatch/spec.md diff --git a/openspec/changes/discord-channel-with-interactions/specs/tool-approval-gates/spec.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/tool-approval-gates/spec.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/specs/tool-approval-gates/spec.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/specs/tool-approval-gates/spec.md diff --git a/openspec/changes/discord-channel-with-interactions/tasks.md b/openspec/changes/archive/2026-04-29-discord-channel-with-interactions/tasks.md similarity index 100% rename from openspec/changes/discord-channel-with-interactions/tasks.md rename to openspec/changes/archive/2026-04-29-discord-channel-with-interactions/tasks.md diff --git a/openspec/changes/exposure-modes/.openspec.yaml b/openspec/changes/archive/2026-04-29-exposure-modes/.openspec.yaml similarity index 100% rename from openspec/changes/exposure-modes/.openspec.yaml rename to openspec/changes/archive/2026-04-29-exposure-modes/.openspec.yaml diff --git a/openspec/changes/exposure-modes/design.md b/openspec/changes/archive/2026-04-29-exposure-modes/design.md similarity index 100% rename from openspec/changes/exposure-modes/design.md rename to openspec/changes/archive/2026-04-29-exposure-modes/design.md diff --git a/openspec/changes/exposure-modes/proposal.md b/openspec/changes/archive/2026-04-29-exposure-modes/proposal.md similarity index 100% rename from openspec/changes/exposure-modes/proposal.md rename to openspec/changes/archive/2026-04-29-exposure-modes/proposal.md diff --git a/openspec/changes/exposure-modes/specs/daemon-exposure/spec.md b/openspec/changes/archive/2026-04-29-exposure-modes/specs/daemon-exposure/spec.md similarity index 100% rename from openspec/changes/exposure-modes/specs/daemon-exposure/spec.md rename to openspec/changes/archive/2026-04-29-exposure-modes/specs/daemon-exposure/spec.md diff --git a/openspec/changes/exposure-modes/specs/netclaw-gateway-security/spec.md b/openspec/changes/archive/2026-04-29-exposure-modes/specs/netclaw-gateway-security/spec.md similarity index 100% rename from openspec/changes/exposure-modes/specs/netclaw-gateway-security/spec.md rename to openspec/changes/archive/2026-04-29-exposure-modes/specs/netclaw-gateway-security/spec.md diff --git a/openspec/changes/exposure-modes/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-04-29-exposure-modes/specs/netclaw-onboarding/spec.md similarity index 100% rename from openspec/changes/exposure-modes/specs/netclaw-onboarding/spec.md rename to openspec/changes/archive/2026-04-29-exposure-modes/specs/netclaw-onboarding/spec.md diff --git a/openspec/changes/exposure-modes/tasks.md b/openspec/changes/archive/2026-04-29-exposure-modes/tasks.md similarity index 100% rename from openspec/changes/exposure-modes/tasks.md rename to openspec/changes/archive/2026-04-29-exposure-modes/tasks.md diff --git a/openspec/changes/graceful-config-restart-drain/.openspec.yaml b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/.openspec.yaml similarity index 100% rename from openspec/changes/graceful-config-restart-drain/.openspec.yaml rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/.openspec.yaml diff --git a/openspec/changes/graceful-config-restart-drain/design.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/design.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/design.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/design.md diff --git a/openspec/changes/graceful-config-restart-drain/proposal.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/proposal.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/proposal.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/proposal.md diff --git a/openspec/changes/graceful-config-restart-drain/specs/netclaw-config-hot-reload/spec.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/netclaw-config-hot-reload/spec.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/specs/netclaw-config-hot-reload/spec.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/netclaw-config-hot-reload/spec.md diff --git a/openspec/changes/graceful-config-restart-drain/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/netclaw-session/spec.md diff --git a/openspec/changes/graceful-config-restart-drain/specs/session-resume/spec.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/session-resume/spec.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/specs/session-resume/spec.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/specs/session-resume/spec.md diff --git a/openspec/changes/graceful-config-restart-drain/tasks.md b/openspec/changes/archive/2026-04-29-graceful-config-restart-drain/tasks.md similarity index 100% rename from openspec/changes/graceful-config-restart-drain/tasks.md rename to openspec/changes/archive/2026-04-29-graceful-config-restart-drain/tasks.md diff --git a/openspec/changes/hub-auth-framework/.openspec.yaml b/openspec/changes/archive/2026-04-29-hub-auth-framework/.openspec.yaml similarity index 100% rename from openspec/changes/hub-auth-framework/.openspec.yaml rename to openspec/changes/archive/2026-04-29-hub-auth-framework/.openspec.yaml diff --git a/openspec/changes/hub-auth-framework/design.md b/openspec/changes/archive/2026-04-29-hub-auth-framework/design.md similarity index 100% rename from openspec/changes/hub-auth-framework/design.md rename to openspec/changes/archive/2026-04-29-hub-auth-framework/design.md diff --git a/openspec/changes/hub-auth-framework/proposal.md b/openspec/changes/archive/2026-04-29-hub-auth-framework/proposal.md similarity index 100% rename from openspec/changes/hub-auth-framework/proposal.md rename to openspec/changes/archive/2026-04-29-hub-auth-framework/proposal.md diff --git a/openspec/changes/hub-auth-framework/specs/hub-auth/spec.md b/openspec/changes/archive/2026-04-29-hub-auth-framework/specs/hub-auth/spec.md similarity index 100% rename from openspec/changes/hub-auth-framework/specs/hub-auth/spec.md rename to openspec/changes/archive/2026-04-29-hub-auth-framework/specs/hub-auth/spec.md diff --git a/openspec/changes/hub-auth-framework/specs/netclaw-gateway-security/spec.md b/openspec/changes/archive/2026-04-29-hub-auth-framework/specs/netclaw-gateway-security/spec.md similarity index 100% rename from openspec/changes/hub-auth-framework/specs/netclaw-gateway-security/spec.md rename to openspec/changes/archive/2026-04-29-hub-auth-framework/specs/netclaw-gateway-security/spec.md diff --git a/openspec/changes/hub-auth-framework/tasks.md b/openspec/changes/archive/2026-04-29-hub-auth-framework/tasks.md similarity index 100% rename from openspec/changes/hub-auth-framework/tasks.md rename to openspec/changes/archive/2026-04-29-hub-auth-framework/tasks.md diff --git a/openspec/changes/inbound-webhooks/.openspec.yaml b/openspec/changes/archive/2026-04-29-inbound-webhooks/.openspec.yaml similarity index 100% rename from openspec/changes/inbound-webhooks/.openspec.yaml rename to openspec/changes/archive/2026-04-29-inbound-webhooks/.openspec.yaml diff --git a/openspec/changes/inbound-webhooks/design.md b/openspec/changes/archive/2026-04-29-inbound-webhooks/design.md similarity index 100% rename from openspec/changes/inbound-webhooks/design.md rename to openspec/changes/archive/2026-04-29-inbound-webhooks/design.md diff --git a/openspec/changes/inbound-webhooks/proposal.md b/openspec/changes/archive/2026-04-29-inbound-webhooks/proposal.md similarity index 100% rename from openspec/changes/inbound-webhooks/proposal.md rename to openspec/changes/archive/2026-04-29-inbound-webhooks/proposal.md diff --git a/openspec/changes/inbound-webhooks/specs/inbound-webhooks/spec.md b/openspec/changes/archive/2026-04-29-inbound-webhooks/specs/inbound-webhooks/spec.md similarity index 100% rename from openspec/changes/inbound-webhooks/specs/inbound-webhooks/spec.md rename to openspec/changes/archive/2026-04-29-inbound-webhooks/specs/inbound-webhooks/spec.md diff --git a/openspec/changes/inbound-webhooks/specs/netclaw-gateway-security/spec.md b/openspec/changes/archive/2026-04-29-inbound-webhooks/specs/netclaw-gateway-security/spec.md similarity index 100% rename from openspec/changes/inbound-webhooks/specs/netclaw-gateway-security/spec.md rename to openspec/changes/archive/2026-04-29-inbound-webhooks/specs/netclaw-gateway-security/spec.md diff --git a/openspec/changes/inbound-webhooks/tasks.md b/openspec/changes/archive/2026-04-29-inbound-webhooks/tasks.md similarity index 100% rename from openspec/changes/inbound-webhooks/tasks.md rename to openspec/changes/archive/2026-04-29-inbound-webhooks/tasks.md diff --git a/openspec/changes/mcp-audience-tool-grants/.openspec.yaml b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/.openspec.yaml similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/.openspec.yaml rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/.openspec.yaml diff --git a/openspec/changes/mcp-audience-tool-grants/design.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/design.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/design.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/design.md diff --git a/openspec/changes/mcp-audience-tool-grants/proposal.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/proposal.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/proposal.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/proposal.md diff --git a/openspec/changes/mcp-audience-tool-grants/specs/netclaw-acl/spec.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-acl/spec.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/specs/netclaw-acl/spec.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-acl/spec.md diff --git a/openspec/changes/mcp-audience-tool-grants/specs/netclaw-cli/spec.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-cli/spec.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/specs/netclaw-cli/spec.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-cli/spec.md diff --git a/openspec/changes/mcp-audience-tool-grants/specs/netclaw-mcp/spec.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-mcp/spec.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/specs/netclaw-mcp/spec.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/specs/netclaw-mcp/spec.md diff --git a/openspec/changes/mcp-audience-tool-grants/tasks.md b/openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/tasks.md similarity index 100% rename from openspec/changes/mcp-audience-tool-grants/tasks.md rename to openspec/changes/archive/2026-04-29-mcp-audience-tool-grants/tasks.md diff --git a/openspec/changes/mcp-server-approval-defaults/.openspec.yaml b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/.openspec.yaml similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/.openspec.yaml rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/.openspec.yaml diff --git a/openspec/changes/mcp-server-approval-defaults/design.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/design.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/design.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/design.md diff --git a/openspec/changes/mcp-server-approval-defaults/proposal.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/proposal.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/proposal.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/proposal.md diff --git a/openspec/changes/mcp-server-approval-defaults/specs/netclaw-acl/spec.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-acl/spec.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/specs/netclaw-acl/spec.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-acl/spec.md diff --git a/openspec/changes/mcp-server-approval-defaults/specs/netclaw-cli/spec.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-cli/spec.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/specs/netclaw-cli/spec.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-cli/spec.md diff --git a/openspec/changes/mcp-server-approval-defaults/specs/netclaw-mcp/spec.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-mcp/spec.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/specs/netclaw-mcp/spec.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/netclaw-mcp/spec.md diff --git a/openspec/changes/mcp-server-approval-defaults/specs/tool-approval-gates/spec.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/tool-approval-gates/spec.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/specs/tool-approval-gates/spec.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/specs/tool-approval-gates/spec.md diff --git a/openspec/changes/mcp-server-approval-defaults/tasks.md b/openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/tasks.md similarity index 100% rename from openspec/changes/mcp-server-approval-defaults/tasks.md rename to openspec/changes/archive/2026-04-29-mcp-server-approval-defaults/tasks.md diff --git a/openspec/changes/multi-speaker-attribution/.openspec.yaml b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/.openspec.yaml similarity index 100% rename from openspec/changes/multi-speaker-attribution/.openspec.yaml rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/.openspec.yaml diff --git a/openspec/changes/multi-speaker-attribution/design.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/design.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/design.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/design.md diff --git a/openspec/changes/multi-speaker-attribution/proposal.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/proposal.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/proposal.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/proposal.md diff --git a/openspec/changes/multi-speaker-attribution/specs/netclaw-acl/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-acl/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/netclaw-acl/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-acl/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/netclaw-agent-memory/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-agent-memory/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/netclaw-agent-memory/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-agent-memory/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/netclaw-session/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/slash-command-dispatch/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/slash-command-dispatch/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/slash-command-dispatch/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/slash-command-dispatch/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/thread-history-backfill/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/thread-history-backfill/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/thread-history-backfill/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/thread-history-backfill/spec.md diff --git a/openspec/changes/multi-speaker-attribution/specs/tool-approval-gates/spec.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/tool-approval-gates/spec.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/specs/tool-approval-gates/spec.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/specs/tool-approval-gates/spec.md diff --git a/openspec/changes/multi-speaker-attribution/tasks.md b/openspec/changes/archive/2026-04-29-multi-speaker-attribution/tasks.md similarity index 100% rename from openspec/changes/multi-speaker-attribution/tasks.md rename to openspec/changes/archive/2026-04-29-multi-speaker-attribution/tasks.md diff --git a/openspec/changes/public-audience-security-hardening/.openspec.yaml b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/.openspec.yaml similarity index 100% rename from openspec/changes/public-audience-security-hardening/.openspec.yaml rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/.openspec.yaml diff --git a/openspec/changes/public-audience-security-hardening/design.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/design.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/design.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/design.md diff --git a/openspec/changes/public-audience-security-hardening/proposal.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/proposal.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/proposal.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/proposal.md diff --git a/openspec/changes/public-audience-security-hardening/specs/audience-context-filtering/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/audience-context-filtering/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/audience-context-filtering/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/audience-context-filtering/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/feature-selection-wizard/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/feature-selection-wizard/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/feature-selection-wizard/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/feature-selection-wizard/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-agent-memory/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-agent-memory/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-agent-memory/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-agent-memory/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-mcp/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-mcp/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-mcp/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-mcp/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-onboarding/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-onboarding/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-onboarding/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-scheduling/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-scheduling/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-scheduling/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-scheduling/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-session/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-subagents/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-subagents/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-subagents/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-subagents/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/netclaw-tools/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-tools/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/netclaw-tools/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/netclaw-tools/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/security-posture-tui/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/security-posture-tui/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/security-posture-tui/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/security-posture-tui/spec.md diff --git a/openspec/changes/public-audience-security-hardening/specs/skill-tools/spec.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/skill-tools/spec.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/specs/skill-tools/spec.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/specs/skill-tools/spec.md diff --git a/openspec/changes/public-audience-security-hardening/tasks.md b/openspec/changes/archive/2026-04-29-public-audience-security-hardening/tasks.md similarity index 100% rename from openspec/changes/public-audience-security-hardening/tasks.md rename to openspec/changes/archive/2026-04-29-public-audience-security-hardening/tasks.md diff --git a/openspec/changes/session-cwd-tracking/.openspec.yaml b/openspec/changes/archive/2026-04-29-session-cwd-tracking/.openspec.yaml similarity index 100% rename from openspec/changes/session-cwd-tracking/.openspec.yaml rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/.openspec.yaml diff --git a/openspec/changes/session-cwd-tracking/design.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/design.md similarity index 100% rename from openspec/changes/session-cwd-tracking/design.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/design.md diff --git a/openspec/changes/session-cwd-tracking/proposal.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/proposal.md similarity index 100% rename from openspec/changes/session-cwd-tracking/proposal.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/proposal.md diff --git a/openspec/changes/session-cwd-tracking/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/session-cwd-tracking/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/netclaw-session/spec.md diff --git a/openspec/changes/session-cwd-tracking/specs/netclaw-tools/spec.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/netclaw-tools/spec.md similarity index 100% rename from openspec/changes/session-cwd-tracking/specs/netclaw-tools/spec.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/netclaw-tools/spec.md diff --git a/openspec/changes/session-cwd-tracking/specs/project-instructions/spec.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/project-instructions/spec.md similarity index 100% rename from openspec/changes/session-cwd-tracking/specs/project-instructions/spec.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/project-instructions/spec.md diff --git a/openspec/changes/session-cwd-tracking/specs/session-cwd/spec.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/session-cwd/spec.md similarity index 100% rename from openspec/changes/session-cwd-tracking/specs/session-cwd/spec.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/specs/session-cwd/spec.md diff --git a/openspec/changes/session-cwd-tracking/tasks.md b/openspec/changes/archive/2026-04-29-session-cwd-tracking/tasks.md similarity index 100% rename from openspec/changes/session-cwd-tracking/tasks.md rename to openspec/changes/archive/2026-04-29-session-cwd-tracking/tasks.md diff --git a/openspec/changes/structured-tool-call-metadata/.openspec.yaml b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/.openspec.yaml similarity index 100% rename from openspec/changes/structured-tool-call-metadata/.openspec.yaml rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/.openspec.yaml diff --git a/openspec/changes/structured-tool-call-metadata/design.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/design.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/design.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/design.md diff --git a/openspec/changes/structured-tool-call-metadata/proposal.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/proposal.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/proposal.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/proposal.md diff --git a/openspec/changes/structured-tool-call-metadata/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/netclaw-session/spec.md diff --git a/openspec/changes/structured-tool-call-metadata/specs/netclaw-tools/spec.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/netclaw-tools/spec.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/specs/netclaw-tools/spec.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/netclaw-tools/spec.md diff --git a/openspec/changes/structured-tool-call-metadata/specs/tool-call-metadata/spec.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/tool-call-metadata/spec.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/specs/tool-call-metadata/spec.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/specs/tool-call-metadata/spec.md diff --git a/openspec/changes/structured-tool-call-metadata/tasks.md b/openspec/changes/archive/2026-04-29-structured-tool-call-metadata/tasks.md similarity index 100% rename from openspec/changes/structured-tool-call-metadata/tasks.md rename to openspec/changes/archive/2026-04-29-structured-tool-call-metadata/tasks.md diff --git a/openspec/changes/tool-approval-gates/.openspec.yaml b/openspec/changes/archive/2026-04-29-tool-approval-gates/.openspec.yaml similarity index 100% rename from openspec/changes/tool-approval-gates/.openspec.yaml rename to openspec/changes/archive/2026-04-29-tool-approval-gates/.openspec.yaml diff --git a/openspec/changes/tool-approval-gates/design.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/design.md similarity index 100% rename from openspec/changes/tool-approval-gates/design.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/design.md diff --git a/openspec/changes/tool-approval-gates/proposal.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/proposal.md similarity index 100% rename from openspec/changes/tool-approval-gates/proposal.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/proposal.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-acl/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-acl/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-acl/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-acl/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-cli/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-cli/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-cli/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-cli/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-input-adapters/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-input-adapters/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-input-adapters/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-input-adapters/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-session/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-slack-socket/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-slack-socket/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-slack-socket/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-slack-socket/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/netclaw-tools/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-tools/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/netclaw-tools/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/netclaw-tools/spec.md diff --git a/openspec/changes/tool-approval-gates/specs/tool-approval-gates/spec.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/specs/tool-approval-gates/spec.md similarity index 100% rename from openspec/changes/tool-approval-gates/specs/tool-approval-gates/spec.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/specs/tool-approval-gates/spec.md diff --git a/openspec/changes/tool-approval-gates/tasks.md b/openspec/changes/archive/2026-04-29-tool-approval-gates/tasks.md similarity index 100% rename from openspec/changes/tool-approval-gates/tasks.md rename to openspec/changes/archive/2026-04-29-tool-approval-gates/tasks.md diff --git a/openspec/changes/working-context-grounding/.openspec.yaml b/openspec/changes/archive/2026-04-29-working-context-grounding/.openspec.yaml similarity index 100% rename from openspec/changes/working-context-grounding/.openspec.yaml rename to openspec/changes/archive/2026-04-29-working-context-grounding/.openspec.yaml diff --git a/openspec/changes/working-context-grounding/design.md b/openspec/changes/archive/2026-04-29-working-context-grounding/design.md similarity index 100% rename from openspec/changes/working-context-grounding/design.md rename to openspec/changes/archive/2026-04-29-working-context-grounding/design.md diff --git a/openspec/changes/working-context-grounding/proposal.md b/openspec/changes/archive/2026-04-29-working-context-grounding/proposal.md similarity index 100% rename from openspec/changes/working-context-grounding/proposal.md rename to openspec/changes/archive/2026-04-29-working-context-grounding/proposal.md diff --git a/openspec/changes/working-context-grounding/specs/netclaw-session/spec.md b/openspec/changes/archive/2026-04-29-working-context-grounding/specs/netclaw-session/spec.md similarity index 100% rename from openspec/changes/working-context-grounding/specs/netclaw-session/spec.md rename to openspec/changes/archive/2026-04-29-working-context-grounding/specs/netclaw-session/spec.md diff --git a/openspec/changes/working-context-grounding/tasks.md b/openspec/changes/archive/2026-04-29-working-context-grounding/tasks.md similarity index 100% rename from openspec/changes/working-context-grounding/tasks.md rename to openspec/changes/archive/2026-04-29-working-context-grounding/tasks.md diff --git a/openspec/specs/audience-context-filtering/spec.md b/openspec/specs/audience-context-filtering/spec.md new file mode 100644 index 000000000..43264714e --- /dev/null +++ b/openspec/specs/audience-context-filtering/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Context layer audience filtering + +The context layer system SHALL accept a `TrustAudience` parameter on +`IContextLayerProvider.GetContextLayer()`. Each context layer implementation +SHALL use the audience to determine what content to return. The +`ContextAssemblyInput` record SHALL include a `TrustAudience Audience` field. +When a feature is disabled deployment-wide, the corresponding context layer +SHALL also return empty even for non-Public audiences. + +#### Scenario: Public audience receives no skill index + +- **WHEN** a Public-audience session assembles context +- **THEN** `SkillIndexContextLayer.GetContextLayer(Public)` returns empty string +- **AND** no skill index appears in the session's system messages + +#### Scenario: Public audience receives no memory index + +- **WHEN** a Public-audience session assembles context +- **THEN** `MemoryIndexContextLayer.GetContextLayer(Public)` returns empty string +- **AND** no memory tool hints appear in the session's system messages + +#### Scenario: Public audience receives no subagent discovery + +- **WHEN** a Public-audience session assembles context +- **THEN** `SubAgentDiscoveryContextLayer.GetContextLayer(Public)` returns empty string +- **AND** no subagent index appears in the session's system messages + +#### Scenario: Disabled skills feature suppresses skill index for Team + +- **GIVEN** `SkillSync.Enabled` is `false` in config +- **WHEN** a Team-audience session assembles context +- **THEN** `SkillIndexContextLayer.GetContextLayer(Team)` returns empty string +- **AND** no skill index appears in the session's system messages + +#### Scenario: Team audience receives all allowed context layers + +- **WHEN** a Team-audience session assembles context +- **THEN** all enabled context layers return their full content + +#### Scenario: Personal audience receives all allowed context layers + +- **WHEN** a Personal-audience session assembles context +- **THEN** all enabled context layers return their full content + +### Requirement: Session block path redaction + +The session block injected into system messages SHALL omit filesystem paths +for Public-audience sessions. The session ID SHALL remain visible for all +audiences. + +#### Scenario: Public session block contains ID only + +- **WHEN** a Public-audience session assembles the static context block +- **THEN** the session block contains `[session]\nid: {sessionId}` +- **AND** no `session_dir` or `media_dir` lines are present + +#### Scenario: Team session block contains full paths + +- **WHEN** a Team-audience session assembles the static context block +- **THEN** the session block contains `id`, `session_dir`, and `media_dir` + +### Requirement: Working context suppression for Public + +The working context block (project directory, recent files) SHALL NOT be +injected into Public-audience sessions. + +#### Scenario: Public session has no working context + +- **WHEN** a Public-audience session has a non-empty working context +- **THEN** `WorkingContext.ToContextBlock()` is NOT injected into the volatile context block + +#### Scenario: Team session receives working context + +- **WHEN** a Team-audience session has a non-empty working context +- **THEN** `WorkingContext.ToContextBlock()` IS injected into the volatile context block + +### Requirement: File access error message sanitization + +File access denial messages for Public-audience sessions SHALL NOT include +the list of allowed root paths or mention the session directory as an allowed +root. Team and Personal audiences SHALL continue to receive verbose error +messages including allowed roots. + +#### Scenario: Public file access denial omits roots + +- **WHEN** a Public-audience session attempts to read a file outside allowed roots +- **THEN** the error message does not reveal any allowed root +- **AND** no root paths are listed in the error +- **AND** the session directory is not named or implied in the error + +#### Scenario: Team file access denial includes roots + +- **WHEN** a Team-audience session attempts to read a file outside allowed roots +- **THEN** the error message includes the list of allowed root paths + +### Requirement: Public audience has no implicit internal file roots + +Public file access SHALL NOT implicitly include identity, skills, or workspaces +roots through global/default file-root configuration. + +#### Scenario: Public file access is session-scoped only + +- **GIVEN** a Public-audience session with default file access configuration +- **WHEN** it resolves implicit readable roots +- **THEN** the resolved roots include only session-scoped locations +- **AND** identity, skills, and workspaces roots are absent unless explicitly + configured for a non-Public audience diff --git a/openspec/specs/background-job-execution/spec.md b/openspec/specs/background-job-execution/spec.md index 903237128..a8e5f4ae5 100644 --- a/openspec/specs/background-job-execution/spec.md +++ b/openspec/specs/background-job-execution/spec.md @@ -1,12 +1,4 @@ -# background-job-execution Specification - -## Purpose - -Define the background job execution infrastructure: manager lifecycle, -process execution, pipeline routing, session state tracking, job monitoring -tool, delivery scoping, and deduplication. - -## Requirements +## ADDED Requirements ### Requirement: Background job manager lifecycle diff --git a/openspec/specs/daemon-container/spec.md b/openspec/specs/daemon-container/spec.md new file mode 100644 index 000000000..df6ff3e6d --- /dev/null +++ b/openspec/specs/daemon-container/spec.md @@ -0,0 +1,327 @@ +## ADDED Requirements + +### Requirement: Release-grade Docker image published on tag + +The project SHALL publish a `netclawd` Docker image to the GitHub Container +Registry (`ghcr.io/aaronontheweb/netclawd`) on every release tag. The image +SHALL be tagged with the exact version (`{{version}}`), major.minor +(`{{major}}.{{minor}}`), and `latest`. The image SHALL be built from +`docker/Dockerfile` via the shared `scripts/docker/build-image.sh` entrypoint +so that PR validation and release publishing share one code path. + +#### Scenario: Tag push publishes all three tag aliases + +- **GIVEN** the release workflow runs on a tag push of `v0.12.0` +- **WHEN** the `publish-docker` job succeeds +- **THEN** `ghcr.io/aaronontheweb/netclawd:v0.12.0`, `:v0.12`, and `:latest` + all reference the same image digest + +#### Scenario: PR validation and release use the same build script + +- **GIVEN** both the `validate-docker-build` and `publish-docker` jobs run + on a release commit +- **WHEN** the release workflow builds its image +- **THEN** the image is produced by invoking `scripts/docker/build-image.sh` + identically to how the PR validation job invokes it — no inline + `docker build` commands in the workflow YAML + +### Requirement: Image entrypoint auto-starts netclawd + +The image SHALL declare `ENTRYPOINT ["/usr/local/bin/netclawd"]`. Starting +the container via `docker run` SHALL launch the daemon as PID 1 without +requiring any additional command, matching the Docker idiom for published +service images (Postgres, Redis, Elasticsearch). + +#### Scenario: docker run starts the daemon + +- **GIVEN** the image is present locally +- **WHEN** an operator runs `docker run -d --rm ` with the minimum + valid configuration env vars and an identity bind-mount +- **THEN** `netclawd` is PID 1 inside the container +- **AND** the daemon binds its HTTP port within 60 seconds + +### Requirement: Minimal valid configuration reaches healthy state + +The image SHALL reach a healthy state within 60 seconds of container start +when provided with a minimal valid configuration: a `NETCLAW_Daemon__Host` +override of `0.0.0.0` (so the HTTP listener accepts non-loopback requests +from port-mapped or host-networked clients) and either provider +configuration env vars or the daemon's built-in default provider. The +`/api/health/ready` endpoint SHALL return `"healthy"` once the daemon +finishes startup. + +#### Scenario: Minimal valid config reaches healthy state + +- **GIVEN** a container started with `NETCLAW_Daemon__Host=0.0.0.0` and the + host forwarding a port to 5199 +- **WHEN** the daemon finishes startup +- **THEN** `GET /api/health/ready` returns `"healthy"` within 60 seconds +- **AND** the container is still running + +#### Scenario: Eval-style minimal config reaches healthy state + +- **GIVEN** a container started with `NETCLAW_Daemon__Host=0.0.0.0`, a + provider env-var triple (`NETCLAW_Providers____Type`, `__Endpoint`, + and matching `NETCLAW_Models__Main__Provider`/`__ModelId`), and a + read-only identity bind-mount +- **WHEN** the daemon finishes startup +- **THEN** `GET /api/health/ready` returns `"healthy"` within 60 seconds + +### Requirement: Env-var configuration surface + +The image SHALL accept all daemon configuration via environment variables +prefixed with `NETCLAW_`, using `__` as the `IConfiguration` section +separator (e.g. `NETCLAW_Daemon__Port=5299`, +`NETCLAW_Providers__eval__Endpoint=http://127.0.0.1:1234/v1`). No config +file baked into the image SHALL supply provider credentials or model +selection — these MUST come from the operator at `docker run` time. + +#### Scenario: Env vars override defaults + +- **GIVEN** `NETCLAW_Daemon__Port=5299` is passed to `docker run` +- **WHEN** the daemon starts +- **THEN** the HTTP listener binds on port 5299, not the default 5199 + +#### Scenario: No image-baked provider credentials + +- **GIVEN** the freshly built image +- **WHEN** `docker image inspect` examines the image layers +- **THEN** no layer contains a `config/netclaw.json` or `config/secrets.json` + file with a non-empty `Providers` section + +### Requirement: Operator state mounts at /root/.netclaw + +The image SHALL declare `VOLUME /root/.netclaw` so operators can mount a +host directory (or anonymous volume) to persist identity files, session +state, SQLite DB, and logs. The image SHALL NOT pre-populate this path +with identity files, config, or secrets — real operators are expected to +produce these via `netclaw init` before starting the container. + +#### Scenario: Operator bind-mounts an initialized home + +- **GIVEN** an operator has previously run `netclaw init` on the host and + has a populated `~/.netclaw/` +- **WHEN** they run `docker run -v ~/.netclaw:/root/.netclaw ` +- **THEN** the container daemon reads their identity and config from the + mounted directory +- **AND** writes new session state back to the host path + +### Requirement: Image includes common autonomous-agent tooling + +The image SHALL include the shell tools that the autonomous agent commonly +invokes via its `shell_execute` tool: `curl`, `wget`, `git`, `jq`, +`sqlite3`, `python3`, and `gh`. The image SHALL also include the companion +`netclaw` CLI binary on PATH so in-container `shell_execute` calls can run +the CLI directly (for example, `netclaw doctor`). + +#### Scenario: CLI on PATH inside container + +- **GIVEN** a running container +- **WHEN** `docker exec which netclaw` is invoked +- **THEN** it returns a path under `/usr/local/bin/` and exits 0 + +#### Scenario: Common shell tools available + +- **GIVEN** a running container +- **WHEN** `docker exec bash -c "command -v git jq sqlite3 python3 gh curl wget"` runs +- **THEN** every tool resolves to a non-empty path and exits 0 + +### Requirement: Base image permits runtime apt install + +The image SHALL be based on an operating system that supports package +installation at runtime (Ubuntu, Debian, or similar). The image SHALL NOT +be based on a chiseled or distroless runtime that removes `apt`/`dpkg`, +because the autonomous agent's high-permissions use case requires the +ability to install additional tools on demand. + +#### Scenario: apt-get update succeeds inside container + +- **GIVEN** a running container with network access +- **WHEN** `docker exec apt-get update` runs +- **THEN** the command exits 0 + +### Requirement: Local build script is the single build entrypoint + +The project SHALL provide `scripts/docker/build-image.sh` as the only +supported path for building the release image. The script SHALL publish +self-contained `linux-x64` binaries for `netclaw` and `netclawd` to +`./publish/{cli,daemon}` and invoke `docker build` against +`docker/Dockerfile`. The script SHALL support an `IMAGE_REPO` environment +variable so contributors can push to a personal fork and a positional +version argument (default `dev`). The script SHALL exit non-zero if the +expected binary outputs are absent after `dotnet publish`. + +#### Scenario: Default invocation builds :dev tag + +- **WHEN** a contributor runs `scripts/docker/build-image.sh` with no arguments +- **THEN** the script builds `ghcr.io/aaronontheweb/netclawd:dev` +- **AND** `docker images` lists the tag + +#### Scenario: Custom version and repo + +- **WHEN** a contributor runs `IMAGE_REPO=ghcr.io/user/nc scripts/docker/build-image.sh v0.11.1` +- **THEN** the script builds `ghcr.io/user/nc:v0.11.1` + +#### Scenario: Missing binaries fail loudly + +- **GIVEN** `./publish/daemon/netclawd` has been deleted mid-run +- **WHEN** the script reaches the `docker build` step +- **THEN** the script prints an error naming the missing path +- **AND** exits with a non-zero status before running `docker build` + +### Requirement: Dedicated Docker validation workflow + +The project SHALL ship a standalone GitHub Actions workflow +(`.github/workflows/validate_docker_image.yml`) that builds and +smoke-tests the release Docker image on every pull request and on +pushes to `dev`/`main`/`master`. The workflow SHALL NOT be lumped +into `pr_validation.yml` (.NET test suites + slopwatch) or +`smoke_sandbox.yml` (Ollama-in-Docker end-to-end), because image +construction is an orthogonal concern with its own failure mode. + +The workflow SHALL build the image via `scripts/docker/build-image.sh` +with no registry push, then start the image with a stub ollama +provider and verify that `GET /api/health/ready` returns `"healthy"` +within 60 seconds. The workflow SHALL NOT authenticate to any +container registry and SHALL NOT push any image. + +#### Scenario: Broken Dockerfile fails a PR + +- **GIVEN** a PR that changes `docker/Dockerfile` to reference a non-existent + base image +- **WHEN** the `validate-docker-build` job runs +- **THEN** the job fails at the `docker build` step +- **AND** PR status shows the failure + +#### Scenario: Missing binary fails a PR + +- **GIVEN** a PR that changes the daemon `.csproj` so `dotnet publish` no + longer produces `./publish/daemon/netclawd` +- **WHEN** the `validate-docker-build` job runs +- **THEN** `scripts/docker/build-image.sh` exits non-zero with a clear + error identifying the missing binary +- **AND** the `docker build` step is not attempted + +#### Scenario: Passing PR succeeds the health probe + +- **GIVEN** a PR with no Dockerfile or build-script regressions +- **WHEN** the `validate-docker-build` job runs +- **THEN** `scripts/docker/build-image.sh` produces a tagged image +- **AND** starting the image with a minimal identity fixture and stub + provider env vars causes `/api/health/ready` to return `"healthy"` within + 60 seconds +- **AND** the job succeeds without pushing to GHCR + +### Requirement: Behavioral eval suite runs against ephemeral container + +`evals/run-evals.sh` SHALL run each eval suite invocation against an +ephemeral `netclawd` container started from the published image. The +script SHALL NOT require, query, or modify the operator's running +development daemon. The container SHALL be named uniquely per run and +torn down on script exit (success, failure, or SIGINT). Eval state +(results DB, session logs, SQLite data) SHALL live inside a temporary +`$EVAL_HOME` directory that is removed on exit. + +#### Scenario: Script spawns and tears down its own container + +- **WHEN** `./evals/run-evals.sh` completes (pass or fail) +- **THEN** no `netclaw-eval-*` container remains running +- **AND** the temporary `$EVAL_HOME` directory is removed + +#### Scenario: Host dev daemon state is untouched + +- **GIVEN** the host's `~/.netclaw/netclaw.db` is snapshotted before the run +- **WHEN** a full eval suite completes +- **THEN** `~/.netclaw/netclaw.db` is byte-identical to the pre-run snapshot + +### Requirement: Host networking for Tailscale and loopback resolution + +`evals/run-evals.sh` SHALL start the eval container with `--network host` +by default. This inherits the host's DNS resolver so Tailscale MagicDNS +hostnames (e.g. `big-gpu.tail...ts.net`) resolve inside the container, and +it inherits the loopback namespace so `http://127.0.0.1:`-style LLM +endpoints are reachable without port mapping. The script SHALL fail loudly +if `$NETCLAW_EVAL_PORT` is already bound on the host, rather than silently +falling through to a different port. + +#### Scenario: Tailscale-only endpoint resolves inside container + +- **GIVEN** the operator's LLM endpoint is `http://big-gpu.tailnet.ts.net:1234/v1` +- **AND** the host has a working Tailscale connection with MagicDNS enabled +- **WHEN** the eval script starts the container and routes prompts through + the eval daemon +- **THEN** the daemon resolves the hostname and completes LLM calls + successfully + +#### Scenario: Port conflict fails fast + +- **GIVEN** the host already has a process bound to `$NETCLAW_EVAL_PORT` +- **WHEN** the eval script starts the container +- **THEN** `docker run` or the subsequent `/api/health/ready` probe fails +- **AND** the script exits with a non-zero status and a clear message +- **AND** no partially-started container is left behind + +### Requirement: Eval-target credentials are never silent + +`evals/run-evals.sh` SHALL require explicit eval-target credentials on +every invocation. If all three of `NETCLAW_EVAL_PROVIDER_TYPE`, +`NETCLAW_EVAL_PROVIDER_ENDPOINT`, and `NETCLAW_EVAL_MODEL_ID` are set in +the environment, the script SHALL use them non-interactively. Otherwise, +the script SHALL prompt the operator interactively on stdin for the +missing values before starting the container. The script SHALL NOT fall +back to a hard-coded provider, endpoint, or model under any circumstances. + +#### Scenario: All env vars set runs non-interactively + +- **GIVEN** `NETCLAW_EVAL_PROVIDER_TYPE`, `_ENDPOINT`, and `_MODEL_ID` are + exported in the caller's environment +- **WHEN** `./evals/run-evals.sh` starts +- **THEN** the script does not read from stdin +- **AND** the container starts with the provided values as env vars + +#### Scenario: Missing env vars trigger interactive prompt + +- **GIVEN** none of the three required env vars are set +- **AND** the script is attached to a terminal +- **WHEN** the script starts +- **THEN** it prompts for provider type, endpoint, and model id on stdin +- **AND** proceeds only after all three have been entered non-empty + +#### Scenario: Missing env vars in non-interactive context fail loudly + +- **GIVEN** none of the three required env vars are set +- **AND** stdin is not a terminal (e.g. the script runs under `ssh` with + `-T` or inside a pipeline) +- **WHEN** the script starts +- **THEN** the script prints an error naming the missing env vars +- **AND** exits with a non-zero status before invoking `docker run` + +### Requirement: Identity bind-mount is read-only + +`evals/run-evals.sh` SHALL bind-mount the operator's identity files +(`~/.netclaw/identity/`) into the container at `/root/.netclaw/identity` +with read-only semantics (`:ro`). The script SHALL copy the identity files +to `$EVAL_HOME/identity` before mounting to decouple the container from +any in-place host mutation during the run. + +#### Scenario: Eval run cannot modify host identity + +- **GIVEN** the host's `~/.netclaw/identity/SOUL.md` is snapshotted +- **WHEN** a full eval suite completes +- **THEN** `~/.netclaw/identity/SOUL.md` is byte-identical to the snapshot + +### Requirement: CLI endpoint override points at the eval daemon + +During an eval run, `evals/run-evals.sh` SHALL export +`NETCLAW_DAEMON_ENDPOINT=http://127.0.0.1:${NETCLAW_EVAL_PORT}` for every +`netclaw -p` invocation, so the CLI client connects to the containerized +eval daemon rather than any daemon the operator may have running for +their real work. The script SHALL NOT modify any client config file on +the host to achieve this. + +#### Scenario: Host CLI is unaffected after the run + +- **GIVEN** the host's `~/.netclaw/client/config.json` is snapshotted +- **WHEN** a full eval suite completes +- **THEN** `~/.netclaw/client/config.json` is byte-identical to the + snapshot diff --git a/openspec/specs/daemon-exposure/spec.md b/openspec/specs/daemon-exposure/spec.md new file mode 100644 index 000000000..39334011d --- /dev/null +++ b/openspec/specs/daemon-exposure/spec.md @@ -0,0 +1,182 @@ +# daemon-exposure Specification + +## Purpose + +Define the daemon's network exposure configuration, bind address management, +exposure mode declaration, startup prerequisite validation, and diagnostic +health checks for tunnel infrastructure. + +## Requirements + +### Requirement: Exposure mode declaration + +The system SHALL support an `ExposureMode` configuration property with the +following values: `local`, `tailscale-serve`, `tailscale-funnel`, +`cloudflare-tunnel`. The default value SHALL be `local`. + +#### Scenario: Default exposure mode + +- **GIVEN** no `Daemon.ExposureMode` is configured +- **WHEN** the daemon starts +- **THEN** the effective exposure mode is `local` + +#### Scenario: Explicit exposure mode + +- **GIVEN** `Daemon.ExposureMode` is set to `tailscale-serve` +- **WHEN** the daemon starts +- **THEN** the effective exposure mode is `tailscale-serve` + +#### Scenario: Invalid exposure mode rejected + +- **GIVEN** `Daemon.ExposureMode` is set to an unrecognized value +- **WHEN** configuration validation runs +- **THEN** validation fails with a descriptive error naming the invalid value + and listing valid options + +### Requirement: Configurable daemon bind address + +The system SHALL support `Daemon.Host` (string, default `"127.0.0.1"`) and +`Daemon.Port` (integer, default `5199`) configuration properties. The daemon +SHALL bind to the address constructed from these properties at startup. + +#### Scenario: Default bind address + +- **GIVEN** no `Daemon.Host` or `Daemon.Port` is configured +- **WHEN** the daemon starts +- **THEN** the daemon binds to `http://127.0.0.1:5199` + +#### Scenario: Custom bind address + +- **GIVEN** `Daemon.Host` is `"0.0.0.0"` and `Daemon.Port` is `5200` +- **WHEN** the daemon starts +- **THEN** the daemon binds to `http://0.0.0.0:5200` + +#### Scenario: Custom port only + +- **GIVEN** `Daemon.Port` is `5200` and `Daemon.Host` is not configured +- **WHEN** the daemon starts +- **THEN** the daemon binds to `http://127.0.0.1:5200` + +### Requirement: Startup prerequisite validation for tunnel modes + +The daemon SHALL validate that tunnel infrastructure prerequisites are met +before completing startup. If prerequisites are not met, the daemon SHALL +fail startup with a descriptive error. The daemon does NOT manage tunnel +processes — it only validates their presence. + +#### Scenario: Tailscale Serve mode with tailscaled running + +- **GIVEN** `Daemon.ExposureMode` is `tailscale-serve` +- **AND** the `tailscaled` process is running +- **WHEN** the daemon starts +- **THEN** startup succeeds + +#### Scenario: Tailscale Serve mode without tailscaled + +- **GIVEN** `Daemon.ExposureMode` is `tailscale-serve` +- **AND** the `tailscaled` process is not running +- **WHEN** the daemon starts +- **THEN** startup fails with error indicating `tailscaled` is not running + +#### Scenario: Tailscale Funnel mode without tailscaled + +- **GIVEN** `Daemon.ExposureMode` is `tailscale-funnel` +- **AND** the `tailscaled` process is not running +- **WHEN** the daemon starts +- **THEN** startup fails with error indicating `tailscaled` is not running + +#### Scenario: Cloudflare Tunnel mode with cloudflared running + +- **GIVEN** `Daemon.ExposureMode` is `cloudflare-tunnel` +- **AND** the `cloudflared` process is running +- **WHEN** the daemon starts +- **THEN** startup succeeds + +#### Scenario: Cloudflare Tunnel mode without cloudflared + +- **GIVEN** `Daemon.ExposureMode` is `cloudflare-tunnel` +- **AND** the `cloudflared` process is not running +- **WHEN** the daemon starts +- **THEN** startup fails with error indicating `cloudflared` is not running + +#### Scenario: Local mode requires no tunnel validation + +- **GIVEN** `Daemon.ExposureMode` is `local` +- **WHEN** the daemon starts +- **THEN** no tunnel prerequisite checks are performed + +### Requirement: Doctor checks for exposure health + +The `netclaw doctor` command SHALL include exposure mode health checks that +validate tunnel infrastructure status and flag unsafe configurations. + +#### Scenario: Non-loopback bind without exposure mode + +- **GIVEN** `Daemon.Host` is `"0.0.0.0"` or any non-loopback address +- **AND** `Daemon.ExposureMode` is `local` +- **WHEN** `netclaw doctor` runs +- **THEN** a warning is reported: non-loopback bind address without a declared + exposure mode may make the daemon host-network reachable without the + required authenticated-user gate + +#### Scenario: Tailscale mode with healthy tunnel + +- **GIVEN** `Daemon.ExposureMode` is `tailscale-serve` +- **AND** `tailscaled` is running and serve is configured +- **WHEN** `netclaw doctor` runs +- **THEN** the exposure check passes + +#### Scenario: Tailscale mode with missing tunnel + +- **GIVEN** `Daemon.ExposureMode` is `tailscale-serve` +- **AND** `tailscaled` is not running +- **WHEN** `netclaw doctor` runs +- **THEN** an error is reported: `tailscaled` is not running + +#### Scenario: Cloudflare mode with missing tunnel + +- **GIVEN** `Daemon.ExposureMode` is `cloudflare-tunnel` +- **AND** `cloudflared` is not running +- **WHEN** `netclaw doctor` runs +- **THEN** an error is reported: `cloudflared` is not running + +### Requirement: Exposure mode does not reload without restart + +Changing the exposure mode or daemon bind address SHALL NOT take effect through +hot-reload. These changes SHALL require a full daemon restart. + +#### Scenario: Exposure mode change ignored during hot-reload + +- **GIVEN** the daemon is running with `Daemon.ExposureMode` set to `local` +- **WHEN** the operator changes `Daemon.ExposureMode` to `tailscale-serve` in + the config file +- **AND** the config hot-reload triggers +- **THEN** the daemon continues operating with `local` mode +- **AND** the daemon logs a warning that exposure mode changes require restart + +### Requirement: Daemon config section in JSON schema + +The `netclaw-config.v1.schema.json` SHALL include a `Daemon` object with +`Host` (string, default `"127.0.0.1"`), `Port` (integer, default `5199`), +and `ExposureMode` (string enum: `local`, `tailscale-serve`, +`tailscale-funnel`, `cloudflare-tunnel`, default `"local"`). + +#### Scenario: Schema validates valid Daemon section + +- **GIVEN** a config file with `"Daemon": { "Host": "0.0.0.0", "Port": 5199, "ExposureMode": "tailscale-serve" }` +- **WHEN** schema validation runs +- **THEN** validation passes + +#### Scenario: Schema rejects invalid ExposureMode + +- **GIVEN** a config file with `"Daemon": { "ExposureMode": "nginx-proxy" }` +- **WHEN** schema validation runs +- **THEN** validation fails citing the invalid enum value + +#### Scenario: Missing Daemon section uses defaults + +- **GIVEN** a config file with no `Daemon` section +- **WHEN** schema validation runs +- **THEN** validation passes +- **AND** defaults resolve to `Host: "127.0.0.1"`, `Port: 5199`, + `ExposureMode: "local"` diff --git a/openspec/specs/device-pairing/spec.md b/openspec/specs/device-pairing/spec.md new file mode 100644 index 000000000..23cd155f5 --- /dev/null +++ b/openspec/specs/device-pairing/spec.md @@ -0,0 +1,165 @@ +# device-pairing Specification + +## Purpose + +Define the bearer token authentication scheme, pairing code exchange flow, +paired device registry, device management commands, and CLI token attachment +for self-hosted remote access without an external identity provider. + +## Requirements + +### Requirement: Bearer token authentication scheme + +The daemon SHALL register a bearer token authentication scheme that validates +device tokens on SignalR connections. The scheme SHALL read the token from the +`Authorization: Bearer ` header on the HTTP upgrade request. Valid +tokens SHALL produce Netclaw claims with `Operator` principal, `Verified` +transport, and the paired device ID as sender. + +#### Scenario: Valid bearer token accepted + +- **GIVEN** a remote connection provides a bearer token matching a paired device +- **WHEN** the bearer token scheme evaluates the connection +- **THEN** authentication succeeds with `PrincipalClassification = Operator`, + `TransportAuthenticity = Verified`, and `SenderId` = the device name + +#### Scenario: Invalid bearer token rejected + +- **GIVEN** a remote connection provides a bearer token that does not match any + paired device +- **WHEN** the bearer token scheme evaluates the connection +- **THEN** authentication fails + +#### Scenario: Missing bearer token defers to other schemes + +- **GIVEN** a connection provides no bearer token +- **WHEN** the bearer token scheme evaluates the connection +- **THEN** the scheme returns `NoResult` (defers to loopback or other schemes) + +### Requirement: Pairing code generation + +The daemon SHALL generate a short-lived pairing code when requested by a local +operator via `netclaw daemon pair`. The code SHALL be a human-readable format +(e.g., `ABCD-1234`), expire after 5 minutes, and be single-use. + +#### Scenario: Generate pairing code + +- **GIVEN** a local operator runs `netclaw daemon pair` +- **WHEN** the command executes +- **THEN** a pairing code is displayed with its expiration time +- **AND** the code is registered with the daemon for validation + +#### Scenario: Pairing code expires + +- **GIVEN** a pairing code was generated 5 minutes ago +- **WHEN** a remote client attempts to exchange the code +- **THEN** the exchange is rejected with an expiration error + +#### Scenario: Pairing code is single-use + +- **GIVEN** a pairing code has been successfully exchanged once +- **WHEN** another client attempts to exchange the same code +- **THEN** the exchange is rejected + +### Requirement: Pairing code exchange + +A remote CLI SHALL exchange a valid pairing code for a long-lived device token +via `netclaw pair `. The exchange SHALL occur over an unauthenticated +pairing endpoint that is separate from the main hub. The daemon SHALL prompt +the operator to name the device and store the token hash in the device +registry. + +#### Scenario: Successful pairing exchange + +- **GIVEN** a valid, unexpired pairing code exists +- **WHEN** a remote CLI runs `netclaw pair http://daemon:5199` and enters the + pairing code and a device name +- **THEN** the daemon validates the code +- **AND** generates a long-lived device token +- **AND** returns the token to the remote CLI +- **AND** stores the token hash and device name in the device registry + +#### Scenario: Remote CLI stores token + +- **GIVEN** a successful pairing exchange returned a device token +- **WHEN** the remote CLI receives the token +- **THEN** the token is stored in `~/.netclaw/config/secrets.json` under a + `DeviceToken` key +- **AND** the daemon endpoint is stored in config for future connections + +### Requirement: Paired device registry + +The daemon SHALL maintain a registry of paired devices at +`~/.netclaw/config/devices.json`. The registry SHALL store device name, token +hash (NOT the raw token), creation timestamp, and last-used timestamp. The +registry SHALL be readable by the operator via `netclaw daemon devices`. + +#### Scenario: List paired devices + +- **GIVEN** two devices are paired: `aaron-laptop` and `aaron-desktop` +- **WHEN** the operator runs `netclaw daemon devices` +- **THEN** the output lists both devices with their names, creation dates, and + last-used timestamps + +#### Scenario: Revoke a paired device + +- **GIVEN** a device `aaron-laptop` is paired +- **WHEN** the operator runs `netclaw daemon devices revoke aaron-laptop` +- **THEN** the device is removed from the registry +- **AND** the device's token is no longer accepted for authentication + +#### Scenario: Last-used timestamp updated on connection + +- **GIVEN** a paired device connects with a valid bearer token +- **WHEN** the connection is authenticated +- **THEN** the device's last-used timestamp is updated in the registry + +### Requirement: Non-local exposure requires paired device or auth scheme + +When the daemon's exposure mode is non-local, startup validation SHALL verify +that at least one paired device exists OR an alternative authentication scheme +(e.g., OIDC) is configured. If neither condition is met, startup SHALL fail. + +#### Scenario: Non-local mode with paired devices starts successfully + +- **GIVEN** exposure mode is `tailscale-serve` +- **AND** one or more paired devices exist +- **WHEN** the daemon starts +- **THEN** startup succeeds + +#### Scenario: Non-local mode with no auth fails startup + +- **GIVEN** exposure mode is `tailscale-serve` +- **AND** no paired devices exist +- **AND** no alternative auth scheme is configured +- **WHEN** the daemon starts +- **THEN** startup fails with error indicating no authentication is configured + for remote access + +### Requirement: CLI attaches bearer token for remote connections + +The CLI's `HubConnectionBuilder` SHALL read a device token from +`~/.netclaw/config/secrets.json` and attach it as a bearer token when +connecting to a non-loopback daemon endpoint. For loopback endpoints, no token +is attached. + +#### Scenario: Remote endpoint with token + +- **GIVEN** `Daemon:Endpoint` is `http://remote-host:5199` +- **AND** a device token exists in `secrets.json` +- **WHEN** the CLI connects to the daemon +- **THEN** the bearer token is attached to the SignalR connection + +#### Scenario: Loopback endpoint skips token + +- **GIVEN** `Daemon:Endpoint` is `http://127.0.0.1:5199` +- **WHEN** the CLI connects to the daemon +- **THEN** no bearer token is attached (loopback scheme handles auth) + +#### Scenario: Remote endpoint without token + +- **GIVEN** `Daemon:Endpoint` is `http://remote-host:5199` +- **AND** no device token exists in `secrets.json` +- **WHEN** the CLI attempts to connect +- **THEN** the connection fails with 401 +- **AND** the CLI displays a message suggesting `netclaw pair` diff --git a/openspec/specs/feature-selection-wizard/spec.md b/openspec/specs/feature-selection-wizard/spec.md new file mode 100644 index 000000000..fb76d35b4 --- /dev/null +++ b/openspec/specs/feature-selection-wizard/spec.md @@ -0,0 +1,110 @@ +## ADDED Requirements + +### Requirement: Feature selection wizard step + +The init wizard SHALL present a Feature Selection step after the Security +Posture step for non-Personal deployment postures. The step SHALL display +toggleable deployment-wide feature switches with audience-appropriate defaults. +These switches control runtime enablement, not audience exposure. Audience +exposure remains governed by explicit tool/server allowlists. + +#### Scenario: Feature selection shown for Public posture + +- **GIVEN** the operator selected Public deployment posture +- **WHEN** the Security Posture step completes +- **THEN** the next step is Feature Selection +- **AND** features default to: memory off, search off, skills off, scheduling + off, subagents off, webhooks off + +#### Scenario: Feature selection shown for Team posture + +- **GIVEN** the operator selected Team deployment posture +- **WHEN** the Security Posture step completes +- **THEN** the next step is Feature Selection +- **AND** features default to: memory on, search on, skills on, scheduling on, + subagents on, webhooks on + +#### Scenario: Feature selection skipped for Personal posture + +- **GIVEN** the operator selected Personal posture +- **WHEN** the Security Posture step completes +- **THEN** the Feature Selection step is skipped +- **AND** all features are enabled by default + +#### Scenario: Operator toggles features + +- **GIVEN** the Feature Selection step is displayed +- **WHEN** the operator presses Space on a feature row +- **THEN** the feature toggles between enabled and disabled +- **AND** pressing Enter advances to the next wizard step + +#### Scenario: Public search toggle does not implicitly allowlist Public search tools + +- **GIVEN** the operator selected Public deployment posture +- **AND** the operator enables Search in Feature Selection +- **WHEN** config is finalized +- **THEN** deployment-wide search runtime is enabled +- **BUT** `web_search` and `web_fetch` are still absent from Public sessions + unless the operator explicitly allowlists them for the Public audience + +### Requirement: Feature config Enabled flags + +The configuration schema SHALL include `Enabled` boolean properties for +Memory, Search, SkillSync, SubAgents, and Webhooks sections, plus a new top- +level `Scheduling` section whose only property is `Enabled`. The Feature +Selection wizard step SHALL write these flags to the config during +`ContributeConfig()`. + +#### Scenario: Disabled memory writes Enabled false + +- **GIVEN** the operator disabled memory in Feature Selection +- **WHEN** config is finalized +- **THEN** `Memory.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled search writes Enabled false + +- **GIVEN** the operator disabled search in Feature Selection +- **WHEN** config is finalized +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled scheduling writes top-level Scheduling.Enabled false + +- **GIVEN** the operator disabled scheduling in Feature Selection +- **WHEN** config is finalized +- **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` +- **AND** `Scheduling` contains no other properties in this change + +#### Scenario: Default Personal config has all features enabled + +- **GIVEN** the operator selected Personal posture (Feature Selection skipped) +- **WHEN** config is finalized +- **THEN** all `Enabled` flags default to `true` + +### Requirement: Feature flags respected at runtime + +Runtime subsystems SHALL check their respective `Enabled` config flag before +activating. When a feature is disabled via config, it SHALL be inactive +regardless of audience profile. When a feature is enabled at runtime, audience +profiles still control which audiences may discover or use it. + +#### Scenario: Memory disabled in config suppresses recall + +- **GIVEN** `Memory.Enabled` is `false` in config +- **WHEN** a Team-audience session starts a new turn +- **THEN** automatic recall returns an empty result +- **AND** memory tools are not offered to the LLM + +#### Scenario: Memory enabled in config allows recall + +- **GIVEN** `Memory.Enabled` is `true` in config +- **WHEN** a Personal-audience session starts a new turn +- **THEN** automatic recall executes normally + +#### Scenario: Search runtime enabled but Public audience not allowlisted + +- **GIVEN** `Search.Enabled` is `true` in config +- **AND** the Public audience profile does not explicitly allow `web_search` or + `web_fetch` +- **WHEN** a Public session starts +- **THEN** search runtime may exist for the deployment +- **BUT** `web_search` and `web_fetch` are not exposed to that session diff --git a/openspec/specs/hub-auth/spec.md b/openspec/specs/hub-auth/spec.md new file mode 100644 index 000000000..9091babf5 --- /dev/null +++ b/openspec/specs/hub-auth/spec.md @@ -0,0 +1,117 @@ +# hub-auth Specification + +## Purpose + +Define the authentication framework for the SignalR hub, including the loopback +scheme, claims-to-principal mapping, and connection identity propagation. + +## Requirements + +### Requirement: Hub requires authentication + +The SignalR hub SHALL reject unauthenticated connections. All hub methods SHALL +require a valid `ClaimsPrincipal` established by at least one registered +authentication scheme. + +#### Scenario: Unauthenticated remote connection rejected + +- **GIVEN** a connection originates from a non-loopback address +- **AND** no bearer token or other credential is provided +- **WHEN** the client attempts to connect to `/hub/session` +- **THEN** the connection is rejected with HTTP 401 + +#### Scenario: Authenticated connection accepted + +- **GIVEN** a connection provides valid credentials for any registered scheme +- **WHEN** the client connects to `/hub/session` +- **THEN** the connection is accepted +- **AND** the hub methods are accessible + +### Requirement: Loopback authentication scheme + +The daemon SHALL register a loopback authentication scheme that automatically +authenticates connections from `127.0.0.1` and `::1` as `LocalProcess` / +`Operator` without requiring any credentials. + +#### Scenario: Loopback connection auto-authenticated + +- **GIVEN** a connection originates from `127.0.0.1` or `::1` +- **WHEN** the client connects to `/hub/session` +- **THEN** the connection is authenticated with principal classification + `Operator` and transport authenticity `LocalProcess` + +#### Scenario: Non-loopback connection not auto-authenticated + +- **GIVEN** a connection originates from a non-loopback address +- **WHEN** the loopback scheme evaluates the connection +- **THEN** the scheme returns no result (defers to other schemes) + +### Requirement: Claims-to-principal mapping + +The daemon SHALL map ASP.NET Core `ClaimsPrincipal` claims to Netclaw's +`PrincipalClassification` and `TransportAuthenticity` types. The mapping +SHALL be centralized in a single service. Authentication schemes SHALL +produce Netclaw-specific claims that the mapper reads. + +#### Scenario: Loopback claims produce Operator principal + +- **GIVEN** the loopback scheme authenticated a connection +- **WHEN** claims are mapped to Netclaw types +- **THEN** `PrincipalClassification` is `Operator` +- **AND** `TransportAuthenticity` is `LocalProcess` + +#### Scenario: Bearer token claims produce identified principal + +- **GIVEN** a future bearer token scheme authenticated a connection with a + device identity claim +- **WHEN** claims are mapped to Netclaw types +- **THEN** `PrincipalClassification` is `Operator` +- **AND** `TransportAuthenticity` is `Verified` +- **AND** a device identifier is available + +#### Scenario: Unknown claims produce strict defaults + +- **GIVEN** an authenticated connection has no Netclaw-specific claims +- **WHEN** claims are mapped to Netclaw types +- **THEN** `PrincipalClassification` is `UntrustedExternal` +- **AND** `TransportAuthenticity` is `Unknown` + +### Requirement: Connection identity propagation + +Every `MessageSource` created for a SignalR session SHALL carry the +authenticated identity from the connection's `ClaimsPrincipal`. The identity +SHALL include `PrincipalClassification`, `TransportAuthenticity`, and an +optional device or principal identifier as `SenderId`. + +#### Scenario: Local session carries operator identity + +- **GIVEN** a loopback-authenticated connection creates a session +- **WHEN** the session's `MessageSource` is constructed +- **THEN** `Principal` is `Operator` +- **AND** `Provenance.TransportAuthenticity` is `LocalProcess` +- **AND** `SenderId` is `"local"` + +#### Scenario: Remote session carries device identity + +- **GIVEN** a bearer-token-authenticated connection creates a session with + device ID `"aaron-laptop"` +- **WHEN** the session's `MessageSource` is constructed +- **THEN** `Principal` is `Operator` +- **AND** `Provenance.TransportAuthenticity` is `Verified` +- **AND** `SenderId` is `"aaron-laptop"` + +### Requirement: Auth framework is scheme-agnostic + +The hub authorization, claims mapping, and identity propagation SHALL NOT +reference any specific authentication scheme. Adding a new scheme (bearer +token, OIDC/JWT) SHALL require only registering the scheme in DI and +producing the expected Netclaw claims — no changes to the hub, session +registry, or downstream policy code. + +#### Scenario: New auth scheme requires no hub changes + +- **GIVEN** a new authentication scheme is registered that produces standard + Netclaw claims +- **WHEN** a connection authenticates via the new scheme +- **THEN** the hub accepts the connection +- **AND** claims mapping and identity propagation work without modification diff --git a/openspec/specs/inbound-webhooks/spec.md b/openspec/specs/inbound-webhooks/spec.md new file mode 100644 index 000000000..3b7625f30 --- /dev/null +++ b/openspec/specs/inbound-webhooks/spec.md @@ -0,0 +1,309 @@ +# inbound-webhooks Specification + +## Purpose + +Define config-driven inbound webhook routes, verified delivery handling, +autonomous session launch, prompt overlay injection, operational receipt +alerts, and reminder-style human notification behavior. + +## Requirements + +### Requirement: Named webhook routes + +The daemon SHALL expose named inbound webhook routes from one JSON file per +route under `NetclawPaths/config/webhooks/`. Top-level `netclaw.json` SHALL only +control whether the inbound webhook feature is enabled. Each route SHALL be +reachable at a stable HTTP path derived from its route filename/name and SHALL +define the audience, prompt overlay, notify instructions, `DeliveryRequired`, +verification settings, and optional notification target used for accepted +deliveries. + +#### Scenario: Configured route resolves by name + +- **GIVEN** a route file named `github-issues.json` exists under + `config/webhooks` +- **WHEN** an HTTP POST arrives at the webhook ingress path for `github-issues` +- **THEN** the daemon resolves that route definition +- **AND** uses that route's audience, prompt overlay, verifier, and notify + settings for the delivery + +#### Scenario: Top-level config only enables the feature + +- **GIVEN** inbound webhooks are enabled in top-level `netclaw.json` +- **AND** route definitions exist only in `config/webhooks/*.json` +- **WHEN** the daemon starts or handles an inbound webhook request +- **THEN** route configuration is discovered from `config/webhooks` +- **AND** route definitions are not required to be embedded in `netclaw.json` + +#### Scenario: Unknown route is rejected + +- **WHEN** an HTTP POST arrives for an unconfigured webhook route name +- **THEN** the daemon returns `404 Not Found` +- **AND** no session is created + +### Requirement: Accepted delivery launches autonomous webhook session + +Each accepted webhook delivery SHALL create a fresh autonomous session with +`ChannelType.Webhook`. Session identity SHALL be unique per accepted delivery so +retries or later notifications do not reuse an unrelated invocation session. + +#### Scenario: Accepted delivery creates unique session + +- **GIVEN** a verified webhook delivery for route `github-issues` +- **WHEN** the daemon accepts the delivery +- **THEN** a new webhook session is created for that delivery +- **AND** the session uses the route's configured audience as its source + audience + +#### Scenario: Separate deliveries create separate sessions + +- **GIVEN** two distinct accepted deliveries for the same route +- **WHEN** the daemon launches work for both +- **THEN** two separate webhook sessions are created + +### Requirement: Route prompt overlay is additive context + +The route prompt SHALL be injected as additive session context. It SHALL NOT +replace the base system prompt assembled from identity files. The normalized +delivery payload SHALL be provided to the session as the first delivery-specific +input. + +#### Scenario: Route prompt supplements base system prompt + +- **GIVEN** the daemon has a normal identity prompt configured +- **AND** route `github-issues` has a webhook prompt overlay +- **WHEN** an accepted delivery launches a webhook session +- **THEN** the session sees both the base system prompt and the route overlay +- **AND** the route overlay does not replace the base prompt + +#### Scenario: Normalized payload becomes delivery input + +- **GIVEN** an accepted webhook delivery with JSON payload content +- **WHEN** the webhook session starts +- **THEN** the normalized payload is provided as delivery-specific input to the + session + +### Requirement: Reminder-style delivery requirement + +Webhook routes SHALL reuse reminder-style notification semantics. +`DeliveryRequired=false` means human-facing notification is optional. +`DeliveryRequired=true` means notification delivery is required when +notification instructions are present. If delivery is required and no +notification is produced for the configured target, execution SHALL be treated +as failed. + +#### Scenario: DeliveryRequired=false may skip notification + +- **GIVEN** a webhook route has `DeliveryRequired = false` +- **WHEN** the agent decides the delivery requires no human-facing update +- **THEN** the webhook execution completes successfully without notification + +#### Scenario: DeliveryRequired=true fails without notification + +- **GIVEN** a webhook route has `DeliveryRequired = true` +- **AND** notification instructions are present +- **WHEN** the webhook execution completes without producing a notification to + the configured target +- **THEN** the execution is marked failed + +### Requirement: Human-facing notification target opens channel-native session + +When a webhook execution decides to notify an interactive channel, the +notification target SHALL use the channel adapter's normal session model. For +Slack, this means opening a Slack-native thread/session rather than rebinding +the original webhook session onto the Slack thread. + +#### Scenario: Slack notification opens Slack-native thread + +- **GIVEN** a webhook route has a Slack notification target +- **WHEN** the webhook execution produces a Slack notification +- **THEN** the system posts to Slack using the proactive-thread path +- **AND** the resulting interactive thread uses a Slack-native session +- **AND** the original webhook session remains separate + +### Requirement: Operational receipt alert per accepted delivery + +Every accepted webhook delivery SHALL emit a deterministic operational receipt +alert independently of any human-facing notification policy. The alert SHALL +identify the route, delivery, and event that fired. + +#### Scenario: Accepted delivery emits receipt alert + +- **GIVEN** a verified webhook delivery is accepted for dispatch +- **WHEN** the daemon finishes ingress validation +- **THEN** an operational receipt alert is emitted +- **AND** the alert includes the route name and delivery identifier + +#### Scenario: Human notification skipped still emits receipt alert + +- **GIVEN** a webhook route uses `DeliveryRequired = false` +- **AND** the agent chooses not to notify a human-facing channel +- **WHEN** the delivery is accepted and processed +- **THEN** the operational receipt alert is still emitted + +### Requirement: Route files are hot-reloaded and fail closed + +The daemon SHALL reload route definitions from `config/webhooks` without daemon +restart. Request-time mtime-gated reload is acceptable for MVP. If a route file +is missing, malformed, or invalid, that route SHALL not be loaded, and a +previously loaded version SHALL be removed immediately instead of serving stale +config. + +#### Scenario: Route file edit is picked up without restart + +- **GIVEN** route `github-issues` is loaded from `github-issues.json` +- **AND** the route file changes on disk +- **WHEN** the next request arrives for `github-issues` +- **THEN** the daemon reloads the route definition before processing the request + +#### Scenario: Invalid edit removes previously loaded route + +- **GIVEN** route `github-issues` was previously valid and loaded +- **WHEN** `github-issues.json` is edited into an invalid state +- **THEN** the daemon stops serving route `github-issues` +- **AND** no stale prior route definition is used for later requests + +### Requirement: Verification kinds are generic and minimal + +Route verification SHALL be modeled as generic verification kinds rather than +one first-class verifier type per provider. MVP SHALL support a minimal set that +includes generic HMAC verification and shared-header secret verification. + +#### Scenario: Generic HMAC verification is configured + +- **GIVEN** a route file configures HMAC verification with header metadata and a + shared secret +- **WHEN** a request arrives with a valid matching signature +- **THEN** the route verification succeeds without requiring a provider-specific + verifier type + +#### Scenario: Shared-header secret verification is configured + +- **GIVEN** a route file configures shared-header secret verification +- **WHEN** a request arrives with the expected secret header value +- **THEN** the route verification succeeds without requiring a provider-specific + verifier type + +### Requirement: Route files are secret-bearing config + +Route files MAY store inline verification secrets. The system SHALL treat +`config/webhooks` as secret-bearing configuration and SHALL NOT assume generic +file tools have unrestricted access to that directory. + +#### Scenario: Route file contains inline verification secret + +- **GIVEN** a route file stores an inline verification secret for HMAC or shared + header validation +- **WHEN** tool access policy is evaluated for generic file tools +- **THEN** `config/webhooks` is treated as secret-bearing config +- **AND** unrestricted generic file access is not implied + +### Requirement: Route file load failures emit operational alerts + +Route file load, reload, or unload failures SHALL emit operational alerts via +the existing operational notification sink so route failures are never silent. + +#### Scenario: Invalid route reload emits operational alert + +- **GIVEN** a previously valid route file becomes invalid on edit +- **WHEN** the daemon attempts to reload that route +- **THEN** an operational alert is emitted identifying the route and reload + failure +- **AND** the route remains unavailable until the file is valid again + +### Requirement: Webhook rejections emit structured logs and counters + +Every webhook ingress outcome — accepted, rejected, filtered, or rate-limited — +SHALL increment a durable in-process counter and emit a structured daemon log +line with at minimum the route name, outcome reason, client remote IP, and +delivery identifier (when available). Rejection paths SHALL NOT emit outbound +operational notification alerts, to avoid spamming operator channels on +adversarial or misconfigured traffic. + +Counters SHALL cover: `accepted`, `route_not_found`, `verification_failed`, +`body_too_large`, `invalid_json`, `rate_limited`, `event_filtered`, and +`duplicate_delivery`. + +#### Scenario: Route-not-found emits log and counter + +- **GIVEN** no route file exists for `unknown-route` +- **WHEN** an HTTP POST arrives at the webhook ingress path for `unknown-route` +- **THEN** the daemon returns `404 Not Found` +- **AND** the `route_not_found` counter is incremented +- **AND** a structured warning log line is emitted with `route=unknown-route`, + `reason=route_not_found`, and the client remote IP +- **AND** no outbound operational notification alert is emitted for the rejection + +#### Scenario: Verification failure emits log and counter + +- **GIVEN** a route with HMAC verification is configured +- **WHEN** a request arrives with an invalid signature +- **THEN** the daemon returns `401 Unauthorized` +- **AND** the `verification_failed` counter is incremented +- **AND** a structured warning log line is emitted including the route name, + the delivery identifier (when the provider supplied one), and + `reason=verification_failed` + +#### Scenario: Duplicate delivery increments counter + +- **GIVEN** a webhook delivery has already been processed within the + deduplication window +- **WHEN** the same delivery identifier arrives again for the same route +- **THEN** the daemon returns `202 Accepted` with `reason=duplicate_delivery` +- **AND** the `duplicate_delivery` counter is incremented + +### Requirement: Stats surface exposes webhook route counts and delivery counters + +The `netclaw stats` CLI surface and `/api/stats` daemon endpoint SHALL include +webhook metrics covering both route registry counts and delivery counters, so +operators can see at a glance how many routes are configured and how ingress +traffic is being handled. + +Route counts SHALL cover `total`, `enabled`, `disabled`, and `invalid` routes. +Delivery counters SHALL cover the same set defined by the rejection +observability requirement above. + +#### Scenario: Stats response includes webhook section + +- **GIVEN** webhook routes are configured under `config/webhooks` +- **WHEN** an operator requests `netclaw stats` +- **THEN** the response includes a dedicated webhooks section +- **AND** the section reports route counts (total, enabled, disabled, invalid) +- **AND** the section reports delivery counters (accepted, filtered, duplicate, + and each rejection reason) + +#### Scenario: Invalid route files counted as invalid + +- **GIVEN** three route files exist under `config/webhooks` and one fails to + parse or validate +- **WHEN** an operator requests `netclaw stats` +- **THEN** the invalid route counter reflects the single unparseable file +- **AND** the enabled counter reflects only the successfully loaded routes + +### Requirement: Notification tool invocation surfaces as session output + +The session actor SHALL emit a tool-result session output for every tool +invocation whose results are fed back into the conversation. Subscribers that +track notification-tool completion (such as webhook and reminder execution +actors) SHALL rely on those session outputs to determine whether the agent +fulfilled a required notification, rather than waiting on information that is +never emitted in production. + +#### Scenario: Required notification succeeds when agent invokes notification tool + +- **GIVEN** a webhook route configures `DeliveryRequired = true` with a Slack + notification target +- **AND** notification instructions are present +- **WHEN** the agent successfully invokes the Slack notification tool during + the webhook session +- **THEN** the webhook execution completes successfully +- **AND** the daemon does not log a false-positive warning that no notification + tool was invoked + +#### Scenario: Required notification still fails when no notification tool invoked + +- **GIVEN** a webhook route configures `DeliveryRequired = true` +- **AND** notification instructions are present +- **WHEN** the agent completes its turn without invoking any notification tool +- **THEN** the webhook execution is marked failed with the "no notification + tool was invoked" reason diff --git a/openspec/specs/netclaw-acl/spec.md b/openspec/specs/netclaw-acl/spec.md index 0c3d475c7..558ffbf90 100644 --- a/openspec/specs/netclaw-acl/spec.md +++ b/openspec/specs/netclaw-acl/spec.md @@ -1,67 +1,4 @@ -# netclaw-acl Specification - -## Purpose - -Define ACL evaluation semantics for channel, sender, mention policy, and tool -grants. - -## Requirements - -### Requirement: Channel and sender allow checks - -The system SHALL evaluate channel and sender policy before executable turn -dispatch. For threaded channel adapters, only authorized senders SHALL create -live executable inbound turns. - -Unauthorized live messages in a thread SHALL NOT be forwarded as ordinary -`SendUserMessage` turns. They remain source-thread material that MAY later be -adopted as quoted context by a subsequent authorized turn, but they do not -independently pass the executable-turn ACL gate. - -When a later authorized turn adopts pending thread material, any -`authority-at-inclusion` value recorded for those adopted messages SHALL be -derived from the same live turn-creation authorization basis that the adapter -applies to threaded inbound messages at adoption time. - -#### Scenario: Sender allowed, channel allowed - -- **GIVEN** sender and channel are explicitly allowed -- **WHEN** a threaded message arrives -- **THEN** ACL evaluation returns allow for executable turn creation - -#### Scenario: Sender disallowed - -- **WHEN** sender is not allowed by policy -- **THEN** ACL evaluation returns deny -- **AND** no executable turn is dispatched for that live message - -#### Scenario: Unauthorized thread message remains non-executable - -- **GIVEN** `AllowedUserIds` contains `"U111"` -- **WHEN** user `U999` sends a message in the same thread -- **THEN** the live message is denied for executable turn creation -- **AND** the message does not become a `SendUserMessage` -- **AND** the message may only appear later inside adopted quoted context if an - authorized user speaks - -### Requirement: Mention and ambient mode behavior - -The system SHALL respect `require_mention` per channel, and mention or ambient -eligibility SHALL NOT override sender authorization for executable turns. - -#### Scenario: Mention-required channel without mention - -- **GIVEN** channel has `require_mention=true` -- **WHEN** message has no mention -- **THEN** no model turn is dispatched - -#### Scenario: Unauthorized ambient candidate still denied - -- **GIVEN** channel has `require_mention=false` -- **AND** sender is not authorized by policy -- **WHEN** the message arrives -- **THEN** the message does not create an executable turn even though the - channel is ambient-enabled +## MODIFIED Requirements ### Requirement: Tool and data grants @@ -80,8 +17,9 @@ Each `ToolAudienceProfile` SHALL support an optional `ApprovalPolicy` of type `ToolApprovalConfig`. The `ApprovalPolicy` SHALL define a `DefaultMode` (Auto, Approval, Deny) and per-tool overrides via `ToolOverrides`. The approval check SHALL execute after the tool access grant check passes. Tools in Approval mode -SHALL consult the approval cache (session-scoped and persistent) before -execution. Tools in Deny mode SHALL be blocked without an approval prompt. +SHALL surface approval context for the executor, and the executor SHALL consult +`IToolApprovalService` before execution. Tools in Deny mode SHALL be blocked +without an approval prompt. #### Scenario: Missing grant blocks tool call @@ -119,7 +57,7 @@ execution. Tools in Deny mode SHALL be blocked without an approval prompt. - **GIVEN** the session has a grant for `shell_execute` - **AND** the Personal `ApprovalPolicy` sets `shell_execute` to Approval mode -- **AND** the command pattern `git push` is not in the approval cache +- **AND** the command pattern `git push` is not already approved in `IToolApprovalService` - **WHEN** the agent invokes `shell_execute` with `git push origin main` - **THEN** the grant check passes - **AND** the approval check returns `RequiresApproval` @@ -127,7 +65,7 @@ execution. Tools in Deny mode SHALL be blocked without an approval prompt. #### Scenario: Tool granted with approval already cached - **GIVEN** the session has a grant for `shell_execute` -- **AND** `git push` is in the session or persistent approval cache +- **AND** `git push` is already approved through `IToolApprovalService` - **WHEN** the agent invokes `shell_execute` with `git push origin main` - **THEN** both the grant check and approval check pass - **AND** the tool executes immediately @@ -137,86 +75,3 @@ execution. Tools in Deny mode SHALL be blocked without an approval prompt. - **GIVEN** ACL does not grant `config_write` for the current sender - **WHEN** the agent attempts to write configuration files through conversation - **THEN** the write is denied with a policy reason code - -### Requirement: Self-configuration prohibition - -ACL and security policy files MUST NOT be modifiable by the agent through -conversation. These files SHALL only be modified through the CLI or direct file -edit by the operator. This prohibition SHALL be enforced regardless of any -grants in the ACL policy. - -#### Scenario: Agent cannot modify ACL through conversation - -- **WHEN** an agent session attempts to modify ACL policy files -- **THEN** the modification is denied regardless of active grants -- **AND** the denial reason indicates that ACL files require CLI or direct edit - -#### Scenario: Agent cannot modify security policy through conversation - -- **WHEN** an agent session attempts to modify gateway security policy files -- **THEN** the modification is denied regardless of active grants - -### Requirement: Scheduled task tool grants - -Each scheduled task definition SHALL specify the required tool grants for its -execution. At execution time, the system SHALL verify that all required tool -grants are still valid before running the task. - -#### Scenario: Scheduled task with valid grants executes - -- **GIVEN** a scheduled task requires `web_search` and `mcp:memorizer` -- **AND** both grants are present in the current ACL policy -- **WHEN** the scheduled task fires -- **THEN** the task executes with the granted tools available - -#### Scenario: Scheduled task with revoked grant is blocked - -- **GIVEN** a scheduled task requires `web_search` -- **AND** the `web_search` grant has been removed from ACL policy since the task - was created -- **WHEN** the scheduled task fires -- **THEN** execution is denied with a policy reason code -- **AND** the task failure is recorded with the missing grant details - -### Requirement: Reminder audience authorization - -The system SHALL authorize reminder minting against the creator's current -source audience / authority before a reminder definition is persisted. A -requested reminder audience SHALL be accepted only when it is equal to or -narrower than the creator's current source audience. Lowering audience is -always allowed. Raising audience above the creator's current authority SHALL be -denied. For conversational and tool-created reminders, omitted `audience` -SHALL resolve to the creating channel/session audience before persistence. - -#### Scenario: Equal audience reminder allowed - -- **GIVEN** the current session source audience is `Team` -- **WHEN** the creator saves a reminder with `audience: Team` -- **THEN** ACL reminder minting authorization allows the write - -#### Scenario: Lower audience reminder allowed - -- **GIVEN** the current session source audience is `Personal` -- **WHEN** the creator saves a reminder with `audience: Public` -- **THEN** ACL reminder minting authorization allows the write - -#### Scenario: Higher audience reminder denied - -- **GIVEN** the current session source audience is `Public` -- **WHEN** the creator saves a reminder with `audience: Team` -- **THEN** ACL reminder minting authorization denies the write -- **AND** the denial reason states that the requested audience exceeds the creator's authority - -#### Scenario: Omitted conversational audience resolves from source - -- **GIVEN** a reminder is being created from a Slack session with source audience `Team` -- **WHEN** the request omits `audience` -- **THEN** the effective reminder audience is resolved to `Team` before persistence - -#### Scenario: Import path validates serialized audience - -- **GIVEN** an authenticated import request carries a serialized reminder definition with `audience: Personal` -- **AND** the import caller's source audience is `Team` -- **WHEN** the server validates the reminder definition -- **THEN** the import is denied before persistence -- **AND** no over-privileged reminder is stored diff --git a/openspec/specs/netclaw-agent-memory/spec.md b/openspec/specs/netclaw-agent-memory/spec.md index 5886a9cec..dd2d073b2 100644 --- a/openspec/specs/netclaw-agent-memory/spec.md +++ b/openspec/specs/netclaw-agent-memory/spec.md @@ -1,335 +1,4 @@ -# netclaw-agent-memory Specification - -Research: `docs/research/agent-patterns.md`, -`docs/research/dynamic-context-discovery.md` (§5 — deferred memory retrieval -decisions: keyword vs. vector search, embedding strategy, injection budgets) - -## Purpose - -Define agent personality (identity files), SQLite-first cross-session memory, -self-configuration through conversation, checkpoint-driven memory curation, -automatic pre-turn recall, and the standard configuration directory structure. -This capability makes Netclaw a persistent, context-aware agent rather than a -stateless chat endpoint. - -## Requirements - -### Requirement: Layered system prompt assembly - -The system SHALL assemble session context from ordered layers: `SOUL.md`, -`AGENTS.md`, `TOOLING.md`, dynamic context layers (tool index, skill index, -memory index), and session-specific context. Later layers SHALL augment earlier -layers. Identity files SHALL be loaded at session start and cached for the -session lifetime. Missing files SHALL be omitted without error. - -#### Scenario: Full layer assembly on session start - -- **GIVEN** identity files exist at `~/.netclaw/identity/SOUL.md`, - `~/.netclaw/identity/AGENTS.md`, and `~/.netclaw/identity/TOOLING.md` -- **WHEN** a new session starts -- **THEN** the system prompt includes content from all three identity files in - layer order (soul, agents, tooling) -- **AND** dynamic context layers and session-specific context are appended - -#### Scenario: Missing identity file does not prevent session start - -- **GIVEN** one or more identity files do not exist on disk -- **WHEN** a new session starts -- **THEN** the system assembles the prompt from available layers -- **AND** the missing layer is omitted without error - -### Requirement: Personality bootstrap via onboarding wizard - -The system SHALL bootstrap agent personality through the `netclaw init` -onboarding wizard. The wizard SHALL collect owner identity, write initial -`SOUL.md`, and configure the standard identity directory. The agent MAY -refine personality through conversation using `file_write` on identity files, -guided by the `netclaw-identity` skill. - -### Requirement: Self-configuration through conversation - -The system SHALL allow the agent to modify identity files (`SOUL.md`, -`AGENTS.md`, `TOOLING.md`) and skill files (`~/.netclaw/skills/*.md`) through -conversation using `file_read` and `file_write`. The `netclaw-identity` -built-in skill SHALL provide triage guidance for what information goes where. -The agent SHALL NOT have tools that directly modify `netclaw.json`, -`secrets.json`, ACL, or security policy. - -#### Scenario: Agent updates identity file - -- **GIVEN** the user asks the agent to adjust its personality -- **WHEN** the agent proposes and the user confirms the change -- **THEN** the agent writes the updated file using `file_write` -- **AND** reports that the change was saved - -#### Scenario: Agent attempts to modify ACL - -- **GIVEN** the user asks the agent to update ACL rules through conversation -- **WHEN** the agent evaluates the request -- **THEN** the agent refuses the modification -- **AND** explains that ACL changes require CLI or direct file edit by the - operator - -### Requirement: Pre-compaction memory flush - -The system SHALL replace the current single-step pre-compaction memory flush -with checkpoint-driven background memory curation. The session SHALL emit -durable memory checkpoints on eligible events including turn completion, -explicit memory requests, compaction boundaries, verified tool findings, and -accepted subagent findings. Compaction-related checkpoints SHALL be high -priority, but the user-facing turn SHALL wait only for durable checkpoint -enqueue acknowledgment, not for curator completion. - -#### Scenario: Compaction boundary creates a high-priority checkpoint - -- **GIVEN** a session is approaching or crossing the compaction threshold -- **WHEN** the session prepares to compact history -- **THEN** the system enqueues a high-priority memory checkpoint containing the - relevant summary inputs -- **AND** compaction continues after checkpoint enqueue succeeds - -#### Scenario: Checkpoint curation retries after failure - -- **GIVEN** a checkpoint was enqueued successfully -- **WHEN** background curation fails or times out -- **THEN** the checkpoint remains pending with retry metadata -- **AND** durable memory is not partially committed - -### Requirement: Standard configuration directory - -The system SHALL use `~/.netclaw/` as the standard configuration directory with -`memory/` as the durable memory home. The memory subsystem SHALL store its -SQLite database, schema metadata, and health/queue state under -`~/.netclaw/memory/`. The redesigned MVP SHALL NOT require any legacy memory -directory or import step in order to start cleanly. - -#### Scenario: Memory directory and database created on startup - -- **GIVEN** `~/.netclaw/memory/` does not exist -- **WHEN** the Netclaw process starts with the redesigned memory subsystem - enabled -- **THEN** the system creates the directory and initializes the SQLite database - schema -- **AND** the daemon reports memory status as healthy when initialization - succeeds - -#### Scenario: Greenfield startup requires no legacy memory store - -- **GIVEN** `~/.netclaw/memory/` is empty and `~/.netclaw/memories/` does not - exist -- **WHEN** the redesigned memory subsystem starts for the first time -- **THEN** the system initializes successfully without any import step -- **AND** uses the SQLite memory store as the only required durable memory - substrate - -### Requirement: Pluggable memory backend with 4-tool surface - -The system SHALL use a local SQLite-backed structured memory substrate as -Netclaw's default and normative durable memory implementation. The frontline -model SHALL continue to see the explicit tools `find_memories`, -`get_memories`, `store_memory`, and `update_memory`, but those tools SHALL -operate over the SQLite memory graph and shared policy pipeline rather than -selecting between file-backed and Memorizer-backed primary providers. Legacy -provider modes SHALL NOT be required for MVP delivery. - -#### Scenario: SQLite memory is the active default substrate - -- **GIVEN** Netclaw starts with the redesigned memory system -- **WHEN** the daemon initializes the memory subsystem -- **THEN** the daemon uses the local SQLite memory database as the primary - durable memory store -- **AND** explicit memory tools route to that store - -#### Scenario: MVP does not depend on legacy provider compatibility - -- **GIVEN** the redesigned memory subsystem is being delivered for greenfield - MVP use -- **WHEN** implementation scope is evaluated -- **THEN** SQLite-backed memory and the explicit tool surface are sufficient for - completion -- **AND** legacy provider-mode bridging may be omitted or deferred to a future - change - -### Requirement: Two-phase memory retrieval - -Memory retrieval SHALL run in two modes: automatic pre-turn recall and explicit -two-phase retrieval. Automatic recall SHALL happen before each user-facing -model turn and SHALL inject a bounded recall bundle derived from the structured -memory graph. Explicit retrieval SHALL continue to use `find_memories` for -lightweight search and `get_memories` for full hydration when manual follow-up -is needed. Automatic recall is the primary retrieval path; explicit retrieval -is a deliberate manual-control path. - -#### Scenario: Automatic recall runs before a user-facing turn - -- **GIVEN** a user sends a new message into an existing or new session -- **WHEN** the session prepares the next model call -- **THEN** the system runs a policy-aware automatic recall query against durable - memory -- **AND** injects a bounded recall bundle before the model sees the turn - -#### Scenario: Explicit two-phase retrieval remains available - -- **GIVEN** the automatic recall bundle was insufficient or the user explicitly - asks what Netclaw remembers -- **WHEN** the frontline model calls `find_memories` -- **THEN** it receives lightweight results suitable for selection -- **AND** can call `get_memories` to fetch full memory bodies only for the - selected items - -#### Scenario: Routine turn relies on automatic recall first - -- **GIVEN** a normal user-facing turn begins -- **WHEN** the automatic recall bundle already provides the relevant durable - context -- **THEN** the frontline model does not need to call explicit retrieval tools by - default -- **AND** proceeds using the system-managed recall bundle - -### Requirement: Memory context layer per backend - -The memory context layer SHALL explain that durable recall is automatic by -default and that explicit memory tools are reserved for deliberate manual -search, save, and correction workflows. The layer SHALL surface degraded memory -status when automatic recall or durable persistence is unavailable. It SHALL no -longer teach the model that backend selection is part of normal memory usage, -and it SHALL explicitly tell the frontline model not to call write tools -reflexively on every turn. - -#### Scenario: Context layer teaches automatic recall first - -- **GIVEN** the redesigned memory subsystem is healthy -- **WHEN** a session prompt is assembled -- **THEN** the memory context layer explains that Netclaw automatically recalls - durable memory before each turn -- **AND** reserves explicit memory tools for deliberate memory operations - -#### Scenario: Context layer distinguishes store and update usage - -- **GIVEN** the redesigned memory subsystem is healthy -- **WHEN** memory guidance is injected into the session prompt -- **THEN** the guidance says `store_memory` is for deliberate save/remember - actions -- **AND** the guidance says `update_memory` is for correction, supersede, - tombstone, or metadata changes to existing memory - -#### Scenario: Context layer reports degraded memory state - -- **GIVEN** the memory database is unavailable or recall has been disabled due - to an operational fault -- **WHEN** a session prompt is assembled -- **THEN** the memory context layer reports degraded memory status -- **AND** does not claim that durable recall is functioning normally - -### Requirement: Hierarchical anchor graph memory model - -The system SHALL model durable memory around anchors/entities with optional -parent-child hierarchy and typed graph edges. Anchors SHALL support containment -(`project` -> `repo` -> `service`) and non-hierarchical relationships -(`related_to`, `depends_on`, `owned_by`) so recall can expand around the -relevant entity without flattening all memory into note blobs. - -#### Scenario: Recall traverses anchor hierarchy - -- **GIVEN** a project anchor contains repository and service child anchors -- **WHEN** a user asks about the project at the parent level -- **THEN** the recall pipeline MAY retrieve child-scoped memory through the - hierarchy -- **AND** only items allowed by policy are injected into the recall bundle - -### Requirement: Durable memory policy envelope - -Every durable anchor, document, record, and edge SHALL carry policy metadata -including `audience`, `sensitivity`, `recallMode`, `confidence`, `freshness`, -and `updateSemantics`. The write path SHALL assign or reject these values -before persistence, and the recall path SHALL filter by them before prompt -injection. - -#### Scenario: Sensitive memory is blocked from auto recall - -- **GIVEN** a stored memory item is marked `audience=personal`, - `sensitivity=secret`, and `recallMode=manual` -- **WHEN** a session whose audience does not include `personal` runs automatic - pre-turn recall -- **THEN** the item is excluded from the automatic recall bundle -- **AND** it remains available only to explicit authorized workflows if policy - allows - -### Requirement: Documents versus records semantics - -The system SHALL distinguish mutable `documents` from immutable `records`. -Documents SHALL represent living, mergeable knowledge that can be updated in -place with version history. Records SHALL represent time-bound observations that -are immutable once written and can only be superseded, expired, or tombstoned -by subsequent operations. - -#### Scenario: Preference update modifies a document - -- **GIVEN** an operator preference is stored as a document on a `person` anchor -- **WHEN** the operator corrects that preference later -- **THEN** the system updates the document according to its merge semantics -- **AND** preserves version lineage for auditability - -#### Scenario: Historical event becomes a superseded record - -- **GIVEN** a host IP change is stored as a record on a `host` anchor -- **WHEN** a newer verified IP change is persisted -- **THEN** the new fact is stored as a new record -- **AND** the older record is marked as superseded rather than overwritten - -### Requirement: Rules-first candidate extraction - -The system SHALL run deterministic rules before any curator LLM call when -converting checkpoints into durable memory. These rules SHALL reject ephemeral -chatter, duplicates, policy-violating content, and low-confidence candidates -before invoking the curator. - -#### Scenario: Trivial chatter is filtered before curation - -- **GIVEN** a checkpoint contains both stable project facts and casual - acknowledgments -- **WHEN** rules-first extraction runs -- **THEN** the stable facts survive as candidates -- **AND** the casual acknowledgments are dropped without calling the curator for - them - -### Requirement: Automatic pre-turn recall - -The system SHALL execute automatic recall before each user-facing model turn -using the latest user message, recent session context, active anchors, and -policy scope. Automatic recall SHALL be bounded by a latency budget and SHALL -degrade safely when the memory substrate is unavailable. - -#### Scenario: Recall completes within budget - -- **GIVEN** the memory substrate is healthy -- **WHEN** a new turn begins -- **THEN** the session retrieves and injects a bounded recall bundle before the - model call -- **AND** the recall operation completes within the configured time budget or - degrades safely - -#### Scenario: Recall failure degrades without blocking the turn - -- **GIVEN** the memory database is temporarily unavailable -- **WHEN** the session starts automatic recall for a turn -- **THEN** the user-facing turn continues without durable recall injection -- **AND** the session records degraded memory status for diagnostics - -### Requirement: Main session owns durable memory persistence - -The main user-facing session SHALL be the default owner of durable memory -writes. Subagents and other helper workflows SHALL return findings to the -owning session, and the owning session SHALL decide whether those findings -become checkpoints and durable writes. - -#### Scenario: Subagent findings flow through the parent session - -- **GIVEN** a subagent returns structured findings from research work -- **WHEN** the parent session accepts those findings -- **THEN** the parent session turns them into a checkpoint for durable memory - review -- **AND** the subagent does not write durable memory directly +## ADDED Requirements ### Requirement: Adopted thread context is not direct durable-memory authority @@ -352,78 +21,3 @@ correct, or otherwise elevate that information. - **WHEN** memory policy otherwise permits the write - **THEN** the durable memory path may proceed under the current authorized message's authority rather than the adopted speaker's authority - -### Requirement: Explicit memory control paths - -The system SHALL treat `store_memory` and `update_memory` as deliberate -manual-control paths layered on top of automatic recall and background curation. -The frontline agent SHALL invoke `store_memory` only for explicit -remember/save requests, deliberate high-value pinning, or operator-directed -structured note capture. The frontline agent SHALL invoke `update_memory` only -for correction, supersede, tombstone, or metadata changes to an existing -durable memory item. - -#### Scenario: Frontline agent uses store_memory for an explicit save request - -- **GIVEN** the user explicitly asks Netclaw to remember a fact or preference -- **WHEN** the frontline agent chooses how to persist that information -- **THEN** it uses `store_memory` as the deliberate explicit write path -- **AND** the request still flows through checkpoint and policy handling rather - than direct uncontrolled persistence - -#### Scenario: Frontline agent uses update_memory for correction - -- **GIVEN** an existing durable memory item must be corrected or superseded -- **WHEN** the frontline agent applies the user's correction -- **THEN** it uses `update_memory` -- **AND** it does not use `store_memory` to create an untracked duplicate for - the same correction - -### Requirement: Memory evaluation and operational criteria - -The redesigned memory subsystem SHALL ship with an eval suite and operational -SLOs covering recall quality, noise suppression, privacy behavior, and latency. -The implementation SHALL NOT be considered complete until the seeded eval suite -demonstrates the configured thresholds. - -#### Scenario: Seeded memory eval suite passes - -- **GIVEN** the seeded recall/privacy fixture suite is executed against the - redesigned subsystem -- **WHEN** the results are reported -- **THEN** relevant recall coverage, noise suppression, privacy leakage, and - latency metrics meet the thresholds defined by the change design -- **AND** a failing metric blocks rollout from being treated as complete - -#### Scenario: Local Ollama eval profile is the primary gate - -- **GIVEN** the seeded memory eval suite supports multiple model profiles -- **WHEN** Netclaw validates the redesigned memory subsystem before rollout -- **THEN** it runs the default gate against smaller local Ollama-hosted models -- **AND** passing larger hosted models does not waive a failing local Ollama - eval result - -### Requirement: Greenfield SQLite-first delivery stance - -The redesigned memory subsystem SHALL be implementation-ready as a greenfield -MVP without requiring legacy markdown import or legacy provider-mode -compatibility. Explicit memory tool names SHALL remain stable within the -redesigned subsystem so prompt and skill guidance can target a consistent -manual-control surface. - -#### Scenario: Greenfield MVP completes without import work - -- **GIVEN** Netclaw has no production-deployed public memory data that must be - preserved -- **WHEN** the redesigned memory subsystem is implemented for MVP -- **THEN** no markdown import or provider-mode migration is required for - completeness -- **AND** deferred legacy compatibility does not block delivery - -#### Scenario: Explicit tool names remain stable - -- **GIVEN** a prompt or skill instructs the model to use `find_memories` and - `store_memory` -- **WHEN** the redesigned memory subsystem is active -- **THEN** those tool names continue to function -- **AND** they execute against the SQLite memory service and policy pipeline diff --git a/openspec/specs/netclaw-cli/spec.md b/openspec/specs/netclaw-cli/spec.md index c93e17d87..4c792fcb7 100644 --- a/openspec/specs/netclaw-cli/spec.md +++ b/openspec/specs/netclaw-cli/spec.md @@ -1,611 +1,5 @@ -# netclaw-cli Specification - -## Purpose - -Define command-line management behavior for onboarding, validation, and -diagnostics. - -## Requirements - -### Requirement: Guided onboarding - -The CLI SHALL provide guided setup through `netclaw init`. The onboarding -wizard SHALL collect Slack credentials, provider configuration, ACL inputs, -MCP server configuration, and exposure mode selection. On completion, the -wizard SHALL run a health check to verify the baseline configuration is -functional. - -#### Scenario: First-time setup - -- **WHEN** operator runs `netclaw init` on a fresh install -- **THEN** guided setup collects provider, Slack, ACL, MCP, and exposure mode - inputs -- **AND** writes a runnable baseline configuration - -#### Scenario: MCP server configured during init - -- **WHEN** onboarding reaches the MCP step -- **THEN** the wizard prompts for at least one MCP server profile (Memorizer - recommended) -- **AND** validates server handshake before proceeding - -#### Scenario: Exposure mode selected during init - -- **WHEN** onboarding reaches the exposure step -- **THEN** the wizard presents available exposure modes (local, tailscale-serve, - tailscale-funnel, cloudflare-tunnel) -- **AND** applies security warnings for internet-reachable modes and explains - that remote daemon access must still require authenticated users - -#### Scenario: Health check on completion - -- **WHEN** onboarding completes all steps -- **THEN** the wizard runs a health check covering Slack connectivity, provider - validation, and MCP server reachability -- **AND** reports pass/fail for each component - -### Requirement: Resumable onboarding - -The CLI SHALL support resuming incomplete onboarding. - -#### Scenario: Resume setup - -- **GIVEN** onboarding is incomplete -- **WHEN** operator runs `netclaw init --resume` -- **THEN** setup continues from first incomplete step - -### Requirement: Config and ACL validation - -The CLI SHALL validate configuration and return actionable errors. - -#### Scenario: Validation failure - -- **WHEN** config validation fails -- **THEN** command exits non-zero -- **AND** output includes remediation guidance - -### Requirement: Security diagnostics - -The CLI SHALL report exposure mode and policy health. - -#### Scenario: Doctor output - -- **WHEN** operator runs `netclaw doctor` -- **THEN** output includes exposure mode, policy status, and prioritized issues - -### Requirement: Optional smoke test command - -The CLI SHALL expose an explicit smoke-test command for live provider checks. - -#### Scenario: Run Ollama smoke test - -- **WHEN** operator runs `netclaw test smoke --provider ollama` -- **THEN** CLI executes provider connectivity smoke checks -- **AND** outputs a concise pass/fail report - -### Requirement: Project management commands - -The CLI SHALL provide `netclaw project list|add|remove` commands for managing -the project registry. Projects represent registered repositories with their -paths, capabilities, and associated AGENTS.md files. - -#### Scenario: List registered projects - -- **WHEN** operator runs `netclaw project list` -- **THEN** output displays all registered projects with paths and capabilities - -#### Scenario: Add a project - -- **WHEN** operator runs `netclaw project add --path /home/user/repos/myproject` -- **THEN** the project is added to the project registry -- **AND** the system scans for an AGENTS.md file in the project root - -#### Scenario: Remove a project - -- **GIVEN** a project is registered -- **WHEN** operator runs `netclaw project remove myproject` -- **THEN** the project is removed from the registry - -### Requirement: Environment discovery command - -The CLI SHALL provide `netclaw environment scan|show` commands for discovering -and displaying the capability inventory of the host environment. - -#### Scenario: Scan environment - -- **WHEN** operator runs `netclaw environment scan` -- **THEN** the system discovers installed tools (git, gh, claude, opencode, - dotnet), git credentials, MCP server reachability, and host capabilities -- **AND** writes the inventory to the environment inventory file - -#### Scenario: Show environment - -- **WHEN** operator runs `netclaw environment show` -- **THEN** output displays the current environment inventory with tool - availability, credential status, and capability details - -### Requirement: Memory display command - -The CLI SHALL provide `netclaw memory show` for displaying the contents of -agent memory files (personality, project registry, environment inventory). - -#### Scenario: Show agent memory - -- **WHEN** operator runs `netclaw memory show` -- **THEN** output displays the contents of personality files, project registry, - and environment inventory in a readable format - -#### Scenario: Show specific memory category - -- **WHEN** operator runs `netclaw memory show --category personality` -- **THEN** output displays only the personality/soul files - -### Requirement: Schedule management commands - -The CLI SHALL provide `netclaw schedule list|show|pause|resume|delete` commands -for managing scheduled tasks. - -#### Scenario: List scheduled tasks - -- **WHEN** operator runs `netclaw schedule list` -- **THEN** output displays all scheduled tasks with name, schedule, status, and - last execution result - -#### Scenario: Show scheduled task details - -- **WHEN** operator runs `netclaw schedule show my-task` -- **THEN** output displays the full task definition including schedule, required - tool grants, instructions, and execution history - -#### Scenario: Pause a scheduled task - -- **GIVEN** a scheduled task is active -- **WHEN** operator runs `netclaw schedule pause my-task` -- **THEN** the task is paused and will not execute until resumed - -#### Scenario: Resume a paused task - -- **GIVEN** a scheduled task is paused -- **WHEN** operator runs `netclaw schedule resume my-task` -- **THEN** the task is reactivated and will execute on its next scheduled time - -#### Scenario: Delete a scheduled task - -- **GIVEN** a scheduled task exists -- **WHEN** operator runs `netclaw schedule delete my-task` -- **THEN** the task is permanently removed from the schedule registry - -### Requirement: Personality reset command - -The CLI SHALL provide `netclaw personality reset` to delete existing personality -files and re-trigger the conversational personality bootstrap on the next -conversation. - -#### Scenario: Reset personality - -- **WHEN** operator runs `netclaw personality reset` -- **THEN** existing personality/soul files are deleted -- **AND** the next conversation triggers the conversational personality bootstrap - -#### Scenario: Reset confirmation - -- **WHEN** operator runs `netclaw personality reset` -- **THEN** the CLI requires explicit confirmation before deleting personality - files - -### Requirement: Cocona command routing - -The application SHALL use Cocona as the CLI command routing framework. All -commands SHALL be routed through Cocona's convention-based command model with -DI integration. - -#### Scenario: Command routed through Cocona - -- **WHEN** operator runs `netclaw [args]` -- **THEN** Cocona routes to the matching command class -- **AND** DI-registered services are available to the command handler - -### Requirement: TUI command classification - -Commands SHALL be classified as either TUI-interactive (rendered via Termina) -or plain-CLI (standard console output). `netclaw init`, `netclaw chat`, and -`netclaw sessions` SHALL use Termina TUI. All other commands SHALL use plain -console output. - -#### Scenario: TUI command launches Termina - -- **WHEN** operator runs `netclaw chat`, `netclaw init`, or `netclaw sessions` -- **THEN** the command handler launches Termina as a hosted service -- **AND** the TUI renders interactive components - -#### Scenario: Plain CLI command uses console output - -- **WHEN** operator runs `netclaw doctor` or any non-TUI command -- **THEN** the command handler writes to standard output -- **AND** no Termina TUI is launched - -### Requirement: Interactive chat command - -The CLI SHALL provide `netclaw chat` as an interactive agent prompt that -connects to the daemon via SignalR. The chat command SHALL support an optional -`--resume ` flag to attach to an existing session instead of -creating a new one. - -#### Scenario: Start chat session - -- **WHEN** operator runs `netclaw chat` -- **THEN** a SignalR connection is established to the daemon -- **AND** a TUI chat interface is rendered with input panel and message history -- **AND** a new session is created via `EnsureSession` - -#### Scenario: Send message in chat - -- **GIVEN** a chat session is active -- **WHEN** operator types a message and presses Enter -- **THEN** a `SendMessage` call is dispatched via SignalR -- **AND** the response streams into the chat history via StreamingTextNode - -#### Scenario: Tool activity displayed inline - -- **GIVEN** a chat session is processing a turn with tool calls -- **WHEN** tools are invoked during the turn -- **THEN** a tool activity panel appears inline showing tool name, status, and - duration -- **AND** completed tools show checkmark with duration -- **AND** in-progress tools show spinner - -#### Scenario: MCP status displayed in status bar - -- **GIVEN** MCP servers are configured -- **WHEN** the chat TUI is active -- **THEN** the status bar shows MCP connectivity status -- **AND** green indicates all servers connected -- **AND** yellow indicates degraded connectivity -- **AND** red indicates servers unreachable - -#### Scenario: Resume existing session via flag - -- **WHEN** operator runs `netclaw chat --resume ` -- **THEN** a SignalR connection is established to the daemon -- **AND** the chat page attaches to the specified session via `EnsureSession` -- **AND** a "Resumed" indicator is shown - -### Requirement: Session browser command - -The CLI SHALL provide `netclaw sessions` as a TUI command that displays recent -sessions and allows the user to select one to resume. - -#### Scenario: Launch session browser - -- **WHEN** operator runs `netclaw sessions` -- **THEN** the TUI displays a list of recent sessions from the daemon catalog -- **AND** daemon connectivity is required (fails with helpful error if daemon - is not running) - -### Requirement: Daemon entry point - -The CLI SHALL provide `netclaw run` as the explicit daemon entry point. The -daemon SHALL start the Slack Socket Mode adapter, Akka actor system, scheduled -task timers, and health endpoints. The daemon SHALL NOT render a TUI. - -#### Scenario: Start daemon mode - -- **WHEN** operator runs `netclaw run` -- **THEN** the Slack Socket Mode adapter connects -- **AND** the Akka actor system starts -- **AND** scheduled task timers are registered -- **AND** health endpoints are available -- **AND** no TUI is rendered - -#### Scenario: Daemon logs to console - -- **GIVEN** the daemon is running -- **WHEN** events occur (messages, tool calls, errors) -- **THEN** events are logged to console and/or configured log output -- **AND** no interactive input is expected - -### Requirement: Skill scan degradation is surfaced operationally - -When skill scanning rejects one or more discovered skills during daemon startup -or rebuild, Netclaw SHALL surface that degraded state through operator-visible -diagnostics rather than silently continuing with a partial inventory. - -#### Scenario: Startup rebuild logs degraded skill inventory - -- **GIVEN** daemon startup scans the skills directory and rejects one or more skills -- **WHEN** the startup rebuild completes -- **THEN** the daemon logs that skill inventory is degraded -- **AND** the log includes the number of accepted skills and rejected issues - -#### Scenario: Sync rebuild logs rejected system skill - -- **GIVEN** system skill sync completes and a subsequent scan rejects a discovered skill -- **WHEN** the registry rebuild finishes -- **THEN** the daemon logs the rejected skill path and reason -- **AND** the registry is rebuilt from accepted skills only - -### Requirement: Doctor command - -The CLI SHALL provide `netclaw doctor` as a plain CLI command that runs startup -checks and reports results with remediation guidance. The doctor command SHALL -exit with code 0 (all pass), 1 (errors), or 2 (warnings only). - -#### Scenario: All checks pass - -- **WHEN** operator runs `netclaw doctor` -- **AND** all startup checks pass -- **THEN** output shows checkmarks for each check -- **AND** exit code is 0 - -#### Scenario: Check fails with remediation - -- **WHEN** operator runs `netclaw doctor` -- **AND** a startup check fails -- **THEN** output shows the failure with a remediation command -- **AND** exit code is 1 - -### Requirement: Memory provider in status output - -The `netclaw status` command SHALL display the active memory provider and -its health status. - -#### Scenario: Status shows memory provider - -- **WHEN** the operator runs `netclaw status` -- **THEN** the output includes a `memory:` line showing: - - Provider name (`files` or `memorizer`) - - Health status (`healthy`, `degraded`, or `unavailable`) - - For Memorizer: endpoint URL and tool count - - For files: memory count and index path - -### Requirement: Signed manifest verification during update - -The `netclaw update` command SHALL verify the minisign signature of -`manifest.json` before trusting its contents. The command SHALL download -`manifest.json.sig` alongside the manifest and verify the Ed25519 signature -against the embedded public key. The command SHALL reject the manifest and abort -the update if signature verification fails. - -#### Scenario: Successful update with valid signature - -- **WHEN** operator runs `netclaw update` -- **AND** the manifest signature verifies against the embedded public key -- **THEN** the update proceeds normally using the verified manifest checksums - -#### Scenario: Update aborted on invalid signature - -- **WHEN** operator runs `netclaw update` -- **AND** the manifest signature does not verify -- **THEN** the command exits with a non-zero code -- **AND** an error message warns of possible manifest tampering - -#### Scenario: Update aborted when signature file missing - -- **WHEN** operator runs `netclaw update` -- **AND** `manifest.json.sig` cannot be downloaded -- **THEN** the command exits with a non-zero code -- **AND** an error message explains the signature file is missing - -### Requirement: Periodic daemon update check - -The daemon SHALL periodically recheck for available updates while running. -The default recheck interval SHALL be 24 hours. The recheck SHALL use the same -`UpdateCheckService` and signature verification as the CLI update command. - -#### Scenario: Daemon detects update after startup - -- **GIVEN** the daemon started with no update available -- **WHEN** a new release is published and 24 hours elapse -- **THEN** the daemon detects the available update on the next periodic check - -#### Scenario: Recheck interval respects cache - -- **GIVEN** the update check cache duration is 1 hour -- **WHEN** the periodic timer fires at the 24-hour interval -- **THEN** a fresh manifest fetch is performed (cache has long expired) - -### Requirement: Update availability operational alert - -The daemon SHALL emit an `UpdateAvailable` operational alert via -`IOperationalNotificationSink` when an update is detected. The alert SHALL -be emitted at most once per detected version (deduplicated by the existing -webhook deduplication mechanism). - -#### Scenario: Alert emitted on update detection - -- **GIVEN** the daemon detects an available update -- **WHEN** the update check result indicates `IsUpdateAvailable` -- **THEN** an `UpdateAvailable` operational alert is emitted with severity - "info" -- **AND** the alert summary includes the current and available versions - -#### Scenario: Alert delivered to configured webhooks - -- **GIVEN** a Slack webhook is configured in notifications config -- **WHEN** an `UpdateAvailable` alert is emitted -- **THEN** the webhook receives a notification formatted per the webhook format - (Generic JSON or Slack Block Kit) - -#### Scenario: Alert not duplicated within dedup window - -- **GIVEN** an `UpdateAvailable` alert was recently emitted for the same version -- **WHEN** the periodic recheck runs again within the deduplication window -- **THEN** no duplicate alert is emitted - - ## ADDED Requirements -### Requirement: Signed manifest verification during update - -The `netclaw update` command SHALL verify the minisign signature of -`manifest.json` before trusting its contents. The command SHALL download -`manifest.json.sig` alongside the manifest and verify the Ed25519 signature -against the embedded public key. The command SHALL reject the manifest and abort -the update if signature verification fails. - -#### Scenario: Successful update with valid signature - -- **WHEN** operator runs `netclaw update` -- **AND** the manifest signature verifies against the embedded public key -- **THEN** the update proceeds normally using the verified manifest checksums - -#### Scenario: Update aborted on invalid signature - -- **WHEN** operator runs `netclaw update` -- **AND** the manifest signature does not verify -- **THEN** the command exits with a non-zero code -- **AND** an error message warns of possible manifest tampering - -#### Scenario: Update aborted when signature file missing - -- **WHEN** operator runs `netclaw update` -- **AND** `manifest.json.sig` cannot be downloaded -- **THEN** the command exits with a non-zero code -- **AND** an error message explains the signature file is missing - -### Requirement: Periodic daemon update check - -The daemon SHALL periodically recheck for available updates while running. -The default recheck interval SHALL be 24 hours. The recheck SHALL use the same -`UpdateCheckService` and signature verification as the CLI update command. - -#### Scenario: Daemon detects update after startup - -- **GIVEN** the daemon started with no update available -- **WHEN** a new release is published and 24 hours elapse -- **THEN** the daemon detects the available update on the next periodic check - -#### Scenario: Recheck interval respects cache - -- **GIVEN** the update check cache duration is 1 hour -- **WHEN** the periodic timer fires at the 24-hour interval -- **THEN** a fresh manifest fetch is performed (cache has long expired) - -### Requirement: Update availability operational alert - -The daemon SHALL emit an `UpdateAvailable` operational alert via -`IOperationalNotificationSink` when an update is detected. The alert SHALL -be emitted at most once per detected version (deduplicated by the existing -webhook deduplication mechanism). - -#### Scenario: Alert emitted on update detection - -- **GIVEN** the daemon detects an available update -- **WHEN** the update check result indicates `IsUpdateAvailable` -- **THEN** an `UpdateAvailable` operational alert is emitted with severity - "info" -- **AND** the alert summary includes the current and available versions - -#### Scenario: Alert delivered to configured webhooks - -- **GIVEN** a Slack webhook is configured in notifications config -- **WHEN** an `UpdateAvailable` alert is emitted -- **THEN** the webhook receives a notification formatted per the webhook format - (Generic JSON or Slack Block Kit) - -#### Scenario: Alert not duplicated within dedup window - -- **GIVEN** an `UpdateAvailable` alert was recently emitted for the same version -- **WHEN** the periodic recheck runs again within the deduplication window -- **THEN** no duplicate alert is emitted - -### Requirement: MCP tool permissions CLI - -The system SHALL provide a `netclaw mcp tools` subcommand for viewing and -managing per-server tool grants across audience profiles. - -#### Scenario: List tools for a server - -- **GIVEN** the daemon is running and `memorizer` is connected -- **WHEN** operator runs `netclaw mcp tools memorizer` -- **THEN** the CLI displays all discovered tools from `memorizer` -- **AND** each tool shows its grant status per audience (Public, Team, Personal) -- **AND** tools not granted to any audience are visually distinguished - -#### Scenario: List tools when daemon is unavailable - -- **GIVEN** the daemon is not running -- **WHEN** operator runs `netclaw mcp tools memorizer` -- **THEN** the CLI reports that tool discovery requires the daemon -- **AND** exits with a non-zero exit code - -#### Scenario: Snapshot current tools as grants - -- **GIVEN** the daemon is running and `memorizer` exposes 5 tools -- **WHEN** operator runs `netclaw mcp tools memorizer --snapshot` -- **THEN** the CLI populates `McpServerToolGrants` for all audience profiles that allow `memorizer` -- **AND** each profile's grant list contains all 5 currently discovered tool names -- **AND** the updated config is written to `netclaw.json` - -#### Scenario: Help for tools subcommand - -- **WHEN** operator runs `netclaw mcp tools --help` -- **THEN** the CLI displays usage, subcommand description, and available flags - -### Requirement: MCP tool permissions TUI - -The system SHALL provide an interactive TUI mode for `netclaw mcp tools` -(invoked without a server name argument) that allows operators to browse -servers, view discovered tools, and toggle per-tool grants per audience. - -#### Scenario: Launch TUI without arguments - -- **GIVEN** the daemon is running with MCP servers connected -- **WHEN** operator runs `netclaw mcp tools` (no server name) -- **THEN** the TUI launches showing a list of configured MCP servers - -#### Scenario: Browse tools for a server - -- **GIVEN** the TUI is showing the server list -- **WHEN** operator selects a server -- **THEN** the TUI shows all discovered tools for that server -- **AND** each tool shows its current grant status for the selected audience - -#### Scenario: Cycle audience in TUI - -- **GIVEN** the TUI is showing tools for a server -- **WHEN** operator presses left/right arrow to cycle audience -- **THEN** the tool grant checkboxes update to reflect the selected audience's grants - -#### Scenario: Toggle tool grant in TUI - -- **GIVEN** the TUI is showing tools for a server under the Team audience -- **WHEN** operator toggles a tool's checkbox -- **THEN** the tool is added to or removed from the Team profile's `McpServerToolGrants` for this server - -#### Scenario: Toggle server access in TUI - -- **GIVEN** the TUI is showing tools for a server not allowed for the Team audience -- **WHEN** operator presses the enable/disable key -- **THEN** the server is added to the Team profile's `AllowedMcpServers` -- **AND** all tools start unchecked (secure by default) - -#### Scenario: Save changes from TUI - -- **GIVEN** the operator has toggled tool grants or server access in the TUI -- **WHEN** operator presses the save key -- **THEN** the updated `AllowedMcpServers` and `McpServerToolGrants` are written to `netclaw.json` -- **AND** the TUI confirms the save - -### Requirement: MCP doctor advisory for ungated servers - -The `netclaw doctor` command SHALL include an advisory check for MCP servers -that have no `McpServerToolGrants` configured on any audience profile. - -#### Scenario: Server with no tool grants triggers advisory - -- **GIVEN** `memorizer` is enabled and connected -- **AND** no audience profile has `McpServerToolGrants` entries for `memorizer` -- **WHEN** operator runs `netclaw doctor` -- **THEN** an info-level advisory is reported for `memorizer` -- **AND** the message suggests adding tool grants for supply-chain protection - -#### Scenario: Server with tool grants passes advisory - -- **GIVEN** `memorizer` has `McpServerToolGrants` on at least one audience profile -- **WHEN** operator runs `netclaw doctor` -- **THEN** no tool grant advisory is reported for `memorizer` - ### Requirement: Init wizard approval mode selection The `netclaw init` wizard SHALL ask about shell approval mode when configuring diff --git a/openspec/specs/netclaw-config-hot-reload/spec.md b/openspec/specs/netclaw-config-hot-reload/spec.md index 9fdde402b..d94a669d7 100644 --- a/openspec/specs/netclaw-config-hot-reload/spec.md +++ b/openspec/specs/netclaw-config-hot-reload/spec.md @@ -1,131 +1,70 @@ -# netclaw-config-hot-reload Specification - -## Purpose - -Define hot-reload behavior for operational configuration files. The system -monitors ACL rules, provider configuration, MCP server profiles, and schedule -definitions for changes and applies them to the runtime without process restart. - -## Requirements - -### Requirement: Operational config file monitoring - -The system SHALL monitor operational configuration files for changes using -`FileSystemWatcher`. Monitored files SHALL include ACL rules, provider -configuration, MCP server profiles, and schedule definitions. - -#### Scenario: ACL file change detected - -- **GIVEN** the ACL rules file exists at the configured path -- **WHEN** the file is modified on disk -- **THEN** the `ConfigWatcherService` detects the change within 500ms - -#### Scenario: Provider config change detected - -- **GIVEN** the provider configuration file exists at the configured path -- **WHEN** the file is modified on disk -- **THEN** the `ConfigWatcherService` detects the change within 500ms - -#### Scenario: Unwatched files are not monitored - -- **GIVEN** personality files, project registry, or environment inventory files - exist -- **WHEN** those files are modified on disk -- **THEN** the `ConfigWatcherService` does NOT detect or process the change - -### Requirement: Change event debounce - -The system SHALL debounce file change events with a configurable window -(default 500ms) to prevent rapid-fire reloads during file save operations. - -#### Scenario: Rapid successive writes debounced - -- **GIVEN** a watched config file is being saved -- **WHEN** the file system emits multiple change events within 500ms -- **THEN** the system processes only one reload after the debounce window - -#### Scenario: Separate files reload independently - -- **GIVEN** ACL rules and provider config are both watched -- **WHEN** both files change within the debounce window -- **THEN** each file's change is processed independently after its own debounce +## MODIFIED Requirements ### Requirement: Validate before apply -The system SHALL validate changed configuration before applying it to the -runtime. Invalid configuration SHALL be rejected with logged diagnostics. -The previous valid configuration SHALL remain in effect. +The system SHALL validate changed configuration before beginning runtime recovery. +Invalid configuration SHALL be rejected with logged diagnostics, and the current +daemon instance SHALL continue running with the previous effective config. +Valid configuration SHALL initiate a coordinated restart sequence instead of an +in-place actor update. -#### Scenario: Valid config change applied +#### Scenario: Valid config change initiates coordinated restart - **GIVEN** a watched config file changes - **WHEN** the new content passes validation -- **THEN** the change is applied to the runtime -- **AND** owning actors are notified +- **THEN** the daemon closes new session ingress +- **AND** begins draining active sessions before requesting host shutdown #### Scenario: Invalid config change rejected - **GIVEN** a watched config file changes - **WHEN** the new content fails validation - **THEN** the change is NOT applied -- **AND** the previous valid configuration remains in effect +- **AND** the current daemon instance continues running with the previous effective config - **AND** validation errors are logged with file path and error details -#### Scenario: Config file deleted +#### Scenario: Config file deletion with valid resulting config initiates restart - **GIVEN** a watched config file is being monitored -- **WHEN** the file is deleted from disk -- **THEN** the system logs a warning -- **AND** the existing runtime configuration remains in effect +- **WHEN** the file is deleted and the resulting effective configuration remains valid +- **THEN** the daemon treats the deletion as a valid config change +- **AND** begins the same coordinated restart flow - **AND** the process does NOT crash -### Requirement: Actor notification on config change - -The system SHALL notify owning actors when their configuration changes via -Akka pub/sub. Each config domain SHALL map to a specific actor or service. - -#### Scenario: ACL change triggers policy refresh +## ADDED Requirements -- **GIVEN** the ACL rules file has been validated successfully -- **WHEN** the config watcher dispatches the change event -- **THEN** the policy engine re-evaluates tool grants for active sessions +### Requirement: Coordinated daemon restart on valid config change -#### Scenario: Provider change triggers IChatClient rebuild +The system SHALL coordinate valid config changes through a restart coordinator. +The coordinator SHALL capture the set of currently active sessions after ingress +is closed, wait for those sessions to drain or time out, persist restart +recovery state, and only then request daemon shutdown. -- **GIVEN** the provider configuration file has been validated successfully -- **WHEN** the config watcher dispatches the change event -- **THEN** the provider factory rebuilds `IChatClient` instances +#### Scenario: Active sessions drain before restart -#### Scenario: MCP profile change triggers server reconnection +- **GIVEN** one or more sessions are active when a valid config change is detected +- **WHEN** restart coordination begins +- **THEN** the coordinator waits for drain completion acknowledgements from those sessions +- **AND** requests daemon shutdown only after all recorded sessions drain or the timeout expires -- **GIVEN** an MCP server profile has been validated successfully -- **WHEN** the config watcher dispatches the change event -- **THEN** affected MCP servers are reconnected or disconnected as appropriate +#### Scenario: Incoming work rejected during restart drain -#### Scenario: Schedule change triggers timer reconfiguration +- **GIVEN** the daemon has entered restart drain mode +- **WHEN** a new inbound message arrives through any daemon-managed adapter +- **THEN** the message is rejected with a restart-in-progress response +- **AND** no new session actor is created for that message -- **GIVEN** the schedule definitions file has been validated successfully -- **WHEN** the config watcher dispatches the change event -- **THEN** the `ScheduleManagerActor` reconfigures timers to match the new - definitions +#### Scenario: Drain timeout still requests restart -### Requirement: ConfigWatcherService hosted service +- **GIVEN** at least one recorded active session does not drain before the restart timeout +- **WHEN** the timeout expires +- **THEN** the coordinator requests daemon shutdown anyway +- **AND** records that recovery will resume from the last durable checkpoint for the timed-out session -The `ConfigWatcherService` SHALL be implemented as an `IHostedService` that -starts with the application and stops on shutdown. It SHALL manage -`FileSystemWatcher` instances for each watched config file. +## REMOVED Requirements -#### Scenario: Service starts with application - -- **GIVEN** the application is starting -- **WHEN** the hosted service initializes -- **THEN** `FileSystemWatcher` instances are created for each watched config - file -- **AND** the service begins monitoring for changes +### Requirement: Actor notification on config change -#### Scenario: Service stops on shutdown +**Reason**: Valid config changes now take effect through a coordinated daemon restart rather than in-place pub-sub notifications to live actors. -- **GIVEN** the application is shutting down -- **WHEN** the hosted service stops -- **THEN** all `FileSystemWatcher` instances are disposed -- **AND** no further change events are processed +**Migration**: Config-domain owners should recover new effective settings during startup and session warmup instead of subscribing to direct hot-reload events. diff --git a/openspec/specs/netclaw-discord-socket/spec.md b/openspec/specs/netclaw-discord-socket/spec.md new file mode 100644 index 000000000..43f44dd94 --- /dev/null +++ b/openspec/specs/netclaw-discord-socket/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: Discord gateway adapter lifecycle and health + +Netclaw SHALL provide a Discord gateway adapter that establishes and maintains a +gateway connection lifecycle equivalent to Slack Socket Mode operationally +(connect, disconnect detection, reconnect attempts, and health reporting). +Adapter startup SHALL fail closed when required Discord security or connection +configuration is invalid. + +#### Scenario: Discord adapter reports healthy connection + +- **GIVEN** valid Discord adapter configuration is present +- **WHEN** Netclaw starts with Discord enabled +- **THEN** the adapter establishes a gateway connection +- **AND** operator diagnostics report Discord adapter health as connected + +#### Scenario: Invalid Discord adapter config fails closed + +- **GIVEN** Discord adapter configuration is missing required security-critical fields +- **WHEN** Netclaw starts +- **THEN** startup fails with explicit validation diagnostics +- **AND** Discord ingress does not run in permissive mode + +### Requirement: Discord ingress normalization and ACL-gated dispatch + +Discord inbound events SHALL be normalized into `SendUserMessage` with complete +source metadata and deterministic session identity. ACL evaluation SHALL run +before session dispatch for all Discord inbound paths. + +#### Scenario: Discord inbound message normalized and dispatched + +- **GIVEN** a Discord message event from an allowed sender/channel +- **WHEN** the Discord adapter processes the event +- **THEN** it produces `SendUserMessage` with normalized content and metadata +- **AND** it dispatches only after ACL allow decision + +#### Scenario: Discord inbound message denied before dispatch + +- **GIVEN** a Discord message event from a denied sender/channel +- **WHEN** ACL evaluates the inbound event +- **THEN** the event is denied before session dispatch +- **AND** a structured deny reason is recorded for diagnostics + +### Requirement: Discord session identity and reply targeting parity + +Discord session identity SHALL be deterministic and thread-aware using +`{channelId}/{threadIdOrMessageId}` where `threadIdOrMessageId` resolves to the +Discord thread ID when present, or the root message ID when not threaded. +Replies SHALL be delivered back to the originating Discord context represented +by that identity. + +#### Scenario: Threaded Discord messages route to same session + +- **GIVEN** two inbound Discord messages in thread `th-42` under channel `ch-7` +- **WHEN** session keys are derived +- **THEN** both map to `ch-7/th-42` +- **AND** both route to the same session actor + +#### Scenario: Non-threaded Discord message uses root message identity + +- **GIVEN** an inbound Discord message in channel `ch-7` without thread context +- **WHEN** session key is derived +- **THEN** key is `ch-7/` +- **AND** reply delivery targets that originating message context + +### Requirement: Text-first slash command compatibility on Discord + +Discord adapter behavior SHALL preserve session-level text-first slash command +dispatch for inbound message content beginning with `/` without requiring +Discord app-command registration in MVP. + +#### Scenario: Text slash command works without app-command registration + +- **GIVEN** Discord app-command registration is not configured +- **WHEN** user sends `/netclaw-operations check health` as a Discord message +- **THEN** slash-command-dispatch processes the message deterministically +- **AND** no Discord platform registration is required for this behavior + +### Requirement: Discord interactive approval with deterministic text fallback + +The Discord adapter SHALL handle `ToolInteractionRequest` in Discord sessions by +preferring Discord interaction controls when available and SHALL always support +deterministic text fallback with equivalent approval options and outcomes. + +#### Scenario: Discord interaction approval path succeeds + +- **GIVEN** Discord interaction callbacks are available +- **WHEN** a tool approval request is emitted +- **THEN** the adapter renders interaction controls +- **AND** selected approval decision is routed as `ToolInteractionResponse` + +#### Scenario: Interaction path unavailable falls back to text deterministically + +- **GIVEN** Discord interaction callbacks are unavailable or fail +- **WHEN** a tool approval request is emitted +- **THEN** the adapter emits a text prompt with deterministic A/B/C/D options +- **AND** text reply parsing routes an equivalent `ToolInteractionResponse` diff --git a/openspec/specs/netclaw-gateway-security/spec.md b/openspec/specs/netclaw-gateway-security/spec.md index 45528bb99..60a9e1606 100644 --- a/openspec/specs/netclaw-gateway-security/spec.md +++ b/openspec/specs/netclaw-gateway-security/spec.md @@ -1,193 +1,44 @@ -# netclaw-gateway-security Specification +## ADDED Requirements -## Purpose +### Requirement: Inbound webhook ingress safeguards -Define security controls for inbound handling, exposure modes, approvals, and -audit behavior. +The system SHALL enforce webhook ingress safeguards before dispatching any agent +work. For configured webhook routes, request verification, request-size limits, +delivery deduplication, and rate limiting SHALL all happen before a session is +created. -## Requirements +#### Scenario: Invalid verifier input rejected before session launch -### Requirement: Default-deny policy +- **GIVEN** a configured webhook route requires request verification +- **WHEN** an inbound request arrives with a missing or invalid signature/secret +- **THEN** the daemon rejects the request +- **AND** no webhook session is created -The system SHALL deny interactions unless explicitly allowed by ACL. +#### Scenario: Duplicate delivery suppressed before dispatch -#### Scenario: Unknown sender blocked +- **GIVEN** a webhook route extracts a delivery identifier from the inbound + request +- **AND** the same delivery identifier has already been accepted recently +- **WHEN** the duplicate request arrives again +- **THEN** the daemon suppresses the duplicate delivery +- **AND** no second webhook session is created -- **WHEN** an unknown sender triggers an interaction -- **THEN** the interaction is denied +#### Scenario: Oversized webhook request rejected -### Requirement: Fail-closed startup +- **GIVEN** a configured webhook route has a maximum request size +- **WHEN** an inbound request exceeds that size limit +- **THEN** the daemon rejects the request before payload dispatch -The system SHALL fail startup if security-critical configuration is invalid. +#### Scenario: Route-level rate limit exceeded -#### Scenario: Invalid ACL prevents startup +- **GIVEN** a configured webhook route has reached its allowed delivery rate +- **WHEN** another request arrives for that route +- **THEN** the daemon rejects the request with a rate-limit response +- **AND** no webhook session is created -- **WHEN** ACL schema is invalid -- **THEN** runtime start fails +#### Scenario: Invalid route file fails closed before dispatch -### Requirement: Controlled exposure modes - -The system SHALL support explicit exposure modes with secure defaults. Host- -network reachable daemon access SHALL require authenticated users. `Public` -deployment posture remains a chat-audience concept and SHALL NOT be -interpreted as permission for anonymous network access. Audience types and -exposure modes are parallel controls: audience governs chat interaction, while -exposure mode governs daemon network reachability. - -#### Scenario: Default local mode - -- **WHEN** no exposure mode is configured -- **THEN** the system binds loopback-only - -#### Scenario: Internet-reachable mode requires authenticated users - -- **GIVEN** exposure mode is internet-reachable (`tailscale-funnel` or - `cloudflare-tunnel`) -- **WHEN** access policy prerequisites are missing -- **THEN** configuration validation fails - -### Requirement: Privileged action approval - -The system SHALL require explicit approval for privileged operations. - -#### Scenario: Privileged request requires approval - -- **WHEN** a privileged operation is requested -- **THEN** the system requires trusted operator approval before execution - -### Requirement: Security audit visibility - -The system SHALL expose policy denies and exposure status in diagnostics. - -#### Scenario: Audit events visible in diagnostics - -- **WHEN** policy allow/deny decisions occur -- **THEN** diagnostics include timestamped records with reason codes - -### Requirement: Self-configuration safety (SEC-008) - -The system SHALL validate configuration changes before writing them to disk. -ACL and security policy files MUST NOT be self-modifiable by the agent. The -agent SHALL be permitted to modify personality files, project registry, -environment inventory, and schedule definitions through conversation. - -#### Scenario: Agent modifies permitted configuration - -- **GIVEN** the agent has `config_write` grant -- **WHEN** the agent writes to a personality file or project registry -- **THEN** the change is validated against schema before write -- **AND** the write succeeds if validation passes - -#### Scenario: Agent blocked from modifying security files - -- **WHEN** the agent attempts to modify ACL, security policy, or gateway - configuration files -- **THEN** the write is rejected regardless of grants -- **AND** an audit record is created for the denied attempt - -#### Scenario: Invalid config change rejected - -- **GIVEN** the agent has `config_write` grant -- **WHEN** the agent writes configuration that fails schema validation -- **THEN** the write is rejected -- **AND** the agent receives validation error details - -### Requirement: Shell execution boundaries (SEC-009) - -The system SHALL enforce safety boundaries on shell command execution. Shell -commands SHALL run as the process user with no privilege escalation. A -configurable timeout (default 60 seconds) SHALL terminate long-running -commands. Output SHALL be truncated at a configurable limit. Interactive -commands (those requiring stdin) SHALL be rejected. Working directory SHALL be -restricted to configured allowed paths. - -#### Scenario: Shell command completes within timeout - -- **GIVEN** shell execution timeout is configured to 60 seconds -- **WHEN** a shell command completes in 10 seconds -- **THEN** the output is returned to the session - -#### Scenario: Shell command exceeds timeout - -- **GIVEN** shell execution timeout is configured to 60 seconds -- **WHEN** a shell command runs for more than 60 seconds -- **THEN** the process is terminated -- **AND** the session receives a timeout error - -#### Scenario: Interactive command rejected - -- **WHEN** a shell command requires interactive stdin input -- **THEN** execution is rejected before launch -- **AND** the session receives a rejection reason - -#### Scenario: Output truncation - -- **GIVEN** output truncation limit is configured -- **WHEN** shell command output exceeds the configured limit -- **THEN** output is truncated with an indicator that content was omitted - -#### Scenario: Working directory restriction - -- **WHEN** a shell command targets a directory outside configured allowed paths -- **THEN** execution is denied with a policy reason code - -### Requirement: Tool invocation audit - -The system SHALL create audit records for all tool invocations. Each audit -record SHALL include: tool name, session ID, timestamp, and allow/deny result. - -#### Scenario: Allowed tool invocation is audited - -- **WHEN** a tool invocation is allowed by policy -- **THEN** an audit record is created with tool name, session ID, timestamp, and - result `allow` - -#### Scenario: Denied tool invocation is audited - -- **WHEN** a tool invocation is denied by policy -- **THEN** an audit record is created with tool name, session ID, timestamp, and - result `deny` with reason code - -#### Scenario: Audit records visible in diagnostics - -- **WHEN** operator queries tool invocation audit -- **THEN** records are available with filtering by session ID, tool name, and - time range - -### Requirement: Fail-closed reminder write validation - -Reminder write surfaces SHALL validate reminder audience server-side before -persisting or importing a reminder definition. This applies to REST, admin, -CLI, and import paths in addition to conversational tool calls. Invalid -audience values, missing required authority context, or requested audiences -that exceed the caller's source authority SHALL be rejected with clear error -messages. Execution may trust the stored reminder audience because minting-time -validation is mandatory. - -#### Scenario: REST create rejects invalid audience value - -- **GIVEN** a REST reminder create request provides `audience: "superuser"` -- **WHEN** the server validates the request -- **THEN** the request is rejected with a clear validation error -- **AND** no reminder definition is persisted - -#### Scenario: Admin import rejects over-privileged reminder - -- **GIVEN** an admin or import request is authenticated with source audience `Team` -- **WHEN** the request submits a reminder definition with stored audience `Personal` -- **THEN** the server rejects the request with a clear over-privilege error -- **AND** the reminder is not written to disk - -#### Scenario: Write path fails closed without authority context - -- **GIVEN** a non-conversational reminder write path cannot determine the caller's source audience / authority -- **WHEN** the request attempts to create or import a reminder definition -- **THEN** the server rejects the request -- **AND** the error states that reminder audience authorization context is required - -#### Scenario: Execution trusts stored audience after validated minting - -- **GIVEN** a reminder definition was accepted by the server's minting validation -- **WHEN** the reminder executes later on a timer -- **THEN** the execution path uses the stored audience as authoritative -- **AND** no deployment-default fallback broadens that audience +- **GIVEN** a route file exists for a webhook route but is malformed or invalid +- **WHEN** a request arrives for that route +- **THEN** the daemon does not use any stale cached route definition +- **AND** the request is rejected before a webhook session is created diff --git a/openspec/specs/netclaw-input-adapters/spec.md b/openspec/specs/netclaw-input-adapters/spec.md index 4d59bb427..9354c6c80 100644 --- a/openspec/specs/netclaw-input-adapters/spec.md +++ b/openspec/specs/netclaw-input-adapters/spec.md @@ -1,466 +1,4 @@ -# netclaw-input-adapters Specification - -## Purpose - -Define the unified input adapter architecture that treats all message sources -identically. All inputs produce a `SendUserMessage` command routed to the -session parent actor. This capability covers transport-agnostic session -commands, source metadata, entity key routing, broadcast subscription for -reply delivery, the Slack Socket Mode adapter, and the internal timer adapter. - -## Requirements - -### Requirement: Transport-agnostic session commands - -All input adapters SHALL produce `SendUserMessage` as the universal command -contract for delivering input to session actors. Session actors SHALL never -reference adapter-specific types. The `SendUserMessage` command and broadcast -events SHALL be the only contract between adapters and session actors. - -#### Scenario: Slack adapter produces SendUserMessage - -- **GIVEN** a Slack `app_mention` event is received -- **WHEN** the Slack adapter processes the event -- **THEN** the adapter produces a `SendUserMessage` command -- **AND** the command contains the message content, entity key, and source - metadata - -#### Scenario: Timer adapter produces SendUserMessage - -- **GIVEN** an Akka timer fires for a scheduled task -- **WHEN** the timer adapter processes the tick -- **THEN** the adapter produces a `SendUserMessage` command -- **AND** the command contains the task instruction as message content - -#### Scenario: Session actor is adapter-agnostic - -- **GIVEN** a session actor receives a `SendUserMessage` command -- **WHEN** the session processes the turn -- **THEN** the session actor does not import or reference any adapter-specific - types -- **AND** the session behavior is identical regardless of the originating - adapter - -### Requirement: Source metadata on all commands - -All inbound `SendUserMessage` commands SHALL carry source metadata sufficient -for ACL evaluation and audit logging. For threaded authorized turns that adopt -prior context, source metadata SHALL identify the current authorized sender as -the executable-turn source, while adopted prior messages are represented only in -the adopted-context audit record and canonical projection. That projection SHALL -continue to name adopted speakers by stable sender id even though they are not -treated as executable-turn sources. - -#### Scenario: Slack source metadata populated - -- **GIVEN** a Slack message event is received -- **WHEN** the Slack adapter creates the `SendUserMessage` command -- **THEN** the source metadata includes adapter type `slack` -- **AND** includes the Slack user ID as sender identity -- **AND** includes the Slack channel ID -- **AND** includes the event timestamp - -#### Scenario: Timer source metadata populated - -- **GIVEN** an Akka timer fires for a scheduled task -- **WHEN** the timer adapter creates the `SendUserMessage` command -- **THEN** the source metadata includes adapter type `timer` -- **AND** includes the task creator as sender identity -- **AND** includes the task ID as the channel equivalent -- **AND** includes the timer fire timestamp - -#### Scenario: ACL uses source metadata for evaluation - -- **GIVEN** a `SendUserMessage` command arrives with source metadata -- **WHEN** the ACL gate evaluates the command -- **THEN** the evaluation uses the sender identity from source metadata -- **AND** the evaluation uses the channel identifier from source metadata - -#### Scenario: Authorized threaded turn source metadata points at authorizer - -- **GIVEN** a thread where unauthorized messages were adopted -- **WHEN** the authorized turn is created -- **THEN** the command source metadata identifies the authorized current sender -- **AND** adopted prior senders are not treated as independent live turn sources - -### Requirement: Authorized threaded turns adopt unsynced context - -When a threaded adapter receives an authorized inbound message, it SHALL hydrate -the unsynced thread gap before that message and construct a single authorized -turn envelope containing: - -- a canonical adopted-context projection for the adopted window; and -- the current authorized executable message. - -The adopted-context portion SHALL be quoted context only. The current -authorized message SHALL be the only executable user instruction in that turn. - -When adopted context is present, the threaded adapter MAY construct the -canonical adopted-context projection before handoff. The session SHALL durably -persist that exact projection together with the adopted-message metadata before -execution continues. Retries or recovery for the same authorized message id -SHALL reuse the persisted adopted-context record rather than re-derive a -different projection from raw thread history. - -If the unsynced gap is empty, the adapter SHALL omit adopted-context -persistence and adopted-context framing and SHALL send only the current -authorized message as an ordinary authorized turn. - -#### Scenario: Authorized message carries adopted window plus executable message - -- **GIVEN** a thread has unsynced prior messages -- **AND** an authorized user sends the next inbound message -- **WHEN** the adapter constructs the session input -- **THEN** exactly one `SendUserMessage` is created -- **AND** it contains the adopted-context projection first -- **AND** it contains the current authorized message second -- **AND** only the current authorized message is executable - -#### Scenario: Zero-gap authorized message omits adopted-context framing - -- **GIVEN** the watermark already covers all prior thread messages before the - current authorized inbound -- **WHEN** the adapter constructs the session input -- **THEN** no adopted-context projection is prepended -- **AND** the session receives only the current authorized message text - -### Requirement: Unauthorized live threaded messages stay off the turn path - -Threaded adapters SHALL NOT map unauthorized live inbound messages to -`SendUserMessage` commands. Those messages SHALL remain pending source-thread -context until a later authorized message adopts them. - -#### Scenario: Unauthorized live message does not become a turn - -- **GIVEN** a threaded Slack message from a non-allowed user -- **WHEN** no authorized user is speaking on that inbound event -- **THEN** no `SendUserMessage` command is created -- **AND** the message does not enter slash-command dispatch or model execution - -### Requirement: Canonical framing and reserved-marker escaping - -The channel pipeline SHALL use the following canonical framing for authorized -threaded turns: - -```text -[adopted-context] -[adopted-message id={messageId} author={senderId} authority-at-inclusion={authorized|pending} ts={timestamp}] -{escaped adopted text} -[/adopted-message] -[/adopted-context] -[current-authorized-message author={senderId} ts={timestamp}] -{escaped current text} -[/current-authorized-message] -``` - -Any user-originated line beginning with a reserved marker prefix SHALL be -escaped by prefixing that line with `\` before inclusion in the canonical -projection. - -The adapter owns source-thread gap fetch and watermark bookkeeping. After the -authorized turn is accepted for enqueue, it SHALL persist a pending cursor for -that authorized message. The adapter SHALL advance the durable -authorized-sync watermark only after `TurnCompleted` or other durable turn -completion confirms that the turn was durably recorded. This sequencing SHALL -remain fail-closed for crash recovery. - -#### Scenario: Adopted message text with reserved marker is escaped - -- **GIVEN** an adopted source message begins with `[adopted-context]` -- **WHEN** the projection is built -- **THEN** the emitted line begins with `\[adopted-context]` -- **AND** the model-visible framing remains unambiguous - -#### Scenario: Current authorized message with reserved marker is escaped - -- **GIVEN** the authorized sender's text begins with `[/adopted-message]` -- **WHEN** the projection is built -- **THEN** the line is escaped before inclusion under - `[current-authorized-message ...]` - -### Requirement: Entity key routing - -The session parent actor SHALL extract an entity key from each -`SendUserMessage` command and route to the correct child session actor. Slack -messages SHALL use entity key pattern `{channelId}/{threadTs}`. Timer -messages SHALL use entity key pattern `schedule/{taskId}/{runTs}`. TUI -messages SHALL use entity key pattern `tui/{sessionId}`. - -#### Scenario: Slack message routed by thread identity - -- **GIVEN** a Slack message arrives from channel `C0123` in thread `T456` -- **WHEN** the session parent extracts the entity key -- **THEN** the entity key is `C0123/T456` -- **AND** the command is routed to the session actor for that key - -#### Scenario: Timer message routed by task and run identity - -- **GIVEN** a timer fires for task `ebay-check` at timestamp `1708531200` -- **WHEN** the session parent extracts the entity key -- **THEN** the entity key is `schedule/ebay-check/1708531200` -- **AND** a new session actor is created for that entity key - -#### Scenario: TUI message routed by session identity - -- **GIVEN** a TUI message arrives with session ID `a1b2c3` -- **WHEN** the session parent extracts the entity key -- **THEN** the entity key is `tui/a1b2c3` -- **AND** the command is routed to the session actor for that key - -#### Scenario: Repeated messages in same thread route to same actor - -- **GIVEN** a session actor exists for entity key `C0123/T456` -- **WHEN** another message arrives in the same Slack thread -- **THEN** the message is routed to the existing session actor -- **AND** no new session actor is created - -#### Scenario: Repeated TUI messages route to same actor - -- **GIVEN** a session actor exists for entity key `tui/a1b2c3` -- **WHEN** the operator sends another message in the same chat session -- **THEN** the message is routed to the existing session actor - -### Requirement: Broadcast subscription for reply delivery - -Input adapters SHALL subscribe to session broadcast events to deliver replies -back through the originating channel. Adapters SHALL consume broadcast events -through pub/sub without direct transport coupling to session actors. - -#### Scenario: Slack adapter receives reply broadcast - -- **GIVEN** the Slack adapter is subscribed to session broadcasts -- **WHEN** a session actor emits a turn broadcast with a reply -- **THEN** the Slack adapter receives the broadcast -- **AND** delivers the reply to the originating Slack thread - -#### Scenario: Timer result broadcast consumed by Slack adapter - -- **GIVEN** a scheduled task session completes with results -- **WHEN** the session emits a result broadcast -- **THEN** the Slack adapter receives the broadcast -- **AND** posts the results to the task's configured reporting channel - -#### Scenario: Multiple adapters can subscribe to same session - -- **GIVEN** both a Slack adapter and a future UI adapter are running -- **WHEN** a session emits a broadcast -- **THEN** both adapters receive the broadcast independently -- **AND** each adapter delivers through its own channel - -### Requirement: Slack Socket Mode adapter - -The Slack adapter SHALL connect via Slack Socket Mode, handle `app_mention` -events, dispatch `SendUserMessage` commands to the session parent, and -deliver reply broadcasts back to the originating Slack thread. - -#### Scenario: Socket Mode connection established at startup - -- **GIVEN** valid Slack app and bot tokens are configured -- **WHEN** Netclaw starts -- **THEN** the Slack adapter opens a Socket Mode connection -- **AND** reports connection health in operator diagnostics - -#### Scenario: App mention event dispatched as session command - -- **GIVEN** the Slack adapter is connected -- **WHEN** an `app_mention` event is received from an allowed channel -- **THEN** the adapter extracts entity key `{channelId}/{threadTs}` -- **AND** creates a `SendUserMessage` with the message text, entity key, and - Slack source metadata -- **AND** routes the command to the session parent actor - -#### Scenario: Reply delivered to originating thread - -- **GIVEN** a session processes a turn from a Slack message -- **WHEN** the session emits a reply broadcast -- **THEN** the Slack adapter posts the reply in the same thread -- **AND** uses the Slack bot token for the API call - -#### Scenario: Socket Mode reconnects on disconnect - -- **GIVEN** the Slack Socket Mode connection drops -- **WHEN** the adapter detects the disconnection -- **THEN** the adapter attempts to reconnect -- **AND** logs the disconnection and reconnection events - -### Requirement: Internal timer adapter - -The timer adapter SHALL fire on Akka timer ticks for scheduled tasks and -deliver the task instruction to a session. The entity key and delivery path -SHALL depend on the reminder's mode: - -- **Mode A** (external notification — `ReportToChannel` set, - `SessionId = null`): the entity key SHALL be `schedule/{taskId}/{runTs}` - and each timer fire SHALL create a fresh isolated session via the existing - `ISessionPipeline.CreateAsync` path. -- **Mode B** (session check-back — `SessionId` set, - `ReportToChannel = null`): the entity key SHALL be the persisted - `SessionId` and the timer fire SHALL re-enter the existing session - actor (rehydrating from Akka.Persistence if currently passivated), NOT - create a new session. Mode B delivery SHALL route through the - originating channel's existing inbound actor hierarchy by telling the - appropriate gateway a `DeliverTrustedSessionTurn` message: - `ChannelType.Slack` → `SlackGatewayActor`; `ChannelType.Tui` or - `ChannelType.SignalR` → `SignalRGatewayActor`. Each gateway routes the - message down its existing hierarchy using the same lookup-or-create - logic it uses for inbound events; `Forward` preserves `Sender` - (the reminder dispatcher's `Ask` temp actor) down the chain. - Any other `OriginChannelType` is rejected at `set_reminder` time — Mode - B requires a gateway that implements the `DeliverTrustedSessionTurn` - handler. - -In both Mode A and Mode B, the dispatched `SendUserMessage` SHALL carry -a `MessageSource` whose `ReminderId` field is populated with -`{reminderId}:{fireTimestampMs}` for idempotent best-effort redelivery -dedup at the session. - -#### Scenario: Timer fires for active Mode A scheduled task - -- **GIVEN** an active Mode A scheduled task has a timer registered -- **WHEN** the Akka timer fires -- **THEN** the timer adapter creates a `SendUserMessage` command -- **AND** the message content is the task's instruction prompt -- **AND** the entity key is `schedule/{taskId}/{runTs}` -- **AND** `MessageSource.ReminderId` equals `{taskId}:{runTsMs}` - -#### Scenario: Fresh session created per Mode A timer execution - -- **GIVEN** a Mode A timer fires for task `daily-report` -- **WHEN** the timer adapter dispatches the command -- **THEN** a new session actor is created for the unique - `schedule/daily-report/{runTs}` entity key -- **AND** the session loads the agent personality from soul files -- **AND** the session does not reuse any previous execution's state - -#### Scenario: Mode B Slack reminder routes through existing gateway chain - -- **GIVEN** a Mode B reminder persists `SessionId = "C0123ABC/1712000000.000000"` - and `OriginChannelType = Slack` -- **WHEN** the Akka timer fires -- **THEN** the reminder dispatcher `Ask`s `SlackGatewayActor` - a `DeliverTrustedSessionTurn` message -- **AND** `SlackGatewayActor`'s handler parses the `SessionId` into - `(channelId, threadTs)` and uses - `Context.Child(channelId).GetOrElse(...)` — the same pattern its - existing `SlackInboundMessage` handler uses — to reach or create the - conversation actor -- **AND** `conversation.Forward(msg)` preserves `Sender` -- **AND** `SlackConversationActor`'s handler uses the same lookup - pattern to reach or create the thread binding actor -- **AND** `binding.Forward(msg)` preserves `Sender` -- **AND** `SlackThreadBindingActor`'s handler reads `Sender` and offers - a `ChannelInput` (with `MessageSource.AckTarget = Sender`) into the - pipeline queue -- **AND** the pipeline delivers a `SendUserMessage` to the existing - `LlmSessionActor` for that session -- **AND** NO new session actor with a `schedule/...` entity key is created - -#### Scenario: Mode B SignalR reminder routes through SignalR gateway - -- **GIVEN** a Mode B reminder persists `SessionId = "signalr/abc123"` and - `OriginChannelType = Tui` (or `SignalR`) -- **WHEN** the Akka timer fires -- **THEN** the reminder dispatcher `Ask`s `SignalRGatewayActor` - a `DeliverTrustedSessionTurn` message -- **AND** `SignalRMessageExtractor.EntityId` matches the message via its - `IWithSessionId` fallback and extracts the session ID -- **AND** `GenericChildPerEntityParent` routes the message to the - existing (or newly-created) `SignalRSessionActor` for that session -- **AND** the session actor's handler reads `Sender` and offers a - `ChannelInput` (with `MessageSource.AckTarget = Sender`) into the - pipeline queue -- **AND** NO new session actor with a `schedule/...` entity key is created -- **AND** if a SignalR client is connected, it receives the streaming - response in real time; otherwise the turn persists and is visible on - next `ResumeSessionAsync` - -#### Scenario: Timer adapter does not fire for paused tasks - -- **GIVEN** a scheduled task is in `paused` status -- **WHEN** the system checks for timer scheduling -- **THEN** no timer is registered for the paused task -- **AND** no `SendUserMessage` command is produced - -### Requirement: TUI input adapter - -The TUI adapter SHALL receive keyboard input via Termina TextInputNode, produce -`SendUserMessage` commands with entity key `tui/{sessionId}`, subscribe to -session broadcasts, and render responses as streaming text. The TUI adapter -SHALL be a Phase 1 input source. - -#### Scenario: TUI adapter produces SendUserMessage - -- **GIVEN** the operator is in a `netclaw chat` session -- **WHEN** the operator types a message and presses Enter -- **THEN** the TUI adapter produces a `SendUserMessage` command -- **AND** the command contains the message content, entity key `tui/{sessionId}`, - and source metadata with adapter type `tui` - -#### Scenario: TUI adapter renders streaming response - -- **GIVEN** a session actor is processing a turn from the TUI adapter -- **WHEN** the session emits token-level broadcast events -- **THEN** the TUI adapter renders tokens in real-time via StreamingTextNode -- **AND** the response appears incrementally in the chat history - -#### Scenario: TUI adapter displays tool invocation status - -- **GIVEN** a session is executing tool calls -- **WHEN** a tool invocation starts -- **THEN** the TUI adapter displays an inline tool activity panel -- **AND** shows the tool name with a spinner indicator -- **WHEN** the tool invocation completes -- **THEN** the spinner is replaced with a checkmark and duration - -#### Scenario: TUI adapter subscribes to session broadcasts - -- **GIVEN** the TUI adapter has sent a `SendUserMessage` command -- **WHEN** the session actor emits a `TurnBroadcast` event -- **THEN** the TUI adapter receives the broadcast -- **AND** renders the response content in the chat history - -#### Scenario: TUI source metadata populated - -- **GIVEN** the operator sends a message via `netclaw chat` -- **WHEN** the TUI adapter creates the `SendUserMessage` command -- **THEN** the source metadata includes adapter type `tui` -- **AND** includes `local-operator` as sender identity -- **AND** includes the session ID as channel identifier -- **AND** includes the current timestamp - -### Requirement: Channel-agnostic thread history fetcher contract - -The channel abstraction layer SHALL define an `IThreadHistoryFetcher` interface -that returns an ordered `IReadOnlyList` for a given `SessionId`. -Each channel adapter that supports threaded conversations MAY implement this -interface as an optional capability. Adapters that do not support threads -(e.g., timer, TUI) SHALL NOT implement it. The `ChannelInput` contract SHALL -NOT carry a backfill-related flag — hydration is an adapter-internal concern -and the session layer SHALL be unaware of whether history was merged into an -inbound message. - -#### Scenario: Fetcher returns chronologically ordered channel inputs - -- **GIVEN** a threaded channel adapter implements `IThreadHistoryFetcher` -- **WHEN** `FetchThreadHistoryAsync(sessionId, ct)` is invoked -- **THEN** the returned list contains `ChannelInput` items in chronological - order (oldest first) -- **AND** the return type contains no channel-specific types - -#### Scenario: Non-threaded adapters do not implement history fetch - -- **GIVEN** a timer adapter or TUI adapter -- **WHEN** the adapter is registered in DI -- **THEN** no `IThreadHistoryFetcher` implementation is registered for that - adapter -- **AND** no hydration logic runs for messages it emits - -#### Scenario: Session layer is unaware of hydration - -- **GIVEN** a `ChannelInput` produced by a threaded adapter after hydration -- **WHEN** the channel pipeline transforms it into a `SendUserMessage` -- **THEN** the resulting command carries no backfill flag -- **AND** the session actor processes it as a normal user turn +## ADDED Requirements ### Requirement: Channel interactive approval capability @@ -491,240 +29,29 @@ messages back to the session actor. value - **AND** `ToolAccessPolicy` can use it to determine approval behavior -### Requirement: Fallback text rendering for basic channels +### Requirement: Text rendering for approval-capable basic channels -Channels that support interactive approval but lack rich UI SHALL render -approval prompts as numbered text option lists and parse user responses by -option number or keyword matching. This covers future SMS or plain-text -adapters. +Channels that support interactive approval but use text interactions SHALL +render approval prompts as numbered or lettered text option lists and parse +user responses by option number, letter, or keyword matching. -#### Scenario: Text-only channel renders ABC options +#### Scenario: Text-only channel renders ABCD options - **GIVEN** a channel with interactive approval support but no rich UI - **WHEN** a `ToolInteractionRequest` is received -- **THEN** the channel posts a text-based approval prompt with labeled options +- **THEN** the channel posts: + ``` + I'd like to run: git push origin main + Reply with: + A) Approve once + B) Approve for this chat + C) Approve always + D) Deny + ``` - **AND** user replies "A", "a", or "approve once" are accepted #### Scenario: Text-only channel routes parsed response - **GIVEN** the user replies "B" to an approval prompt - **WHEN** the channel parses the reply -- **THEN** it sends a `ToolInteractionResponse` with `ApprovedAlways` - -### Requirement: ChannelInput / MessageSource ack target propagation - -`MessageSource` SHALL expose an optional `AckTarget` field of type -`IActorRef?`. `MessageSource` is explicitly non-persisted (marked -`[ProtoIgnore]` on `SendUserMessage.Source`), so adding a runtime -`IActorRef` is safe. `ChannelPipeline.MapToCommand`'s stream sink SHALL -propagate this value as the `Tell` sender when dispatching the resulting -`SendUserMessage` to the session manager. When `AckTarget` is null, the -sink SHALL use `ActorRefs.NoSender` exactly as today, preserving -fire-and-forget semantics for regular user-message ingress. - -This extension exists so that trusted deliveries (e.g., Mode B reminders) -can receive the session's existing `CommandAck` reply without the session -actor or the pipeline needing to special-case reminder messages. The -session's existing `TryReplyAck()` helper replies to `Sender`, which is -the `AckTarget` actor for trusted deliveries and `NoSender` for regular -user messages. - -#### Scenario: Regular inbound message preserves fire-and-forget semantics - -- **GIVEN** a Slack user sends a message in an active thread -- **WHEN** the pipeline maps the `ChannelInput` (whose - `Source.AckTarget = null`) to `SendUserMessage` -- **THEN** the session manager is told with `ActorRefs.NoSender` -- **AND** the session's `TryReplyAck()` call goes to `DeadLetters` - (the existing no-op behavior — the helper checks for `IsNobody()`) - -#### Scenario: Trusted delivery receives CommandAck via AckTarget - -- **GIVEN** a reminder dispatcher's `Ask` reaches a channel - gateway's `DeliverTrustedSessionTurn` handler -- **AND** the handler forwards the message down to the leaf binding/ - session actor, which builds a `ChannelInput` with - `MessageSource.AckTarget = Sender` (the dispatcher's Ask temp actor) -- **AND** the pipeline stream sink Tells the session manager using - `cmd.Source.AckTarget` as the sender -- **WHEN** the session's `HandleIncomingUserMessage` runs and fires - `TryReplyAck()` -- **THEN** the session Tells `Sender` (the Ask temp actor) a - `CommandAck` -- **AND** the dispatcher's `Ask` completes successfully - -### Requirement: Trusted session turn delivery protocol - -The shared protocol message `DeliverTrustedSessionTurn` SHALL be defined -in `Netclaw.Actors.Protocol` with the following shape: - -``` -DeliverTrustedSessionTurn( - SessionId SessionId, - string Content, - MessageSource Source) : IWithSessionId -``` - -Every server-side channel gateway in the daemon that supports Mode B -reminder re-entry SHALL register a `Receive` -handler that mirrors the gateway's existing inbound-routing logic — the -same lookup-or-create pattern used to route real inbound events down to -the leaf binding/session actor. The handler SHALL parse `SessionId` -into channel-specific addressing, SHALL use `Context.Child(name) -.GetOrElse(...)` (or the equivalent `GenericChildPerEntityParent` -routing path) to reach the next actor in the hierarchy, and SHALL -`Forward(msg)` the message down to preserve the original `Sender`. The -channel-level inbound ACL check (e.g., -`SlackAclPolicy.EvaluateInbound`) SHALL NOT be called from this handler -— the two message types (inbound event and trusted delivery) have -separate handlers with separate logic, so no shared code path exists -where a flag could accidentally leak the bypass. - -At the leaf actor (`SlackThreadBindingActor` for Slack, -`SignalRSessionActor` for SignalR), the handler SHALL read `Sender` -(the Ask temp actor, preserved via the `Forward` chain) and build a -`ChannelInput` carrying the reminder `Content`, the supplied -`MessageSource` (with `ReminderId`, trusted provenance, and stored -audience), and `MessageSource.AckTarget = Sender`. It SHALL offer the -`ChannelInput` to the pipeline queue via `inputQueue.OfferAsync(...)`. -On non-`Enqueued` offer result, the leaf actor SHALL Tell `Sender` a -`CommandNack` directly so the reminder dispatcher's Ask can complete -with failure. - -The daemon currently hosts two gateways that MUST implement this -handler chain: - -- **`SlackGatewayActor`** (three-level hierarchy: gateway → - `SlackConversationActor` → `SlackThreadBindingActor`). Each level - gets its own `Receive` handler. The - gateway-level handler parses `{channelId}/{threadTs}` from the - `SessionId`, looks up or creates the conversation by channel ID, and - forwards. The conversation-level handler looks up or creates the - thread binding by thread TS, and forwards. The binding-level handler - offers the `ChannelInput` to the pipeline. - -- **`SignalRGatewayActor`** (flat hierarchy via - `GenericChildPerEntityParent` + `SignalRMessageExtractor`). - `SignalRMessageExtractor.EntityId` SHALL be extended with an - `IWithSessionId` fallback so the shared `DeliverTrustedSessionTurn` - message (which implements `IWithSessionId`) is routable by the - extractor without needing to implement the channel-internal - `ISignalRSessionMessage` interface. `ISignalRSessionMessage` remains - `internal` — no upstream dependency leak. `SignalRSessionActor` gets - one new `Receive` handler that offers the - `ChannelInput` to its pipeline. - -#### Scenario: Slack gateway handles DeliverTrustedSessionTurn - -- **GIVEN** a Mode B reminder fires for - `SessionId = "C0123ABC/1712000000.000000"` -- **WHEN** `SlackGatewayActor` receives a `DeliverTrustedSessionTurn` - with that `SessionId`, the reminder prompt, and a `MessageSource` - whose `Principal = VerifiedAutomation` and - `Provenance.SourceKind = "reminder"` -- **THEN** the gateway parses the `SessionId` into - `(channelId, threadTs)` and looks up or creates the conversation - actor using `Context.Child(channelId).GetOrElse(...)` -- **AND** the gateway Forwards the message to the conversation -- **AND** the conversation looks up or creates the thread binding - using the same pattern and Forwards -- **AND** the thread binding reads `Sender`, constructs a - `ChannelInput` with `MessageSource.AckTarget = Sender`, and offers - it to the pipeline queue -- **AND** `SlackAclPolicy.EvaluateInbound` is NOT invoked - -#### Scenario: SignalR gateway handles DeliverTrustedSessionTurn - -- **GIVEN** a Mode B reminder fires for `SessionId = "signalr/abc123"` -- **WHEN** `SignalRGatewayActor` receives a - `DeliverTrustedSessionTurn` with that `SessionId` -- **THEN** `SignalRMessageExtractor.EntityId` returns `"signalr/abc123"` - via its `IWithSessionId` fallback -- **AND** `GenericChildPerEntityParent` routes the message to the - `SignalRSessionActor` for that session (creating one if needed) -- **AND** the session actor reads `Sender`, constructs a `ChannelInput` - with `MessageSource.AckTarget = Sender`, and offers it to the - pipeline queue -- **AND** if a SignalR client is currently connected, the streaming - response reaches the client in real time via the existing - `SignalRSessionActor` → `SessionHub` bridge -- **AND** if no client is currently connected, the session still - processes the turn and persists `TurnRecorded`; streaming output is - dropped per the existing `OverflowStrategy.DropHead` behavior; the - execution actor still receives `CommandAck` because `TryReplyAck` - fires regardless of subscribers - -#### Scenario: Concurrent inbound and trusted delivery produce a single binding - -- **GIVEN** a real inbound event and a Mode B reminder - `DeliverTrustedSessionTurn` arrive at the same gateway in parallel, - both targeting the same session addressing -- **WHEN** both handlers run concurrently -- **THEN** exactly one conversation/session actor chain exists for the - session (the existing `Context.Child(name).GetOrElse(...)` lookup is - idempotent under actor supervision) -- **AND** both messages are successfully queued into the same pipeline - in arrival order - -#### Scenario: Gateway rejects on pipeline queue backpressure - -- **GIVEN** a Mode B `DeliverTrustedSessionTurn` reaches the leaf - binding/session actor -- **WHEN** `inputQueue.OfferAsync(channelInput)` returns a - non-`Enqueued` result (e.g., `Dropped`, `Failure`, `QueueClosed`) -- **THEN** the leaf actor Tells `Sender` (the Ask temp actor) a - `CommandNack` directly -- **AND** the reminder dispatcher's `Ask` completes with - `CommandNack` -- **AND** the reminder execution actor does NOT call - `_client.AckAsync(envelope)` -- **AND** Akka.Reminders redelivers the envelope per its policy - -### Requirement: SignalR message extractor IWithSessionId fallback - -`SignalRMessageExtractor` SHALL extend its `EntityId` implementation to -fall through to `IWithSessionId.SessionId.Value` when a message does not -implement the channel-internal `ISignalRSessionMessage` interface. This -allows upstream protocol messages (such as `DeliverTrustedSessionTurn`) -that implement `IWithSessionId` to be routed through the SignalR -gateway's `GenericChildPerEntityParent` without needing to leak -`ISignalRSessionMessage` as a public interface. - -```csharp -public override string? EntityId(object message) => message switch -{ - ISignalRSessionMessage msg => msg.SessionId.Value, - IWithSessionId wid => wid.SessionId.Value, - _ => null -}; -``` - -The existing `ISignalRSessionMessage` match SHALL continue to fire -first so that channel-internal routing messages are unchanged. -`ISignalRSessionMessage` SHALL remain `internal`. - -#### Scenario: Internal SignalR message routes via ISignalRSessionMessage - -- **GIVEN** a `StartSignalRSession` message (implements - `ISignalRSessionMessage`) arrives at `SignalRGatewayActor` -- **WHEN** `SignalRMessageExtractor.EntityId` inspects the message -- **THEN** the first pattern matches and returns `msg.SessionId.Value` -- **AND** routing proceeds as today - -#### Scenario: Upstream protocol message routes via IWithSessionId fallback - -- **GIVEN** a `DeliverTrustedSessionTurn` message (implements - `IWithSessionId` but not `ISignalRSessionMessage`) arrives at - `SignalRGatewayActor` -- **WHEN** `SignalRMessageExtractor.EntityId` inspects the message -- **THEN** the second pattern matches and returns `wid.SessionId.Value` -- **AND** `GenericChildPerEntityParent` routes the message to the - matching `SignalRSessionActor` child - -#### Scenario: Unroutable message returns null - -- **GIVEN** a message that implements neither `ISignalRSessionMessage` - nor `IWithSessionId` -- **WHEN** `SignalRMessageExtractor.EntityId` inspects it -- **THEN** `EntityId` returns `null` -- **AND** `GenericChildPerEntityParent` does not route the message +- **THEN** it sends a `ToolInteractionResponse` with `ApprovedSession` diff --git a/openspec/specs/netclaw-mcp/spec.md b/openspec/specs/netclaw-mcp/spec.md index 008866949..952bb4e5a 100644 --- a/openspec/specs/netclaw-mcp/spec.md +++ b/openspec/specs/netclaw-mcp/spec.md @@ -1,267 +1,34 @@ -# netclaw-mcp Specification - -Research: `docs/research/dynamic-context-discovery.md` - -## Purpose - -Define MCP server integration, validation, policy enforcement, and diagnostics. - -## Requirements - -### Requirement: MCP server profile configuration - -The system SHALL support named MCP server profiles in configuration. Each -profile SHALL specify a transport type (`stdio` or `SSE`), the command or URL -for the server, and optional environment variables to pass to the server -process. - -#### Scenario: Disabled by default - -- **WHEN** no MCP profile is enabled -- **THEN** no MCP tools are loaded - -#### Scenario: Stdio transport profile - -- **GIVEN** an MCP profile is configured with transport type `stdio` -- **WHEN** the profile is loaded -- **THEN** the system launches the server using the configured command -- **AND** communicates via stdio transport - -#### Scenario: SSE transport profile - -- **GIVEN** an MCP profile is configured with transport type `SSE` -- **WHEN** the profile is loaded -- **THEN** the system connects to the configured URL via SSE transport - -#### Scenario: Environment variables passed to server - -- **GIVEN** an MCP profile specifies environment variables -- **WHEN** the server process is launched (stdio transport) -- **THEN** the configured environment variables are set in the server process - environment - -### Requirement: MCP validation - -The system SHALL validate MCP server connectivity and discovery. - -#### Scenario: Validate server - -- **WHEN** operator runs MCP validation -- **THEN** output indicates handshake status and discovered tool count - -### Requirement: Policy-gated MCP invocation - -The system SHALL apply ACL and grants before invoking MCP tools. - -#### Scenario: Missing grant denies MCP tool - -- **WHEN** an MCP tool is requested without grant -- **THEN** invocation is denied with a policy reason code - -### Requirement: MCP diagnostics visibility - -The system SHALL expose MCP server health in diagnostics. - -#### Scenario: Server becomes unavailable - -- **WHEN** a configured MCP server is unreachable -- **THEN** diagnostics mark it degraded or unavailable with last error timestamp - -#### Scenario: Daemon reports MCP auth failure - -- **GIVEN** the daemon can reach the MCP server but authentication is rejected on the live runtime path -- **WHEN** the operator runs `netclaw mcp list` or `netclaw doctor` -- **THEN** the CLI reports `auth failed` -- **AND** remediation points to `netclaw mcp auth ` when OAuth is in use - -#### Scenario: Doctor cannot verify OAuth auth offline - -- **GIVEN** an HTTP/SSE MCP server uses OAuth -- **AND** the daemon is unavailable -- **WHEN** the operator runs `netclaw doctor` -- **THEN** doctor may report offline connectivity evidence -- **BUT** it SHALL not claim the server is unauthorized unless the daemon runtime path has verified that auth failure - -### Requirement: Memorizer as external memory tier - -The Memorizer MCP server SHALL be the recommended first MCP server for Netclaw -deployments. Memorizer provides `store`, `search`, `get`, `delete`, and -`create_relationship` operations for persisting research findings and -cross-session learning. Memorizer is an external memory tier complementing -first-party local memory (personality, project registry, environment inventory). - -#### Scenario: Store research finding via Memorizer - -- **GIVEN** the `memorizer` MCP server is configured and reachable -- **AND** the session has `mcp:memorizer` grant -- **WHEN** the agent stores a research finding -- **THEN** the finding is persisted in Memorizer and retrievable in future - sessions - -#### Scenario: Search across sessions via Memorizer - -- **GIVEN** prior sessions have stored findings in Memorizer -- **WHEN** the agent searches Memorizer for a topic -- **THEN** relevant findings from prior sessions are returned - -### Requirement: Tool discovery and registration - -On startup, the system SHALL discover tools from all enabled MCP server -profiles and register them as Microsoft.Extensions.AI (MEAI) tool definitions. -Tool discovery SHALL refresh on each session start to pick up newly added or -removed tools from MCP servers. - -To avoid context window bloat with large tool catalogs (see -`docs/research/dynamic-context-discovery.md` §1–2), the system SHALL use a -three-step discovery strategy: a compressed tool index injected into the system -prompt for agent awareness, a `search_tools` meta-tool for browsing available -tools (names and descriptions only), and a `load_tool` meta-tool for -on-demand activation of individual tool definitions. `search_tools` SHALL NOT -load tool schemas into the session — it SHALL return a discovery menu only. -The agent SHALL call `load_tool` to activate each tool it needs. Core tools -(shell, file operations) SHALL remain always-loaded; MCP tools SHALL be -deferred by default. - -When an LLM call fails after tools have been dynamically loaded, the system -SHALL evict all discovered tools from the session's available tool set. This -prevents a tool set that caused the failure (e.g., oversized schemas) from -poisoning subsequent turns. - -#### Scenario: Startup tool discovery - -- **GIVEN** two MCP servers are enabled with a combined total of 5 tools -- **WHEN** the system starts -- **THEN** all 5 tools are discovered and registered as MEAI tool definitions - -#### Scenario: Session-start tool refresh - -- **GIVEN** an MCP server has added a new tool since last session start -- **WHEN** a new session actor initializes -- **THEN** the refreshed tool list includes the newly added tool - -### Requirement: Graceful degradation - -Tool calls to unavailable MCP servers SHALL return a clear error message to the -agent. The agent SHALL continue operating with remaining available tools. The -system SHALL attempt reconnection on the next tool call to a previously -unavailable server. - -#### Scenario: Unavailable server returns clear error - -- **GIVEN** a configured MCP server is unreachable -- **WHEN** the agent invokes a tool from that server -- **THEN** a clear error is returned indicating the server is unavailable -- **AND** the agent continues the conversation with remaining tools - -#### Scenario: Reconnection on next call - -- **GIVEN** an MCP server was previously unreachable -- **WHEN** the agent invokes a tool from that server again -- **THEN** the system attempts reconnection before returning an error - -#### Scenario: Partial server availability - -- **GIVEN** two MCP servers are configured and one is unreachable -- **WHEN** a session initializes -- **THEN** tools from the reachable server are available -- **AND** tools from the unreachable server are marked as unavailable - -### Requirement: Per-tool audience filtering for MCP servers - -The system SHALL support per-server tool allowlists on each audience profile -via `McpServerToolGrants`. When a server has an entry in the grants dictionary, -only tools whose bare name appears in the list SHALL be exposed to that -audience. When a server has no entry (or grants is null), all tools from that -server SHALL be exposed (backward-compatible default). - -Tool grants compose with the existing `AllowedMcpServers` gate: a tool must -pass both the server-level check AND the per-tool grant check to be exposed. - -#### Scenario: Tool granted to audience is exposed - -- **GIVEN** audience profile has `McpServerToolGrants: { "memorizer": ["search_memories", "get"] }` -- **AND** the `memorizer` server is in `AllowedMcpServers` -- **WHEN** the session resolves available tools for this audience -- **THEN** `memorizer/search_memories` and `memorizer/get` are exposed -- **AND** other tools from `memorizer` are not exposed - -#### Scenario: No grants for server exposes all tools - -- **GIVEN** audience profile has `McpServerToolGrants` that does not contain a `memorizer` entry -- **AND** the `memorizer` server is in `AllowedMcpServers` -- **WHEN** the session resolves available tools for this audience -- **THEN** all tools from `memorizer` are exposed - -#### Scenario: Null grants exposes all tools from allowed servers - -- **GIVEN** audience profile has `McpServerToolGrants: null` -- **AND** servers are allowed via `AllowedMcpServers` or `McpServersMode: All` -- **WHEN** the session resolves available tools -- **THEN** all tools from all allowed servers are exposed - -#### Scenario: Empty tool list blocks all tools from server - -- **GIVEN** audience profile has `McpServerToolGrants: { "memorizer": [] }` -- **AND** the `memorizer` server is in `AllowedMcpServers` -- **WHEN** the session resolves available tools -- **THEN** no tools from `memorizer` are exposed - -#### Scenario: Server not in AllowedMcpServers is blocked regardless of grants - -- **GIVEN** audience profile has `McpServerToolGrants: { "memorizer": ["search_memories"] }` -- **BUT** `memorizer` is NOT in `AllowedMcpServers` and `McpServersMode` is `Allowlist` -- **WHEN** the session resolves available tools -- **THEN** no tools from `memorizer` are exposed - -#### Scenario: Different audiences see different tools from same server - -- **GIVEN** Team profile has `McpServerToolGrants: { "memorizer": ["search_memories", "get"] }` -- **AND** Personal profile has `McpServersMode: All` with no `McpServerToolGrants` -- **WHEN** a Team session resolves tools -- **THEN** only `search_memories` and `get` are exposed -- **AND** when a Personal session resolves tools, all `memorizer` tools are exposed +## MODIFIED Requirements ### Requirement: Tool grant enforcement in search_tools -Tools blocked by `McpServerToolGrants` SHALL NOT appear in `search_tools` -results for the requesting session's audience. The compressed tool index -injected into system prompts SHALL also reflect per-tool grant filtering. - -#### Scenario: Blocked tool absent from search_tools - -- **GIVEN** Team profile grants only `["search_memories"]` from `memorizer` -- **WHEN** a Team session calls `search_tools` with query matching `store` -- **THEN** `memorizer/store` does NOT appear in results - -#### Scenario: Blocked tool absent from load_tool - -- **GIVEN** Team profile grants only `["search_memories"]` from `memorizer` -- **WHEN** a Team session calls `load_tool` for `memorizer/store` -- **THEN** the tool is denied with a policy reason - -### Requirement: Tool change detection logging - -At MCP server connect time, the system SHALL compare discovered tools against -tool grants configured across all audience profiles. The system SHALL log -warnings for tools that appear on the server but are not granted to any -audience, and for granted tool names that do not exist on the server. +`search_tools` and `load_tool` SHALL enforce the same effective audience and +feature gates as direct MCP tool exposure. A session MUST NOT be able to use +these discovery/load paths to enumerate or activate tools that are blocked by +deployment-wide runtime switches, audience allowlists, or per-server per-tool +grants. -#### Scenario: New tool discovered but not granted +#### Scenario: Public session cannot discover blocked MCP capabilities -- **GIVEN** `memorizer` server exposes tools `[search_memories, store, get, archive]` -- **AND** across all audience profiles, only `[search_memories, store, get]` are granted -- **WHEN** the daemon connects to `memorizer` -- **THEN** a warning is logged identifying `archive` as discovered but not granted to any audience +- **GIVEN** a session has audience `Public` +- **AND** Public does not have access to a given MCP server or tool +- **WHEN** the session calls `search_tools` +- **THEN** blocked servers and tools do not appear in results +- **AND** the response does not reveal hidden tool names for blocked internals -#### Scenario: Granted tool not found on server +#### Scenario: Public session cannot activate blocked MCP tool through load_tool -- **GIVEN** Team profile grants `["search_memories", "old_tool"]` from `memorizer` -- **AND** `memorizer` does not expose a tool named `old_tool` -- **WHEN** the daemon connects to `memorizer` -- **THEN** a warning is logged identifying `old_tool` as granted but not found on the server +- **GIVEN** a session has audience `Public` +- **AND** the requested MCP tool is not exposed to Public +- **WHEN** the session calls `load_tool` +- **THEN** the tool is not activated +- **AND** the result follows the generic denied/not-found path without leaking + hidden capability inventory -#### Scenario: No grants configured skips change detection +#### Scenario: Disabled subsystem hides discovery inventory for all audiences -- **GIVEN** no audience profile has `McpServerToolGrants` entries for `memorizer` -- **WHEN** the daemon connects to `memorizer` -- **THEN** no tool change detection warnings are logged for that server +- **GIVEN** a deployment-wide feature switch disables the relevant MCP-backed + subsystem +- **WHEN** a Team session calls `search_tools` +- **THEN** tools from that disabled subsystem are absent from discovery results +- **AND** `load_tool` cannot activate them diff --git a/openspec/specs/netclaw-onboarding/spec.md b/openspec/specs/netclaw-onboarding/spec.md index 18c892521..466cf4d03 100644 --- a/openspec/specs/netclaw-onboarding/spec.md +++ b/openspec/specs/netclaw-onboarding/spec.md @@ -1,43 +1,4 @@ -# netclaw-onboarding Specification - -## Purpose - -Define first-run and resumable onboarding experience for Netclaw operators. - -## Requirements - -### Requirement: Stepwise setup wizard - -The system SHALL guide operators through setup steps with validation at each -step. - -#### Scenario: Step progression - -- **WHEN** operator completes a step successfully -- **THEN** onboarding advances to the next step - -### Requirement: Secret-safe input handling - -The system SHALL avoid echoing sensitive credentials in plain text output. - -#### Scenario: Entering provider key - -- **WHEN** operator enters a provider API key -- **THEN** the input is masked and not logged in clear text - -### Requirement: Security warnings for internet-reachable modes - -The system SHALL show explicit warnings before enabling internet-reachable -exposure modes. `Public` deployment posture remains a channel-audience term, -not an anonymous network-access term. Audience selection and exposure-mode -selection are independent choices: audience controls chat participants, while -exposure mode controls daemon network reachability. - -#### Scenario: Enable funnel mode - -- **WHEN** operator selects `tailscale-funnel` -- **THEN** onboarding requires explicit confirmation and validation that remote - access is restricted to authenticated users +## MODIFIED Requirements ### Requirement: Guided onboarding @@ -47,290 +8,35 @@ search backend, browser automation, memory provider selection, MCP server configuration, and exposure mode selection. On completion, the wizard SHALL run a health check to verify the baseline configuration is functional. +The wizard SHALL NOT write `AGENTS.md` to disk during identity file +generation. AGENTS.md is binary-controlled firmware loaded from embedded +resources at runtime. The wizard SHALL continue to write `SOUL.md` and +`TOOLING.md` as operator-mutable identity files. + +For non-Personal postures, the wizard SHALL also present a Feature Selection +step that writes deployment-wide `Enabled` switches. These switches SHALL NOT +implicitly rewrite Public audience allowlists. + #### Scenario: First-time setup - **WHEN** operator runs `netclaw init` on a fresh install - **THEN** guided setup collects provider, Slack, ACL, search, browser automation, memory, and exposure mode inputs - **AND** writes a runnable baseline configuration +- **AND** writes SOUL.md and TOOLING.md to `~/.netclaw/identity/` +- **AND** does NOT write AGENTS.md (or writes a reference-only stub) -#### Scenario: MCP server configured during init - -- **WHEN** onboarding reaches the MCP step -- **THEN** the wizard prompts for at least one MCP server profile (Memorizer - recommended) -- **AND** validates server handshake before proceeding - -#### Scenario: Exposure mode selected during init - -- **WHEN** onboarding reaches the exposure step -- **THEN** the wizard presents available exposure modes (local, tailscale-serve, - tailscale-funnel, cloudflare-tunnel) -- **AND** applies security warnings for internet-reachable modes - -#### Scenario: Health check on completion - -- **WHEN** onboarding completes all steps -- **THEN** the wizard runs a health check covering Slack connectivity, provider - validation, memory backend reachability (if Memorizer), and MCP server - reachability -- **AND** reports pass/fail/degraded for each component - -#### Scenario: Health check reports degraded Memorizer - -- **GIVEN** the operator configured `Memory.Provider = "memorizer"` -- **WHEN** the health check runs -- **AND** the Memorizer MCP server is unreachable -- **THEN** the health check reports a warning (degraded, not failed) -- **AND** displays "Memorizer unreachable — memory will use local files" - -### Requirement: Phase 2 conversational personality bootstrap - -The system SHALL trigger a conversational personality bootstrap on the first -`netclaw chat` session if personality files (PERSONALITY.md, INSTRUCTIONS.md, -USER.md) do not exist. The bootstrap conversation SHALL ask the operator about -communication preferences, tone, name preferences, and working style, then -write the resulting soul files to the standard config directory. - -#### Scenario: First conversation triggers bootstrap - -- **GIVEN** no personality files exist in the config directory -- **WHEN** the operator starts their first `netclaw chat` session -- **THEN** the agent initiates a personality bootstrap conversation -- **AND** asks about communication preferences and working style - -#### Scenario: Bootstrap writes soul files - -- **GIVEN** the personality bootstrap conversation is complete -- **WHEN** the operator has answered all preference questions -- **THEN** the system writes PERSONALITY.md, INSTRUCTIONS.md, and USER.md to - the config directory - -#### Scenario: Bootstrap skipped when files exist - -- **GIVEN** personality files already exist in the config directory -- **WHEN** a new conversation starts -- **THEN** no personality bootstrap is triggered -- **AND** the existing personality files are loaded normally - -### Requirement: Environment discovery during onboarding - -The system SHALL scan for installed tools and host capabilities as part of -Phase 2 onboarding. Discovery results SHALL be persisted to the environment -inventory file for use in session context and capability self-awareness. - -#### Scenario: Tool discovery during onboarding - -- **WHEN** Phase 2 onboarding runs environment discovery -- **THEN** the system scans for installed tools (git, gh, claude, opencode, - dotnet, node) -- **AND** checks git credential status -- **AND** writes results to the environment inventory file - -#### Scenario: MCP server reachability check during onboarding - -- **GIVEN** MCP servers are configured -- **WHEN** Phase 2 onboarding runs environment discovery -- **THEN** the system checks reachability of each configured MCP server -- **AND** records reachability status in the environment inventory - -### Requirement: Project registration during onboarding - -The system SHALL ask the operator about repositories to register as part of -Phase 2 onboarding. Registered projects are added to the project registry -with their paths, capabilities, and AGENTS.md locations. - -#### Scenario: Register projects during onboarding - -- **WHEN** Phase 2 onboarding reaches the project registration step -- **THEN** the system asks the operator about repositories to register -- **AND** scans provided paths for AGENTS.md files - -#### Scenario: Skip project registration - -- **WHEN** Phase 2 onboarding reaches the project registration step -- **AND** the operator indicates no projects to register -- **THEN** onboarding proceeds with an empty project registry - -### Requirement: Memory provider selection during onboarding - -The init wizard SHALL include a Memory step (step 6, after BrowserAutomation) -that allows operators to choose between "Local files" (default) and -"Memorizer" as the cross-session memory backend. The step SHALL always render -and SHALL NOT be conditionally skipped. `TotalSteps` SHALL be 9. - -#### Scenario: Operator selects local files - -- **WHEN** the wizard reaches the Memory step -- **AND** the operator selects "Local files (default)" -- **THEN** the wizard writes `"Memory": { "Provider": "files" }` to - `netclaw.json` -- **AND** advances to the next step without further substeps - -#### Scenario: Operator selects Memorizer - -- **WHEN** the wizard reaches the Memory step -- **AND** the operator selects "Memorizer" -- **THEN** the wizard advances to the Memorizer connection substep - -#### Scenario: Default selection is local files - -- **WHEN** the wizard reaches the Memory step -- **THEN** "Local files (default)" is pre-selected - -### Requirement: Memorizer MCP connection configuration - -When the operator selects Memorizer, the wizard SHALL collect MCP server -connection details: transport type (stdio or http) and the corresponding -connection parameters (URL for http, command + arguments for stdio). The -wizard SHALL write both `Memory.Provider` and a `McpServers.memorizer` entry -to `netclaw.json`. - -#### Scenario: Configure HTTP transport - -- **GIVEN** the operator selected Memorizer -- **WHEN** the wizard reaches the connection substep -- **AND** the operator selects "HTTP" transport and enters a URL -- **THEN** the wizard writes `"McpServers": { "memorizer": { "Transport": "http", "Url": "", "Enabled": true } }` - -#### Scenario: Configure stdio transport - -- **GIVEN** the operator selected Memorizer -- **WHEN** the wizard reaches the connection substep -- **AND** the operator selects "stdio" transport and enters command + arguments -- **THEN** the wizard writes the corresponding stdio MCP server entry - -### Requirement: Memorizer connectivity validation during onboarding - -After collecting Memorizer connection details, the wizard SHALL probe the -configured endpoint to validate connectivity. The probe SHALL use a 10-second -timeout. On failure, the wizard SHALL offer retry or fallback to local files. - -#### Scenario: Successful connectivity probe - -- **GIVEN** the operator entered Memorizer connection details -- **WHEN** the wizard probes the endpoint -- **AND** the endpoint responds within 10 seconds -- **THEN** the wizard shows a success message -- **AND** advances to the next step - -#### Scenario: Failed connectivity probe with retry - -- **GIVEN** the operator entered Memorizer connection details -- **WHEN** the wizard probes the endpoint -- **AND** the endpoint does not respond within 10 seconds -- **THEN** the wizard shows the error -- **AND** offers "Retry" or "Fall back to local files" - -#### Scenario: Fallback to local files after probe failure - -- **GIVEN** the Memorizer connectivity probe failed -- **WHEN** the operator selects "Fall back to local files" -- **THEN** the wizard sets `Memory.Provider` to `"files"` -- **AND** removes the `McpServers.memorizer` entry -- **AND** advances to the next step - -### Requirement: TUI wizard delivery mechanism - -The `netclaw init` onboarding wizard SHALL be delivered through Termina TUI -as an interactive 9-step wizard with progress indication, validation, and -back-navigation. - -#### Scenario: Wizard renders in TUI - -- **WHEN** operator runs `netclaw init` -- **THEN** a Termina TUI application launches -- **AND** the wizard displays step progress (e.g., "Step 2 of 9") -- **AND** the wizard displays a progress bar - -#### Scenario: Step-specific components rendered - -- **GIVEN** the wizard is on a step requiring text input -- **WHEN** the step is displayed -- **THEN** the wizard renders TextInputNode components for text/secret fields -- **AND** renders SelectionListNode components for choice fields - -#### Scenario: Back navigation between steps - -- **GIVEN** the wizard is on step 3 -- **WHEN** the operator presses Esc -- **THEN** the wizard navigates back to step 2 -- **AND** previous input values are preserved - -#### Scenario: Live validation during wizard - -- **GIVEN** the wizard is on the Memory step with Memorizer selected -- **WHEN** the operator enters connection details -- **THEN** the wizard validates connectivity with a SpinnerNode -- **AND** displays success or failure before allowing progression - - -## ADDED Requirements - -### Requirement: Phase 2 conversational personality bootstrap - -The system SHALL trigger a conversational personality bootstrap on the first -conversation if personality files (PERSONALITY.md, INSTRUCTIONS.md, USER.md) -do not exist. The bootstrap conversation SHALL ask the operator about -communication preferences, tone, name preferences, and working style, then -write the resulting soul files to the standard config directory. - -#### Scenario: First conversation triggers bootstrap - -- **GIVEN** no personality files exist in the config directory -- **WHEN** the operator starts their first conversation with Netclaw -- **THEN** the agent initiates a personality bootstrap conversation -- **AND** asks about communication preferences and working style - -#### Scenario: Bootstrap writes soul files - -- **GIVEN** the personality bootstrap conversation is complete -- **WHEN** the operator has answered all preference questions -- **THEN** the system writes PERSONALITY.md, INSTRUCTIONS.md, and USER.md to - the config directory - -#### Scenario: Bootstrap skipped when files exist - -- **GIVEN** personality files already exist in the config directory -- **WHEN** a new conversation starts -- **THEN** no personality bootstrap is triggered -- **AND** the existing personality files are loaded normally - -### Requirement: Environment discovery during onboarding - -The system SHALL scan for installed tools and host capabilities as part of -Phase 2 onboarding. Discovery results SHALL be persisted to the environment -inventory file for use in session context and capability self-awareness. - -#### Scenario: Tool discovery during onboarding - -- **WHEN** Phase 2 onboarding runs environment discovery -- **THEN** the system scans for installed tools (git, gh, claude, opencode, - dotnet, node) -- **AND** checks git credential status -- **AND** writes results to the environment inventory file - -#### Scenario: MCP server reachability check during onboarding - -- **GIVEN** MCP servers are configured -- **WHEN** Phase 2 onboarding runs environment discovery -- **THEN** the system checks reachability of each configured MCP server -- **AND** records reachability status in the environment inventory - -### Requirement: Project registration during onboarding - -The system SHALL ask the operator about repositories to register as part of -Phase 2 onboarding. Registered projects are added to the project registry -with their paths, capabilities, and AGENTS.md locations. - -#### Scenario: Register projects during onboarding +#### Scenario: Identity files written on completion -- **WHEN** Phase 2 onboarding reaches the project registration step -- **THEN** the system asks the operator about repositories to register -- **AND** scans provided paths for AGENTS.md files +- **WHEN** the wizard completes and writes config +- **THEN** `SOUL.md` is written from the embedded SOUL template +- **AND** `TOOLING.md` is written from the embedded TOOLING template +- **AND** `AGENTS.md` is NOT written from a template -#### Scenario: Skip project registration +#### Scenario: Public posture defaults search off without mutating Public tool allowlist -- **WHEN** Phase 2 onboarding reaches the project registration step -- **AND** the operator indicates no projects to register -- **THEN** onboarding proceeds with an empty project registry +- **GIVEN** the operator selected Public posture +- **WHEN** the Feature Selection step is shown +- **THEN** Search defaults to disabled +- **AND** enabling Search there affects only the deployment-wide runtime switch +- **AND** `Tools.AudienceProfiles.Public.AllowedTools` is not implicitly widened diff --git a/openspec/specs/netclaw-scheduling/spec.md b/openspec/specs/netclaw-scheduling/spec.md index c26658793..d65d7a087 100644 --- a/openspec/specs/netclaw-scheduling/spec.md +++ b/openspec/specs/netclaw-scheduling/spec.md @@ -1,865 +1,66 @@ -# netclaw-scheduling Specification +## MODIFIED Requirements -## Purpose +### Requirement: Scheduling runtime config -Define chat-driven scheduled task creation, persistence, isolated execution -via Akka timers, result reporting, task management, and failure handling -guardrails. This capability enables Netclaw to manage its own schedule -through conversation and execute tasks autonomously. +The system SHALL define a top-level `Scheduling` config section whose only +property in this change is `Enabled`. This section governs reminder/scheduled +execution runtime only and SHALL NOT be interpreted as a background-job shell +execution toggle. -## Requirements +#### Scenario: Scheduling config contains only Enabled -### Requirement: Chat-driven task creation - -The agent SHALL create scheduled tasks when the user requests recurring or -timed actions through conversation. The agent SHALL assign a human-readable -task ID and confirm the schedule. Tasks SHALL support fixed interval and cron -expression schedule types. Tasks requesting tool grants that cannot be -satisfied by ACL policy SHALL be rejected at creation time. - -Reminder definitions minted through conversation, tool calls, CLI, REST, or -import SHALL persist an execution audience that is less than or equal to the -creator's current source audience / authority. For conversational or tool- -created reminders, omitted `audience` SHALL inherit the audience of the -creating channel/session rather than the deployment default. Lowering audience -is always allowed. - -#### Scenario: Create interval-based scheduled task - -- **GIVEN** the user asks the agent to perform an action on a recurring basis -- **WHEN** the agent parses the request as a fixed-interval schedule -- **THEN** the agent creates a task with the specified interval -- **AND** assigns a human-readable task ID -- **AND** confirms the schedule, next run time, and required tool grants - -#### Scenario: Create cron-based scheduled task - -- **GIVEN** the user specifies a cron expression for scheduling -- **WHEN** the agent validates the cron expression -- **THEN** the agent creates a task with the cron schedule -- **AND** confirms the resolved next execution time - -#### Scenario: Reject task with ungrantable tools - -- **GIVEN** the user requests a scheduled task that requires the `shell` tool -- **WHEN** the `shell` grant is not available in the ACL policy for that sender -- **THEN** the agent rejects the task at creation time -- **AND** explains which tool grants are missing - -#### Scenario: Task ID collision avoided +- **WHEN** scheduling config is written to `netclaw.json` +- **THEN** it appears as a top-level `Scheduling` object +- **AND** `Enabled` is the only property introduced by this change -- **GIVEN** a task with ID `ebay-check` already exists -- **WHEN** the user requests a new task that would generate the same ID -- **THEN** the agent generates a unique variant of the ID -- **AND** confirms the actual task ID assigned - -#### Scenario: Omitted conversational audience inherits source audience - -- **GIVEN** a reminder is created from a Team-audience Slack session -- **AND** the request omits `audience` -- **WHEN** the reminder is persisted -- **THEN** the stored reminder audience is `Team` -- **AND** execution does not fall back to the deployment default later - -#### Scenario: Lower audience override allowed - -- **GIVEN** a reminder is created from a Personal-audience session -- **WHEN** the creator explicitly sets `audience` to `Team` -- **THEN** the reminder is accepted -- **AND** the stored reminder audience is `Team` - -#### Scenario: Broader audience override rejected - -- **GIVEN** a reminder is created from a Team-audience session -- **WHEN** the creator explicitly sets `audience` to `Personal` -- **THEN** the reminder is rejected before persistence -- **AND** the error explains that the requested audience exceeds the creator's current authority - -### Requirement: Schedule persistence - -Scheduled tasks SHALL be persisted to disk at -`~/.netclaw/schedules/tasks.json` and SHALL survive process restarts. On -startup, the system SHALL load persisted tasks and re-establish Akka timers -for all active tasks. - -#### Scenario: Tasks survive process restart +### Requirement: Chat-driven task creation -- **GIVEN** active scheduled tasks exist in `tasks.json` -- **WHEN** the Netclaw process restarts -- **THEN** all persisted tasks are loaded from disk -- **AND** Akka timers are re-established for active tasks -- **AND** paused tasks remain paused +Scheduling SHALL be controlled by both a deployment-wide runtime switch and +audience/tool allowlists. `Scheduling.Enabled = false` disables reminder +scheduling for all audiences. When runtime-enabled, Public sessions still +require explicit allowlist exposure before they may create, inspect, or mutate +reminders. -#### Scenario: New task persisted immediately +#### Scenario: Scheduling runtime-disabled blocks reminder creation -- **GIVEN** the user creates a new scheduled task through conversation -- **WHEN** the task is confirmed -- **THEN** the task is written to `tasks.json` before the confirmation is sent +- **GIVEN** `Scheduling.Enabled` is `false` in config +- **WHEN** a Team session attempts to create a reminder or schedule +- **THEN** the scheduling tools are absent or denied +- **AND** no reminder definition is persisted -#### Scenario: Corrupted tasks file handled gracefully +#### Scenario: Public scheduling remains blocked without explicit allowlist -- **GIVEN** `tasks.json` contains invalid JSON -- **WHEN** the Netclaw process starts -- **THEN** the system logs a warning -- **AND** starts without any scheduled tasks -- **AND** the operator is notified of the corruption +- **GIVEN** `Scheduling.Enabled` is `true` in config +- **AND** a session has audience `Public` +- **AND** Public does not have the necessary scheduling exposure/grants +- **WHEN** the session attempts to create or inspect a reminder +- **THEN** the scheduling tools are absent or denied ### Requirement: Isolated task execution -Each scheduled task execution SHALL run in either a **fresh isolated session** -(Mode A — external notification) or **re-enter the originating session** -(Mode B — session check-back), determined at set time by whether the -reminder carries an explicit `ReportToChannel`. Mode A sessions SHALL load the -agent personality and project context overlays and SHALL NOT share state with -interactive sessions. Mode B executions SHALL reuse the persisted state of -the originating session actor (via Akka.Persistence rehydration if the -session has passivated) and SHALL NOT create a new session. - -Execution MAY trust the stored reminder audience because reminder minting and -import paths SHALL validate the persisted audience before the definition is -saved. In both modes, the effective audience at execution time SHALL be the -stored reminder audience, not the live audience of the originating session. - -#### Scenario: Fresh session per Mode A execution - -- **GIVEN** a reminder persisted with `ReportToChannel` set and `SessionId = null` -- **WHEN** the timer tick triggers execution -- **THEN** a new session actor is created with entity key - `schedule/{taskId}/{runTs}` -- **AND** the task instruction is delivered as the user message -- **AND** agent personality is loaded from soul files - -#### Scenario: Session re-entry per Mode B execution - -- **GIVEN** a reminder persisted with `SessionId` set and `ReportToChannel = null` -- **WHEN** the timer tick triggers execution -- **THEN** the existing session actor for the persisted `SessionId` is - addressed (rehydrating from Akka.Persistence if currently passivated) -- **AND** NO new session actor is created with a `schedule/...` entity key -- **AND** the reminder turn is delivered as a `SendUserMessage` whose - `MessageSource.ChannelType` matches the stored `OriginChannelType` - -#### Scenario: Scheduled session isolated from interactive sessions - -- **GIVEN** an interactive Slack session exists for the same user -- **WHEN** a Mode A scheduled task executes -- **THEN** the scheduled session does not read or modify interactive session - state -- **AND** the interactive session does not see scheduled session turns - -#### Scenario: Task tool grants applied to session - -- **GIVEN** a scheduled task specifies `tool_grants: ["web_search", "web_fetch"]` -- **WHEN** the task session starts -- **THEN** only the granted tools are available to the session -- **AND** ungrantable tools are not offered to the LLM - -#### Scenario: Execution uses validated stored audience - -- **GIVEN** a reminder definition was accepted with stored audience `Public` -- **WHEN** the reminder later executes on schedule -- **THEN** the execution session uses stored audience `Public` -- **AND** it does not recompute audience from the deployment posture default - -### Requirement: Result reporting - -Task execution results SHALL be delivered according to the execution mode. - -- **Mode A** (external notification): results SHALL be posted to the - notification target stored on the reminder definition. Notification targets - SHALL always be canonical identifiers produced by - `IReminderTargetResolver` (never raw LLM-supplied strings). -- **Mode B** (session check-back): the reminder turn is routed through the - originating channel's existing inbound handling path. The daemon hosts - two server-side gateways, both of which implement a - `Receive` handler that reuses the gateway's - existing routing code. The reminder dispatcher SHALL tell the appropriate - gateway based on `OriginChannelType`: `ChannelType.Slack` → - `SlackGatewayActor`; `ChannelType.Tui` or `ChannelType.SignalR` → - `SignalRGatewayActor`. The channel-level inbound ACL SHALL be bypassed - because the reminder's audience is already validated at minting time. - Any other `OriginChannelType` SHALL be rejected at `set_reminder` time. - -Both modes SHALL support a silent-unless-notable mode where routine results -are suppressed and only notable findings are posted (Mode A) or delivered as -a new turn (Mode B). - -#### Scenario: Mode A results posted to configured channel - -- **GIVEN** a scheduled task has `report_to.channel` configured with a - canonical channel ID -- **WHEN** the task execution completes with results -- **THEN** the results are posted to the configured Slack channel via the - reminder's isolated execution session - -#### Scenario: Mode B Slack delivery routes through existing gateway chain - -- **GIVEN** a Mode B reminder created from a Slack thread session with - `OriginChannelType = Slack` -- **WHEN** the reminder fires -- **THEN** the reminder dispatcher `Ask`s `SlackGatewayActor` - with a `DeliverTrustedSessionTurn` message carrying the originating - `SessionId`, reminder prompt, and trusted `MessageSource` -- **AND** the gateway's handler parses the `SessionId` into - `(channelId, threadTs)` and uses its existing - `Context.Child(name).GetOrElse(...)` lookup to reach the conversation - actor -- **AND** `conversation.Forward(msg)` preserves `Sender` -- **AND** `SlackConversationActor`'s handler uses the same lookup pattern - to reach the thread binding actor -- **AND** `binding.Forward(msg)` preserves `Sender` -- **AND** `SlackThreadBindingActor`'s handler reads `Sender`, builds a - `ChannelInput` with `MessageSource.AckTarget = Sender`, and offers it to - the pipeline queue -- **AND** the reminder turn is delivered through the normal `ChannelInput` - → `ChannelPipeline` → `SendUserMessage` → session pipeline -- **AND** the session's streaming response is posted back to the original - Slack thread via the binding's existing output sink -- **AND** `SlackAclPolicy.EvaluateInbound` is NOT called - -#### Scenario: Mode B SignalR delivery routes through existing gateway chain - -- **GIVEN** a Mode B reminder created from a SignalR session (including - TUI) with `OriginChannelType` = `Tui` or `SignalR` and `SessionId` = - `signalr/{guid}` -- **WHEN** the reminder fires -- **THEN** the reminder dispatcher `Ask`s `SignalRGatewayActor` - with a `DeliverTrustedSessionTurn` message -- **AND** `SignalRMessageExtractor.EntityId` matches the message via its - `IWithSessionId` fallback and extracts the session GUID -- **AND** `GenericChildPerEntityParent` routes the message to the existing - `SignalRSessionActor` child for that session (creating one if needed) -- **AND** `SignalRSessionActor`'s handler reads `Sender`, builds a - `ChannelInput` with `MessageSource.AckTarget = Sender`, and offers it to - the pipeline queue -- **AND** if a SignalR client is currently connected, the streaming - response reaches the client in real time via the existing bridge -- **AND** if no client is currently connected, the session still processes - the turn and persists `TurnRecorded`; streaming output is dropped per - the existing `OverflowStrategy.DropHead` behavior and is visible on - next `ResumeSessionAsync` - -#### Scenario: Silent-unless-notable suppresses routine results - -- **GIVEN** a scheduled task is configured with silent-unless-notable mode -- **WHEN** the task execution completes with no notable findings -- **THEN** no message is posted (Mode A) or delivered as a turn (Mode B) -- **AND** the execution is logged as completed with no notable output - -#### Scenario: Notable results always posted - -- **GIVEN** a scheduled task is configured with silent-unless-notable mode -- **WHEN** the task execution produces notable findings -- **THEN** the results are delivered via the mode-appropriate path -- **AND** the findings are clearly presented - -### Requirement: Reminder notification target validation - -The `set_reminder` tool SHALL validate any LLM-supplied `reportToChannel` -value through a transport-agnostic `IReminderTargetResolver` abstraction -before persisting the reminder definition. Validation SHALL accept -human-readable Slack handles (`#channel-name`, `@username`) and raw Slack -identifiers, and SHALL persist the resolver's canonical identifier — never -the raw LLM input. Unresolvable targets SHALL cause the tool invocation to -fail immediately with an error message the LLM can act on. When no -notification channel transport is registered in the host and the LLM -supplies a `reportToChannel`, the tool SHALL fail loudly with a -"no notification channel transport configured" error rather than silently -deferring the failure to reminder execution time. - -When the LLM does **not** supply `reportToChannel`, the tool SHALL NOT -synthesize one by splitting the calling `context.SessionId`. Instead, if -`context.SessionId` is present, the tool SHALL persist the reminder in -**Mode B** with `SessionId = context.SessionId`, `OriginChannelType = -context.ChannelType`, and `ReportToChannel = null`. The tool SHALL -reject Mode B at set time if `context.ChannelType` is not `Slack`, `Tui`, -or `SignalR` — these are the only channel types with gateways that -support `DeliverTrustedSessionTurn`. If neither an explicit -`reportToChannel` nor an addressable `context.SessionId` is available, -the reminder SHALL be persisted with both fields null (headless execution). - -#### Scenario: Hash-prefixed channel name resolved to canonical ID - -- **GIVEN** a host with a registered `IReminderTargetResolver` that maps - `#general` to channel ID `C0123ABC` -- **WHEN** the LLM calls `set_reminder` with `reportToChannel: "#general"` -- **THEN** the persisted `ReminderDefinition.ReportToChannel` equals - `C0123ABC` -- **AND** `SessionId` is null (Mode A) -- **AND** the tool response reports success with the resolved schedule - -#### Scenario: User handle resolved to canonical user ID - -- **GIVEN** a host with a registered resolver that maps `@aaronontheweb` to - user ID `U0456XYZ` -- **WHEN** the LLM calls `set_reminder` with `reportToChannel: "@aaronontheweb"` -- **THEN** the persisted `ReminderDefinition.ReportToChannel` equals - `U0456XYZ` -- **AND** default notify instructions direct the agent to send a direct - message to that resolved user ID - -#### Scenario: Raw channel ID passes through without an API call - -- **GIVEN** a host with a registered resolver -- **WHEN** the LLM calls `set_reminder` with `reportToChannel: "C0123ABC"` -- **THEN** the persisted `ReminderDefinition.ReportToChannel` equals - `C0123ABC` -- **AND** no directory lookup against the channel transport is performed - -#### Scenario: Unresolvable target returns actionable tool error - -- **GIVEN** a host with a registered resolver that cannot resolve - `#nonexistent-channel` -- **WHEN** the LLM calls `set_reminder` with - `reportToChannel: "#nonexistent-channel"` -- **THEN** the tool returns an error string beginning with - `Error: Could not resolve reportToChannel` -- **AND** no `ReminderDefinition` is persisted -- **AND** no `SaveReminderCommand` is sent to the reminder manager actor - -#### Scenario: No channel transport configured rejects supplied target - -- **GIVEN** a host with no `IReminderTargetResolver` registered in DI -- **WHEN** the LLM calls `set_reminder` with any non-empty `reportToChannel` -- **THEN** the tool returns an error string containing - `No notification channel transport is configured` -- **AND** no `ReminderDefinition` is persisted - -#### Scenario: Mode B — session check-back without explicit channel - -- **GIVEN** a tool execution context with `SessionId = "C0123ABC/1234567890.123456"` - and `ChannelType = Slack` -- **WHEN** the LLM calls `set_reminder` without supplying `reportToChannel` -- **THEN** the persisted `ReminderDefinition.SessionId` equals - `C0123ABC/1234567890.123456` -- **AND** `ReminderDefinition.OriginChannelType` equals `Slack` -- **AND** `ReminderDefinition.ReportToChannel` is null -- **AND** `ReminderDefinition.ReportToThreadTs` is null -- **AND** the resolver is NOT invoked -- **AND** default notify instructions direct the agent to reply in the - originating session - -#### Scenario: Mode B rejected for unsupported origin channel types - -- **GIVEN** a tool execution context with `ChannelType = Headless` (or - `Webhook`, `Reminder`) and a non-null `SessionId` -- **WHEN** the LLM calls `set_reminder` without supplying `reportToChannel` -- **THEN** the tool returns an error string explaining that Mode B is - only supported for channels with a `DeliverTrustedSessionTurn` gateway - (Slack, Tui, SignalR) -- **AND** no `ReminderDefinition` is persisted - -#### Scenario: Headless configuration with no supplied target continues to work - -- **GIVEN** a host with no `IReminderTargetResolver` registered -- **WHEN** the LLM calls `set_reminder` without supplying `reportToChannel` - and without an addressable `context.SessionId` -- **THEN** the reminder is persisted with `ReportToChannel = null` and - `SessionId = null` -- **AND** the tool returns success - -### Requirement: Task management - -The agent and CLI SHALL support listing, pausing, resuming, and deleting -scheduled tasks. The agent SHALL provide task status and next-run time -when listing tasks. - -#### Scenario: List all scheduled tasks via conversation - -- **GIVEN** multiple scheduled tasks exist -- **WHEN** the user asks to see scheduled tasks -- **THEN** the agent lists all tasks with ID, name, status, schedule, and - next run time - -#### Scenario: Pause a scheduled task - -- **GIVEN** an active scheduled task exists -- **WHEN** the user asks the agent to pause the task -- **THEN** the task status is set to paused -- **AND** the Akka timer for the task is cancelled -- **AND** the task remains in `tasks.json` with `status: "paused"` - -#### Scenario: Resume a paused task - -- **GIVEN** a paused scheduled task exists -- **WHEN** the user asks the agent to resume the task -- **THEN** the task status is set to active -- **AND** the Akka timer is re-established -- **AND** the next run time is calculated from the current time - -#### Scenario: Delete a scheduled task - -- **GIVEN** a scheduled task exists -- **WHEN** the user asks the agent to delete the task -- **THEN** the task is removed from `tasks.json` -- **AND** the Akka timer is cancelled -- **AND** the agent confirms deletion - -#### Scenario: Manage tasks via CLI - -- **GIVEN** active scheduled tasks exist -- **WHEN** the operator runs CLI commands for schedule management -- **THEN** the CLI supports list, pause, resume, and delete operations -- **AND** changes are reflected in `tasks.json` - -### Requirement: Failure handling and guardrails - -Netclaw's reminder manager SHALL track consecutive execution failures per -reminder via `_failureCounts` and SHALL auto-pause a reminder when the -count reaches an internal `FailurePauseThreshold` constant. A successful -execution SHALL reset the failure count to zero. Paused reminders SHALL -remain persisted with `status: "paused"` and SHALL be visible via -`netclaw reminders list`. - -`FailurePauseThreshold` is not operator-configurable — it lives as an -`internal const` on `ReminderManagerActor`. `Akka.Reminders` applies its -own separate retry budget (`MaxDeliveryAttempts`, library default) to -envelope delivery; Netclaw's auto-pause threshold is set strictly below -the library's default so the Netclaw-side pause fires first in practice -and operators see a `paused` reminder in `netclaw reminders list` before -the library would mark an occurrence terminally failed. If either -default changes in a way that breaks this ordering, add back a single -operator knob. - -The reminder manager SHALL enforce a maximum concurrent execution limit -(`MaxConcurrentExecutions`, internal const) and SHALL enforce a -per-execution timeout (`ExecutionTimeoutSeconds`, internal const on -`ReminderExecutionActor`). - -#### Scenario: Consecutive failures auto-pause task - -- **GIVEN** a scheduled task has failed N times in a row where N equals - `FailurePauseThreshold` -- **WHEN** the Nth failure is reported to `ReminderManagerActor` -- **THEN** the task status is set to `paused` -- **AND** the Akka timer for the task is cancelled -- **AND** a log event is emitted naming the reminder and the failure count -- **AND** the reminder remains in `tasks.json` with `status: "paused"` - -#### Scenario: Successful execution resets failure counter - -- **GIVEN** a scheduled task has failed twice -- **WHEN** the next execution succeeds -- **THEN** the internal failure count for that reminder is reset to zero -- **AND** subsequent failures start counting from zero again - -#### Scenario: Max concurrent execution limit enforced - -- **GIVEN** `MaxConcurrentExecutions` is reached and that many reminders are currently executing -- **WHEN** another reminder fires -- **THEN** the new reminder is deferred to an internal queue -- **AND** the Akka.Reminders envelope is still acked (both Mode A and - Mode B deferred paths — a reminder that can't be dispatched yet is - acked and the library's retry/auto-pause machinery covers starvation) - -#### Scenario: Execution timeout enforced - -- **GIVEN** a reminder execution exceeds the per-execution timeout -- **WHEN** the timeout fires -- **THEN** the execution is cancelled and reported as a failure -- **AND** the failure is counted toward `FailurePauseThreshold` - -### Requirement: Execution history CLI command - -The CLI SHALL provide a `netclaw reminder history ` subcommand that -reads and displays the execution history for a given reminder. The command -SHALL accept an optional `--last N` flag (default: 20) to limit the number -of records shown. Output SHALL be formatted as a table with columns: -`fired_at`, `status`, `duration`, `session_id`. If no history file exists -for the given ID, the command SHALL print a clear "no history recorded" -message and exit with code 0. - -#### Scenario: History displayed for a reminder with records - -- **WHEN** the operator runs `netclaw reminder history daily-summary` -- **THEN** the most recent 20 execution records are shown as a table -- **AND** each row includes fired_at (UTC), success/failure status, - duration in ms, and the session ID - -#### Scenario: Limit applied with --last flag - -- **WHEN** the operator runs `netclaw reminder history daily-summary --last 5` -- **THEN** only the 5 most recent records are shown - -#### Scenario: No history file returns graceful message - -- **WHEN** the operator runs `netclaw reminder history new-reminder` - and no history file exists for `new-reminder` -- **THEN** the command prints "No execution history recorded for new-reminder" -- **AND** exits with code 0 - -#### Scenario: Unknown reminder ID returns error - -- **WHEN** the operator runs `netclaw reminder history nonexistent-id` - and no reminder definition exists for that ID -- **THEN** the command exits with a non-zero code and a clear error message - -### Requirement: get_reminder_history agent tool - -The system SHALL provide a `get_reminder_history` tool requiring the -`scheduling` grant. The tool SHALL accept a `reminder_id` parameter and an -optional `last` parameter (default: 20, max: 100). The tool SHALL return a -structured list of execution records enabling the agent to assess job health -inline. If no history exists, the tool SHALL return an empty list. - -#### Scenario: Agent queries recent executions - -- **GIVEN** the agent holds the `scheduling` grant -- **WHEN** the agent calls `get_reminder_history` with `reminder_id: "daily-summary"` -- **THEN** the tool returns up to 20 recent execution records -- **AND** each record includes firedAt, success, durationMs, sessionId, - and errorMessage - -#### Scenario: Agent enforces max record count - -- **WHEN** the agent calls `get_reminder_history` with `last: 200` -- **THEN** the tool returns at most 100 records - -#### Scenario: Tool rejected without scheduling grant - -- **GIVEN** the current session does not hold the `scheduling` grant -- **WHEN** the agent attempts to call `get_reminder_history` -- **THEN** the tool call is rejected by the ACL policy -- **AND** the agent receives a permission-denied response - -### Requirement: Envelope-ack-gated at-least-once delivery for Mode B - -For Mode B reminders, the `ReminderManagerActor` SHALL NOT call -`_client.AckAsync(envelope)` eagerly. It SHALL spawn -`ReminderExecutionActor` and pass the `ReminderEnvelope` to the child -explicitly. The execution actor SHALL acquire `IReminderClient` via -`ReminderClientExtension.Get(Context.System)` at startup and SHALL call -`_client.AckAsync(envelope)` itself once the target session has -confirmed receipt. - -The execution actor SHALL dispatch -`DeliverTrustedSessionTurn(SessionId, Content, MessageSource)` to the -target channel gateway using `Ask` (Slack via -`SlackGatewayActor`, SignalR/TUI via `SignalRGatewayActor`, selected by -`OriginChannelType`) with a timeout of -`ReminderSettings.DefaultAckTimeout` (Akka.Reminders' shipped default, -currently 10 seconds — referencing the library constant directly so -Netclaw tracks any future library change automatically). The gateway's -handler SHALL propagate the message down its existing routing hierarchy -via `Forward` (preserving `Sender`), until it reaches the leaf binding/ -session actor, which reads `Sender` and places it on the outgoing -`ChannelInput` as `MessageSource.AckTarget`. -`ChannelPipeline.MapToCommand`'s stream sink SHALL use -`cmd.Source?.AckTarget ?? ActorRefs.NoSender` as the `Tell` sender when -delivering to the session manager. `LlmSessionActor`'s existing -`TryReplyAck()` fires `CommandAck` to that sender, completing the -dispatcher's `Ask`. - -On `CommandAck`, the execution actor SHALL call -`await _client.AckAsync(envelope)`, inspect the -`ReminderAckResponse.ResponseCode`, log on non-`Success`, and tell -`Context.Parent` a `ReminderExecutionCompleted(success=true)` for -bookkeeping. On Ask-timeout, `CommandNack`, or any gateway/transport -exception, the execution actor SHALL NOT call `AckAsync`; it SHALL -tell the parent a `ReminderExecutionCompleted(success=false)` with an -error message. The un-acked envelope SHALL be redelivered by -`Aaron.Akka.Reminders` per its built-in `AckTimeout` and -`MaxDeliveryAttempts` defaults. - -For Mode A reminders, the manager SHALL continue to call -`_client.AckAsync(envelope)` eagerly after spawning the execution actor -as today. - -Redelivery SHALL be best-effort deduped: the target session dedup -pre-checks the reminder's `(reminderId, fireTimestampMs)` pair against -its in-memory `ProcessedReminderIds` set (rebuilt from persisted -`TurnRecorded.SourceReminderId` events on recovery, not serialized to -snapshot) and SHALL reply `CommandAck` without processing a duplicate -when the dedup check hits. Dedup misses (across snapshot recovery -boundaries or after long passivation) result in the reminder being -processed a second time, which is an explicitly accepted tradeoff. - -#### Scenario: Mode B envelope acked by execution child via IReminderClient - -- **GIVEN** a Mode B reminder fires -- **WHEN** the `ReminderManagerActor` receives the envelope -- **THEN** the manager spawns a `ReminderExecutionActor` child and - passes the envelope to it -- **AND** the manager does NOT call `_client.AckAsync(envelope)` itself -- **WHEN** the execution child `Ask`s the target channel - gateway with a `DeliverTrustedSessionTurn` -- **AND** the gateway forwards the message down its routing hierarchy, - each level preserving `Sender` via `Forward` -- **AND** the leaf binding/session actor reads `Sender` and builds a - `ChannelInput` with `MessageSource.AckTarget = Sender` -- **AND** the pipeline stream stage maps the `ChannelInput` to - `SendUserMessage` and tells the session manager using the - `AckTarget` as the `Tell` sender -- **AND** the session's `HandleIncomingUserMessage` fires - `TryReplyAck()`, which replies `CommandAck` to the Ask temp actor -- **THEN** the execution child's `Ask` completes with `CommandAck` -- **AND** the execution child calls `await _client.AckAsync(envelope)` - exactly once -- **AND** the execution child tells `Context.Parent` a - `ReminderExecutionCompleted(success=true)` - -#### Scenario: Session Ask-timeout triggers Akka.Reminders redelivery - -- **GIVEN** a Mode B reminder fires and the target channel gateway has - been dispatched a `DeliverTrustedSessionTurn` -- **AND** the pipeline or session fails to reply `CommandAck` within - `ReminderSettings.DefaultAckTimeout` -- **WHEN** the execution actor's `Ask` times out -- **THEN** the execution actor does NOT call `_client.AckAsync(envelope)` -- **AND** the execution actor tells `Context.Parent` a - `ReminderExecutionCompleted(success=false)` with a timeout error -- **AND** `Aaron.Akka.Reminders` marks the envelope as ack-timed-out - and redelivers it per its built-in `MaxDeliveryAttempts` default - -#### Scenario: Redelivered reminder is deduped on the target session - -- **GIVEN** a Mode B reminder was previously processed by the session - (evidenced by a `TurnRecorded` event whose `SourceReminderId` matches - the reminder's `{reminderId}:{fireTimestampMs}` and is present in - `ProcessedReminderIds`) -- **WHEN** Akka.Reminders redelivers the same envelope after a - transient failure -- **THEN** the session dedup pre-check fires in - `HandleIncomingUserMessage` and `TryReplyAck()` returns `CommandAck` - without re-processing the turn -- **AND** the execution actor calls `_client.AckAsync(envelope)` once, - closing out the redelivery loop - -#### Scenario: Gateway rejects the ChannelInput on backpressure - -- **GIVEN** a Mode B reminder fires and the execution actor has - dispatched `DeliverTrustedSessionTurn` to the channel gateway -- **WHEN** the leaf binding actor's `inputQueue.OfferAsync(channelInput)` - returns a non-`Enqueued` result -- **THEN** the binding Tells `Sender` (the Ask temp actor via `AckTarget` - it would have set) a `CommandNack` directly -- **AND** the execution actor's `Ask` completes with - `CommandNack` -- **AND** the execution actor does NOT call `AckAsync` -- **AND** the envelope is redelivered by Akka.Reminders - -### Requirement: Reminder delivery guarantees - -The Mode B reminder delivery pipeline SHALL provide at-least-once -guarantees from the Akka.Reminders envelope down to the target session's -in-memory `CommandAck` boundary, with an explicitly accepted gap between -session-ack and turn-persist that is subsumed by future work. - -**Guaranteed windows** (at-least-once, dedup-safe or redelivery-safe): - -1. Crash before the channel gateway receives - `DeliverTrustedSessionTurn`: envelope un-acked, Akka.Reminders - redelivers on next fire. -2. Crash between the gateway's `OfferAsync` and the pipeline stream - stage processing the `ChannelInput`: the Ask temp actor never - receives a reply, execution actor's `Ask` times out without calling - `AckAsync`, envelope un-acked, Akka.Reminders redelivers. -3. Crash after session received the message (in-memory state updated) - but before execution actor calls `_client.AckAsync(envelope)`: the - envelope is still un-acked, Akka.Reminders redelivers. On - redelivery, if `TurnRecorded` already persisted, the session's - `ProcessedReminderIds` dedup catches it (best-effort); if not, the - redelivery is processed as a fresh turn (desired retry). -4. Ack message lost in flight between execution actor and the - Akka.Reminders scheduler proxy: Akka.Reminders redelivers on - `AckTimeout`, session dedup likely catches the duplicate. - -**Explicitly NOT guaranteed (accepted tradeoffs)**: - -- **Crash after `_client.AckAsync(envelope)` succeeds but before the - session's LLM turn completes and `TurnRecorded` is persisted.** In - this window the envelope has been acknowledged from Akka.Reminders' - perspective (the scheduler will not redeliver it) but the session - only reached in-memory state and did not write a durable record. On - restart, the reminder is lost. This window spans the entire LLM turn - execution, potentially minutes for tool-heavy reasoning. **This is - the identical failure mode every regular `SendUserMessage` has today** - — Mode B reminders do not introduce a new failure class. Closing - this gap requires a durable ingress queue on `LlmSessionActor`, which - is session-wide work deferred to the drain-on-shutdown follow-up - (issues #403, #419). - -- **Duplicate reminder processing across snapshot recovery boundaries.** - If `LlmSessionActor` is recovered from a snapshot rather than - replaying the full journal, the `ProcessedReminderIds` dedup set - starts empty. A redelivery of a pre-snapshot reminder would then be - processed as a fresh turn. In practice this requires the reminder to - still be within Akka.Reminders' `MaxDeliveryWindow` after a snapshot - has been taken — a narrow timing window. **Accepted tradeoff**: the - LLM itself typically recognizes a duplicate prompt in its recent - context and responds appropriately. Persisting the dedup set to - snapshot was not worth the complexity. - -Operators who need stronger guarantees should track the -drain-on-shutdown follow-up. - -#### Scenario: Crash before gateway offer is safe - -- **GIVEN** a Mode B reminder fires -- **WHEN** the daemon crashes before the channel gateway's - `DeliverTrustedSessionTurn` handler completes its `OfferAsync` -- **THEN** the envelope is un-acked -- **AND** on daemon restart, Akka.Reminders redelivers the envelope -- **AND** the reminder is processed normally - -#### Scenario: Crash between gateway offer and stream stage is safe - -- **GIVEN** a Mode B reminder fires and the channel gateway has - successfully offered a `ChannelInput` to the pipeline queue -- **WHEN** the daemon crashes before the pipeline stream stage processes - the `ChannelInput` and reaches the session actor -- **THEN** the execution actor's `Ask` times out -- **AND** `_client.AckAsync(envelope)` is not called -- **AND** the envelope is un-acked -- **AND** on daemon restart, Akka.Reminders redelivers and the reminder - is processed normally - -#### Scenario: Crash between session in-memory receipt and AckAsync is safe - -- **GIVEN** the session's `HandleIncomingUserMessage` has updated - in-memory state and fired `TryReplyAck()`, but the `CommandAck` has - not yet been processed by the execution actor's Ask -- **WHEN** the daemon crashes before `_client.AckAsync(envelope)` is - called -- **THEN** the envelope is un-acked -- **AND** on daemon restart, Akka.Reminders redelivers -- **AND** if `TurnRecorded` was already persisted by the session before - the crash, the dedup pre-check catches the redelivery (best-effort) -- **AND** if `TurnRecorded` was NOT yet persisted, the redelivered - reminder is processed as a fresh turn (desired retry) - -#### Scenario: Crash after AckAsync but before TurnRecorded loses the reminder (accepted gap) - -- **GIVEN** the execution actor has called - `_client.AckAsync(envelope)` successfully and received a - `ReminderAckResponse(Success)` -- **AND** the session has begun processing the turn but has not yet - persisted `TurnRecorded` -- **WHEN** the daemon crashes -- **THEN** the envelope is acked from Akka.Reminders' perspective and - is NOT redelivered on restart -- **AND** the session recovery replays its journal but finds no - `TurnRecorded` for this reminder -- **AND** the reminder turn is lost -- **AND** this outcome is documented as an explicit accepted tradeoff, - identical to the failure mode every regular `SendUserMessage` has - today, subsumed by the drain-on-shutdown follow-up (issues #403, #419) - -#### Scenario: Duplicate across snapshot recovery is accepted - -- **GIVEN** a Mode B reminder was processed and `TurnRecorded` - persisted -- **AND** a subsequent `SessionSnapshot` was taken -- **AND** the session later recovers from that snapshot (journal - replay skips events before the snapshot) -- **AND** a redelivery of the original reminder arrives via - Akka.Reminders (the envelope was within `MaxDeliveryWindow`) -- **WHEN** the dedup pre-check runs -- **THEN** the set is empty (not populated from the snapshot) and the - redelivery is processed as a fresh turn -- **AND** the LLM may observe the duplicate in its transcript context - and respond appropriately -- **AND** this outcome is documented as an explicit accepted tradeoff - -#### Scenario: Delivery guarantees documented in reminder-set confirmation - -- **GIVEN** a Mode B reminder is successfully set -- **WHEN** the tool returns its success message -- **THEN** the message conveys that the reminder will fire and deliver - a new turn to the originating session - -### Requirement: Recurring reminder expiration - -Recurring reminders (interval and cron) support an optional `ExpiresAt` -timestamp. When a reminder expires, it is soft-disabled — the definition -and history are preserved on disk, but no further executions occur. - -Backwards compatibility: `ExpiresAt` is stored as a nullable -`ExpiresAtMs` field on `ReminderDefinition`. Existing definitions -without this field deserialize as `null` (no expiration), preserving -current behavior. - -#### Scenario: Expired interval reminder auto-disabled on fire - -- **GIVEN** an enabled interval reminder with `ExpiresAt` in the past -- **WHEN** Akka.Reminders fires the envelope -- **THEN** the manager disables the reminder without executing it -- **AND** the envelope is acknowledged -- **AND** the definition remains on disk with `Enabled = false` - -#### Scenario: Expired cron reminder auto-disabled on fire - -- **GIVEN** an enabled cron reminder with `ExpiresAt` in the past -- **WHEN** Akka.Reminders fires the envelope -- **THEN** the manager disables the reminder before rescheduling -- **AND** no new cron schedule is created - -#### Scenario: Reconciliation disables expired recurring reminders - -- **GIVEN** the daemon restarts -- **AND** one or more recurring reminders have `ExpiresAt` in the past -- **WHEN** reconciliation runs -- **THEN** each expired reminder is disabled and its schedule cancelled -- **AND** the reconciliation result includes the count of disabled-expired - reminders - -#### Scenario: Non-expired recurring reminder fires normally - -- **GIVEN** an enabled interval reminder with `ExpiresAt` in the future -- **WHEN** the reminder fires -- **THEN** execution proceeds as normal - -#### Scenario: ExpiresIn parameter accepted on set_reminder - -- **GIVEN** a user calls `set_reminder` with `schedule_type = "interval"` - and `expires_in = "24h"` -- **WHEN** the tool processes the request -- **THEN** `ExpiresAt` is computed as `now + 24h` and set on the definition -- **AND** the success response includes the expiration time - -#### Scenario: ExpiresIn rejected on one-shot reminders - -- **GIVEN** a user calls `set_reminder` with `schedule_type = "once"` and - `expires_in = "24h"` -- **WHEN** the tool validates the request -- **THEN** an error is returned: "expires_in is not applicable to one-shot - reminders" - -### Requirement: LLM self-cancellation of fulfilled reminders - -Recurring reminders include prompt guidance telling the executing LLM to -call `cancel_reminder` when the reminder's purpose is permanently -fulfilled. This reuses the existing `cancel_reminder` tool (hard-delete) -rather than introducing a separate completion tool — fewer tools means -less confusion for smaller models. - -#### Scenario: LLM self-cancels a fulfilled recurring reminder - -- **GIVEN** an enabled interval reminder fires and the LLM executes -- **AND** the LLM determines the task is permanently fulfilled -- **WHEN** the LLM calls `cancel_reminder` with the reminder's ID -- **THEN** the reminder and its history are deleted -- **AND** future fires do not execute +Autonomous scheduling/runtime-owned execution SHALL continue using the persisted +originating audience and SHALL NOT widen feature exposure at execution time. -#### Scenario: Prompt guidance includes reminder ID and cancellation instructions +#### Scenario: Scheduled execution does not widen audience after minting -- **GIVEN** a recurring (interval or cron) reminder definition -- **WHEN** the execution actor builds the prompt -- **THEN** the prompt includes guidance to call `cancel_reminder` -- **AND** the guidance includes the reminder's own ID +- **GIVEN** a reminder definition was persisted with audience `Public` +- **WHEN** it later executes on schedule +- **THEN** execution uses the stored audience `Public` +- **AND** it does not gain search, memory, skills, subagents, or other + capabilities that were not exposed to that audience at mint time -### Requirement: Delivery observation timeout alignment +#### Scenario: Disabled scheduling runtime prevents execution of persisted reminders -The `DeliveryObservedTimeout` for Mode B (current_session) delivery must -be aligned with the execution timeout. A delivery observation window -shorter than the execution window causes false failures when LLM turns -take longer than the observation timeout but complete before the execution -timeout. +- **GIVEN** reminder definitions already exist on disk +- **AND** `Scheduling.Enabled` is later set to `false` +- **WHEN** the daemon starts +- **THEN** scheduling runtime paths do not execute those reminders until the + runtime switch is re-enabled -#### Scenario: Delivery observation succeeds for LLM turns taking >30s +#### Scenario: Background jobs are unaffected by Scheduling.Enabled -- **GIVEN** a Mode B reminder with `deliveryRequired = true` -- **AND** the LLM turn takes 45 seconds to produce a delivery -- **WHEN** the delivery is observed at t=45s -- **THEN** the execution completes successfully -- **AND** no failure is recorded +- **GIVEN** `Scheduling.Enabled` is `false` +- **WHEN** a Personal shell tool invocation submits a background job +- **THEN** background-job shell infrastructure follows its existing shell/ + background-job policy +- **AND** it is not disabled solely by `Scheduling.Enabled` diff --git a/openspec/specs/netclaw-session/spec.md b/openspec/specs/netclaw-session/spec.md index bcae374b2..c874dc17d 100644 --- a/openspec/specs/netclaw-session/spec.md +++ b/openspec/specs/netclaw-session/spec.md @@ -1,23 +1,4 @@ -# netclaw-session Specification - -## Purpose - -Define session identity, turn lifecycle, persistence recovery, subscriber -model, context management, and compaction behavior. - -Research: `docs/research/context-management-patterns.md` - -## Requirements - -### Requirement: Slack thread session identity - -The system SHALL key each session by `{channelId}/{threadTs}`. - -#### Scenario: Route repeated thread messages to same actor - -- **GIVEN** a thread session key already exists -- **WHEN** a new message arrives in the same thread -- **THEN** the same session actor handles the turn +## MODIFIED Requirements ### Requirement: Persisted turn lifecycle @@ -38,41 +19,9 @@ The session actor SHALL create an `IApprovalChannel` instance at session start and pass it to the tool execution pipeline. During the Processing behavior phase, the session actor SHALL handle `ToolInteractionResponse` messages by completing the corresponding `TaskCompletionSource` in the approval channel. -The session actor SHALL also update the `CommandApprovalCache` based on the -approval decision (session-scoped for ApproveOnce, persistent via -`ToolApprovalStore` for ApproveAlways). - -The persisted `TurnRecorded` event SHALL carry an optional -`SourceReminderId` field (protobuf tag 5, additive). When a -`SendUserMessage` arrives with `MessageSource.ReminderId` set, the -resulting `TurnRecorded` event SHALL copy that value into -`SourceReminderId` so that reminder-originated turns are distinguishable -in the journal (for forensics) and survive recovery (so the in-memory -dedup set can be rebuilt via event replay). - -The persisted `TurnRecorded` event SHALL carry an optional -`SourceBackgroundJobId` field (protobuf tag 6, additive). When a -`SendUserMessage` arrives with `MessageSource.BackgroundJobId` set, the -resulting `TurnRecorded` event SHALL copy that value into -`SourceBackgroundJobId` so that background-job-originated turns are -distinguishable in the journal and survive recovery (so the in-memory -dedup set can be rebuilt via event replay). - -`SessionState` SHALL maintain an `ActiveBackgroundJobs` dictionary -(`ImmutableDictionary`) persisted to the Akka journal. -`ActiveJobInfo` SHALL carry `JobId`, `Command`, `Rationale`, and `StartedAt`. -When a background job is started, the session SHALL persist an event adding -the job entry. When a background job result is delivered, the session SHALL -persist an event removing the job entry and adding the job ID to a dedup -set (mirroring `ProcessedReminderIds`). The working context SHALL surface -active background jobs with their rationales so the LLM knows what it is -waiting for after compaction or session resumption. - -Background job completion delivered through `DeliverTrustedSessionTurn` SHALL -be treated as the trusted completion of the original tool execution, matching -the trust semantics of synchronous shell results. The session SHALL process the -delivery only within the originating session and the persisted originating -audience/boundary captured for that job. +The session actor SHALL also record approvals through `IToolApprovalService` +based on the approval decision (session-scoped for ApproveOnce, persistent for +ApproveAlways). #### Scenario: Persist and emit assistant reply @@ -80,61 +29,6 @@ audience/boundary captured for that job. - **THEN** a `TurnRecorded` event is persisted - **AND** typed output events are emitted to subscribers based on their filter -#### Scenario: Reminder-originated turn carries SourceReminderId - -- **GIVEN** the session receives a `SendUserMessage` whose - `MessageSource.ReminderId` equals `"daily-digest:1712000000000"` -- **WHEN** the turn completes and `TurnRecorded` is persisted -- **THEN** the persisted event has - `SourceReminderId = "daily-digest:1712000000000"` -- **AND** the event is replayable as a normal turn on recovery - -#### Scenario: Background job started persisted to session state - -- **GIVEN** the pipeline routes a tool call to background execution -- **WHEN** `BackgroundJobStarted` is received by the session -- **THEN** an `ActiveJobInfo` entry is added to `ActiveBackgroundJobs` -- **AND** the addition is persisted to the journal - -#### Scenario: Background job result delivery removes active job - -- **GIVEN** a background job result arrives via `DeliverTrustedSessionTurn` -- **WHEN** the session processes the delivery -- **THEN** the job entry is removed from `ActiveBackgroundJobs` -- **AND** the job ID is added to the dedup set -- **AND** both changes are persisted to the journal - -#### Scenario: Session applies trusted delivery with originating scope - -- **GIVEN** a background job result arrives via `DeliverTrustedSessionTurn` -- **AND** the job has persisted originating audience/boundary metadata -- **WHEN** the session processes the delivery -- **THEN** the turn is treated with the same trust semantics as a synchronous - shell result for that session -- **AND** processing remains scoped to the persisted originating - audience/boundary - -#### Scenario: Active jobs visible in working context - -- **GIVEN** a session has active background jobs -- **WHEN** the working context is built for the LLM -- **THEN** the context includes a section listing pending jobs with their - rationales and start times - -#### Scenario: Active jobs survive session recovery - -- **GIVEN** a session with active background jobs has been passivated -- **WHEN** the session rehydrates from the journal -- **THEN** `ActiveBackgroundJobs` is restored with all entries -- **AND** the background job dedup set is restored - -#### Scenario: Non-reminder turn has null SourceReminderId - -- **GIVEN** the session receives a regular user `SendUserMessage` with - `MessageSource.ReminderId = null` -- **WHEN** the turn completes and `TurnRecorded` is persisted -- **THEN** the persisted event has `SourceReminderId = null` - #### Scenario: Multi-subscriber filtered delivery - **GIVEN** multiple subscribers with different OutputFilter bitmasks @@ -164,654 +58,3 @@ audience/boundary captured for that job. - **GIVEN** a tool requires approval - **WHEN** the pipeline emits a `ToolInteractionRequest` - **THEN** all subscribers receive it regardless of their `OutputFilter` - -### Requirement: Persist adopted-context audit records - -When an authorized threaded turn adopts unsynced prior thread messages, the -session system SHALL durably persist or reuse an adopted-context record for -audit before execution continues for that authorized turn. - -The persisted record SHALL include at minimum: - -- session or thread identity -- authorizer identity for the current authorized message -- sync lower bound and upper bound -- included message ids -- included message timestamps -- included message sender ids -- authority-at-inclusion for each included message -- the exact canonical attribution projection presented to the model -- enough linkage to correlate retries or recovery for the same authorized - message id - -The idempotency basis for this record SHALL be the current authorized message -identity within the session or thread. If the same authorized message is -retried or replayed after adopted-context persistence has already succeeded, the -session SHALL reuse the existing adopted-context record and exact persisted -projection rather than persist a duplicate or re-derive a new projection from -raw thread history. - -If the authorized message has no unsynced adopted gap, the session SHALL NOT -persist an adopted-context record and SHALL treat the turn as an ordinary -authorized turn. - -If adopted-context persistence fails, the system SHALL NOT enqueue the -authorized turn and SHALL NOT advance the authorized-sync watermark. - -If durable turn completion is not observed after the adopted-context record has -been persisted, the durable authorized-sync watermark SHALL remain unchanged and -the persisted record SHALL remain a fail-closed audit artifact that retries or -recovery can reuse rather than proof that the turn ran. - -#### Scenario: Adopted-context record persisted for authorized turn - -- **GIVEN** an authorized threaded message adopts three unsynced prior messages -- **WHEN** the turn is prepared -- **THEN** the session persists one adopted-context audit record -- **AND** the record contains the authorizer, sync bounds, included messages, - authority-at-inclusion, and the exact canonical projection - -#### Scenario: Persistence failure blocks enqueue - -- **GIVEN** an authorized threaded message would adopt unsynced prior messages -- **WHEN** adopted-context persistence fails -- **THEN** the authorized turn is not enqueued -- **AND** the authorized-sync watermark does not advance - -#### Scenario: Missing durable completion leaves audit without watermark advance - -- **GIVEN** the adopted-context record has been persisted -- **WHEN** durable turn completion is not observed for that authorized message -- **THEN** the durable authorized-sync watermark remains unchanged -- **AND** the persisted adopted-context record is treated as a non-executed - audit artifact that retries or recovery may reuse - -#### Scenario: Same authorized message retry reuses persisted record - -- **GIVEN** an adopted-context record already exists for a specific current - authorized message identity -- **AND** a prior enqueue attempt for that message did not complete -- **WHEN** the system retries that same authorized message -- **THEN** the existing adopted-context record is reused -- **AND** the execution linkage is updated without persisting a duplicate - -### Requirement: Adopted context is non-executable quoted context - -The session SHALL treat adopted-context material as quoted context rather than -ordinary authoritative turn history unless a later explicit change says -otherwise. Only the current authorized message in that turn SHALL be executable. - -Adopted or pending unauthorized content SHALL NOT directly: - -- dispatch a model turn on its own; -- enter slash-command dispatch; -- originate tool approvals; -- originate tool calls, reminders, or jobs; or -- originate direct durable memory writes. - -#### Scenario: Adopted context cannot execute without current authorized message - -- **GIVEN** a thread contains only unauthorized pending messages after the last - watermark -- **WHEN** no authorized message arrives -- **THEN** the session does not execute a turn from that pending material - -#### Scenario: Authorized turn executes only current message - -- **GIVEN** an authorized turn includes adopted context plus the current - authorized message -- **WHEN** the session executes the turn -- **THEN** only the current authorized message is treated as executable -- **AND** the adopted context remains quoted supporting material - -### Requirement: Canonical projection is derived from persisted record - -The threaded adapter MAY construct the model-visible multi-speaker projection -before session handoff. When adopted context exists, the session SHALL persist -that exact projection together with the adopted-message metadata before -execution continues. - -Retries, replay, or crash recovery for the same authorized message id SHALL -reuse the persisted adopted-context record keyed by that authorized message id -rather than reconstruct a different projection from raw thread history. - -If no adopted-context record exists because the turn had no unsynced gap, the -model SHALL receive only the current authorized message and no empty -adopted-context projection. - -#### Scenario: Audit replay matches model-visible projection - -- **GIVEN** an adopted-context record exists for a turn -- **WHEN** an operator reviews that turn later -- **THEN** the stored canonical projection matches the attribution framing that - was shown to the model - -### Requirement: Context window usage transparency - -The system SHALL include context window metadata in `UsageOutput` events so -subscribers can display usage percentage without duplicating session config. - -#### Scenario: UsageOutput includes context window metadata - -- **WHEN** a turn completes with usage data -- **THEN** `UsageOutput` includes `ContextWindowTokens` (total capacity) and - `UsagePercent` (input tokens / context window) - -### Requirement: Decoupled immutable session state - -The system SHALL maintain conversation state (history, turn count, title) in an -immutable `SessionState` record decoupled from the actor. State transitions -SHALL be pure functions (`Apply` methods) testable without an ActorSystem. - -#### Scenario: State transitions are pure and testable - -- **GIVEN** a `SessionState` instance -- **WHEN** an event is applied via `Apply()` -- **THEN** a new `SessionState` is returned with the event applied -- **AND** the original instance is not modified - -### Requirement: Session recovery across restart - -The system SHALL recover session state from journal and snapshots. - -#### Scenario: Recover context after process restart - -- **GIVEN** prior persisted turns exist -- **WHEN** the process restarts -- **THEN** the session recovers prior context before processing new input - -#### Scenario: Recover state after actor kill - -- **GIVEN** two completed turns are persisted -- **WHEN** the session actor is killed and a new message arrives for the same session -- **THEN** a new actor recovers from the journal with TurnCount == 2 -- **AND** the next turn continues from the recovered state - -### Requirement: Conversation compaction - -The system SHALL compact long session history using a tiered approach that -produces a structured summary surviving successive compactions without -grounding decay, enforces tool call/result pair integrity at the compaction -boundary, and disambiguates the self session from any foreign session -identifiers referenced in the discarded window. Before and after compaction -boundaries, the session SHALL emit high-priority memory checkpoints into the -durable memory queue instead of performing a synchronous one-off memory flush -that depends on the turn path completing all curation work inline. - -Compaction logic SHALL be encapsulated in a `SessionCompactionPipeline` static -utility class, separate from the session actor. The pipeline accepts a -`SessionState` snapshot and `CompactionParameters` record and sends results -back to the actor via `self.Tell()`. - -The compaction observer LLM SHALL produce output in a fixed structured -format with nine sections: Primary Request and Intent, Key Technical -Concepts, Files and Code Sections, Problem Solving, Pending Tasks, Task -Evolution, Current Work, Next Step, and Required Files. The Task Evolution -section SHALL contain direct quotes from user messages that changed the -task, to prevent drift across successive compactions. - -The compaction summary message SHALL be wrapped with a distinctive header -of the form `[session-summary session:{id}]` so that consumers (the -observer on successive compactions, the reducer, and the UI) can -recognize it as a prior-compaction artifact and preserve it across -successive compactions without relying on a separately-persisted index. - -The compaction observer SHALL receive the self `SessionId` in its system -prompt and SHALL explicitly mark any foreign session identifiers in -observations as `session:{id}` rather than conflating them with the self -session. - -The compaction observer system prompt SHALL include a rule instructing the -model to preserve any prior structured summary block verbatim and update -in place, rather than re-summarizing or rewriting it. - -#### Scenario: Compaction threshold reached - -- **GIVEN** `UsageDetails.InputTokenCount` exceeds the compaction token limit - derived from `ModelCapabilities.CompactionTokenLimit(SessionTuning.CompactionThreshold)` -- **WHEN** compaction runs -- **THEN** the actor enters `Compacting` phase via `TransitionTo(Compacting)` -- **AND** incoming messages are buffered during compaction - -#### Scenario: Compaction boundary emits memory checkpoint - -- **GIVEN** compaction is about to run or has just completed a summary reduction -- **WHEN** the compaction boundary is reached -- **THEN** the session enqueues a high-priority memory checkpoint for durable - curation -- **AND** the user-facing session does not wait for background curation to - finish - -#### Scenario: Tiered compaction — tool result clearing first - -- **GIVEN** compaction is triggered -- **WHEN** phase 1 runs -- **THEN** old tool results are replaced with placeholders -- **AND** the N most recent tool interactions are preserved in full - (configurable via `SessionTuning.KeepRecentToolResults`) -- **AND** if threshold is now satisfied, no summarization LLM call is made - -#### Scenario: Tiered compaction — structured summarization - -- **GIVEN** phase 1 (tool clearing) did not bring context under threshold -- **WHEN** the observer LLM call runs -- **THEN** the observer produces a summary containing the nine fixed sections - (Primary Request and Intent, Key Technical Concepts, Files and Code Sections, - Problem Solving, Pending Tasks, Task Evolution, Current Work, Next Step, - Required Files) -- **AND** the Task Evolution section contains direct quotes from user - messages that changed the task -- **AND** the summary is wrapped with a `[session-summary session:{id}]` - header and stored in the compacted history -- **AND** a `SessionCompacted` event is persisted carrying the compacted - messages -- **AND** a persistence snapshot is taken -- **AND** compacted state remains usable for future turns - -#### Scenario: Successive compactions do not re-summarize prior summary - -- **GIVEN** a session that has been compacted, with a prior - `[session-summary session:{id}]` message in history -- **WHEN** a subsequent compaction is triggered -- **THEN** the observer system prompt instructs the model to preserve the - prior summary block verbatim and update its sections in place -- **AND** the reducer's user-message-boundary walk-back preserves the - prior summary message in the kept window (the summary is a User-role - message with a distinctive header) - -#### Scenario: Self session disambiguation in observer - -- **GIVEN** the discarded window contains a reference to a session identifier - that is not the running session (e.g. the agent was investigating another - session via a tool call) -- **WHEN** the observer LLM call runs -- **THEN** the observer system prompt includes the self session id -- **AND** the produced summary marks the foreign session as `session:{id}` -- **AND** the produced summary does not conflate the foreign session with the - self session - -#### Scenario: Tool call/result pair integrity during compaction - -- **GIVEN** conversation history contains tool call/result pairs -- **WHEN** the extractive reducer selects the kept window -- **THEN** the kept window starts on a `User`-role message (not a - `Tool`-role message and not an `Assistant` message that contains - `FunctionCallContent` without a matching preceding user turn) -- **AND** tool call/result pairs are never split across the compaction - boundary -- **AND** older tool interactions remain representable in the journal for - checkpoint extraction and summarization - -### Requirement: Automatic pre-turn memory recall - -The session system SHALL run automatic durable-memory recall before each -user-facing model turn. The recall pipeline SHALL use the incoming user -message, recent turn state, active project/session context, and policy scope to -assemble a bounded recall bundle. If recall exceeds its latency budget or the -memory substrate is unhealthy, the turn SHALL continue in degraded mode without -blocking on recall. - -#### Scenario: User-facing turn receives automatic recall bundle - -- **GIVEN** a session receives a new user message -- **WHEN** the turn pipeline prepares the model request -- **THEN** the session queries durable memory before the model call -- **AND** injects a bounded recall bundle when eligible memories are found - -#### Scenario: Recall timeout degrades safely - -- **GIVEN** the memory recall pipeline exceeds its configured time budget -- **WHEN** the session is preparing the next model call -- **THEN** the session continues without the recall bundle -- **AND** records degraded memory status for diagnostics and observability - -### Requirement: Durable memory checkpoint scheduling - -The session system SHALL emit durable memory checkpoints on eligible events -including explicit memory requests, stable user facts, verified tool findings, -compaction boundaries, and accepted subagent findings. Checkpoint enqueue SHALL -be durable before the turn reports a successful explicit save, and pending -checkpoints SHALL survive daemon restart. - -#### Scenario: Explicit remember request is durably queued - -- **GIVEN** the operator explicitly tells Netclaw to remember a fact -- **WHEN** the session handles that request -- **THEN** the session durably enqueues a high-priority checkpoint before - reporting success -- **AND** background curation may complete after the user-facing turn finishes - -#### Scenario: Pending checkpoints recover after restart - -- **GIVEN** one or more memory checkpoints were queued before daemon shutdown -- **WHEN** the daemon restarts -- **THEN** the memory worker reloads the pending checkpoints -- **AND** resumes curation without losing the queued work - -### Requirement: Tool context in session state - -The system SHALL load available tools into session state based on the active -policy grants at session initialization. Tool definitions SHALL be refreshed -from the tool registry each time a session actor starts or recovers. - -#### Scenario: Session loads granted tools at initialization - -- **GIVEN** the ACL grants `shell`, `web_search`, and `mcp:memorizer` to the - current channel and sender -- **WHEN** a session actor initializes -- **THEN** session state includes tool definitions for only the granted tool - categories - -#### Scenario: Denied tools excluded from session - -- **GIVEN** the ACL does not grant `github` for the current channel -- **WHEN** a session actor initializes -- **THEN** GitHub tool definitions are not loaded into session state - -### Requirement: Config hot-reload integration - -The session system SHALL respond to config change notifications dispatched by -the `ConfigWatcherService`. Active sessions SHALL re-evaluate their tool grants -when ACL changes, rebuild provider connections when provider config changes, -and reconnect MCP servers when MCP profiles change. - -#### Scenario: ACL change refreshes tool grants for active session - -- **GIVEN** a session actor is active with tools loaded from the previous ACL -- **WHEN** the config watcher publishes an ACL change event -- **THEN** the session actor re-evaluates tool grants against the new ACL -- **AND** adds or removes tools from the session's available tool set - -#### Scenario: Provider change triggers IChatClient rebuild - -- **GIVEN** a session actor is using an `IChatClient` from the current provider - configuration -- **WHEN** the config watcher publishes a provider change event -- **THEN** the session actor obtains a new `IChatClient` from the provider - factory -- **AND** subsequent turns use the new provider configuration - -#### Scenario: MCP profile change triggers server reconnection - -- **GIVEN** a session actor has MCP tools loaded from connected servers -- **WHEN** the config watcher publishes an MCP profile change event -- **THEN** the session actor refreshes its MCP tool definitions -- **AND** newly added servers' tools become available -- **AND** removed servers' tools are no longer available - -#### Scenario: Schedule change does not affect active sessions - -- **GIVEN** a session actor is processing turns -- **WHEN** the config watcher publishes a schedule change event -- **THEN** the session actor does NOT take any action -- **AND** the `ScheduleManagerActor` handles timer reconfiguration independently - - -# netclaw-session Delta Spec - -## MODIFIED Requirements - -### Requirement: Skill index context layer injection - -The skill index context layer SHALL accept the session's effective trust -audience and available tool set when producing the skill index for system -prompt injection. The injected index SHALL be filtered per-audience rather -than identical for all sessions. - -#### Scenario: Session prompt includes audience-filtered skill index - -- **GIVEN** a session with `TrustAudience.Team` and tools `[web_search, web_fetch, file_read]` -- **WHEN** the system prompt is assembled -- **THEN** the skill index context layer injects the Team-audience compressed - menu -- **AND** skills requiring `shell_execute` are not present in the injected index - -#### Scenario: Session prompt uses pre-built menu - -- **GIVEN** pre-built menus exist for each audience -- **WHEN** the system prompt is assembled for a new session -- **THEN** the context layer selects the pre-built menu matching the session's - effective audience -- **AND** no per-turn menu generation occurs - - -# netclaw-session Delta Spec - -## MODIFIED Requirements - -### Requirement: Slash-command interception before LLM dispatch - -The session actor SHALL intercept user messages starting with `/` and check -the slash-command registry before passing the message to the LLM. This -interception SHALL apply to all message sources (Slack, webhook, scheduled -jobs, reminders). - -#### Scenario: Slash command intercepted before LLM - -- **GIVEN** a user message starting with `/netclaw-operations` -- **WHEN** the session actor receives the message -- **THEN** the slash-command registry is checked BEFORE any LLM call -- **AND** if matched, the skill content is injected as a transient system message -- **AND** the remainder text becomes the user message for the LLM turn - - -## MODIFIED Requirements - -### Requirement: Decoupled immutable session state - -The system SHALL maintain conversation state (history, turn count, title) in an -immutable `SessionState` record decoupled from the actor. State transitions -SHALL be pure functions (`Apply` methods) testable without an ActorSystem. - -Session actor internal concerns SHALL be decomposed into independently testable -modules: - -- **SessionSubscriberManager**: Owns subscriber registration, deregistration, - filtered output delivery, and watch lifecycle. -- **DeliveryRetryHandler**: Owns retry counting, eligibility tracking, and - nudge message construction for channel delivery failures. -- **TurnStateTracker**: Owns per-turn transient counters (tool call count, - budget nudge, duplicate detection). Provides `Reset()` for turn boundaries. -- **DiscoveredToolCache**: Owns MCP tool retention with lease countdown, - eviction, and max count enforcement. -- **ProcessingWatchdog**: Owns operation ID tracking, timer management, and - expiry validation for stuck-operation detection. - -Each module SHALL be a plain `internal sealed` class instantiated by the actor, -not registered in DI. - -#### Scenario: State transitions are pure and testable - -- **GIVEN** a `SessionState` instance -- **WHEN** an event is applied via `Apply()` -- **THEN** a new `SessionState` is returned with the event applied -- **AND** the original instance is not modified - -#### Scenario: Handler modules testable without ActorSystem - -- **GIVEN** a `TurnStateTracker` instance -- **WHEN** `Reset()` is called -- **THEN** all per-turn counters are zeroed -- **AND** no Akka.NET types are required for the test - -### Requirement: Automatic pre-turn memory recall - -The session system SHALL run automatic durable-memory recall before each -user-facing model turn. Recall logic SHALL be encapsulated in a -`SessionRecallManager` class that owns the turn recall cache and progressive -exclusion set. The manager SHALL provide `ResolveForTurn()`, -`InjectIntoMessages()`, `ResetForNewTurn()`, and `ResetForCompaction()` methods. - -The recall pipeline SHALL use the incoming user message, recent turn state, -active project/session context, and policy scope to assemble a bounded recall -bundle. If recall exceeds its latency budget or the memory substrate is -unhealthy, the turn SHALL continue in degraded mode without blocking on recall. - -#### Scenario: User-facing turn receives automatic recall bundle - -- **GIVEN** a session receives a new user message -- **WHEN** the turn pipeline prepares the model request -- **THEN** the `SessionRecallManager` resolves recall before the model call -- **AND** injects a bounded recall bundle when eligible memories are found - -#### Scenario: Recall timeout degrades safely - -- **GIVEN** the memory recall pipeline exceeds its configured time budget -- **WHEN** the session is preparing the next model call -- **THEN** the session continues without the recall bundle -- **AND** records degraded memory status for diagnostics and observability - -### Requirement: Session title generation - -Title generation SHALL be encapsulated in a `SessionTitleGenerator` static -utility class. The generator SHALL determine whether to generate a title based -on turn count and `SessionTuning.TitleGenerationInterval`, and fire a sidecar -LLM call that sends `TitleGenerationCompleted` back to the actor. Title -generation is best-effort — failures are silently logged and do not affect -session operation. - -#### Scenario: Title generated on first turn - -- **GIVEN** a session completes turn 1 -- **WHEN** `SessionTitleGenerator.ShouldGenerate(1, interval)` is evaluated -- **THEN** the result is `true` -- **AND** a sidecar LLM call is fired to generate a title - -#### Scenario: Title generation failure is silent - -- **GIVEN** the sidecar LLM call for title generation fails -- **WHEN** the error is caught -- **THEN** a warning is logged -- **AND** the session continues without a title update - -### Requirement: LLM invocation encapsulation - -LLM call execution and streaming SHALL be encapsulated in a `SessionLlmInvoker` -static utility class. The invoker SHALL handle timeout wrapping, streaming delta -forwarding via `self.Tell(LlmResponseDeltaReceived)`, and error packaging as -`LlmCallFailed`. Dynamic context layer injection SHALL be a static method on -this class. - -#### Scenario: LLM streaming inactivity timeout - -The system SHALL enforce a single reset-on-delta inactivity timeout for LLM -streaming calls using `FirstTokenTimeout` (default 600s). The timer starts -when the LLM call is fired and resets on every streaming delta received. - -- **GIVEN** an LLM streaming call is in progress -- **WHEN** no deltas arrive within `FirstTokenTimeout` of the call start or - the last received delta -- **THEN** the watchdog fires and the turn fails with `ErrorCategory.Timeout` -- **AND** the error message indicates the stream timed out due to inactivity - -Backward compat: if `TurnLlmTimeoutSeconds` is configured but -`FirstTokenTimeoutSeconds` is not, `FirstTokenTimeout` uses `TurnLlmTimeout`. - -#### Scenario: Streaming deltas forwarded to actor - -- **GIVEN** an LLM streaming call is in progress -- **WHEN** text content chunks arrive -- **THEN** each chunk after the first is forwarded as `LlmResponseDeltaReceived` -- **AND** the first chunk is held until the second arrives (single-chunk optimization) -- **AND** the watchdog refreshes with `FirstTokenTimeout` on each delta - -### Requirement: Tool execution encapsulation - -Tool execution SHALL be encapsulated in a `SessionToolExecutionPipeline` static -utility class. The pipeline SHALL execute tool calls in parallel, clamp oversized -results to `SessionTuning.MaxInlineToolResultChars`, track sub-agent activity, -and send `ToolExecutionCompleted` or `ToolExecutionFailed` back to the actor. - -#### Scenario: Parallel tool execution - -- **GIVEN** an LLM response contains 3 tool calls -- **WHEN** `SessionToolExecutionPipeline.ExecuteToolsAsync()` runs -- **THEN** all 3 tool calls execute in parallel -- **AND** results are collected and sent as a single `ToolExecutionCompleted` - -#### Scenario: Tool execution timeout - -- **GIVEN** tool execution is in progress -- **WHEN** the configured `ToolExecutionTimeout` elapses -- **THEN** the pipeline sends `ToolExecutionFailed` with a `TimeoutException` - -### Requirement: Reminder redelivery best-effort dedup - -`SessionState` SHALL maintain an in-memory -`ImmutableHashSet ProcessedReminderIds`, folded in the -`Apply(TurnRecorded)` handler from each recovered or live event's -`SourceReminderId` when non-null. `Apply(SessionCompacted)` SHALL preserve -the set across compaction (similar to how `WorkingContext` is preserved). -The set SHALL NOT be persisted to `SessionSnapshot`: on snapshot-based -recovery the set starts empty and rebuilds from post-snapshot event replay -via the normal `Apply(TurnRecorded)` path. - -`LlmSessionActor` SHALL pre-check `cmd.Source?.ReminderId` against -`ProcessedReminderIds` at the top of both the `Ready`-phase -`HandleIncomingUserMessage` method and the `Processing`-phase -`Command` buffer handler. On a dedup hit, the session -SHALL reply `CommandAck` to the sender without modifying state, -persisting events, or dispatching an LLM call. - -The dedup check SHALL happen *before* any audience enforcement, ACL -evaluation, or prompt construction, so that a redelivery from -Akka.Reminders is handled entirely in memory and cannot trigger spurious -side effects. - -**Best-effort semantics**: dedup is not guaranteed across snapshot -recovery boundaries. A reminder processed before a snapshot and -redelivered after a snapshot-based recovery will be processed as a fresh -turn. This is an explicitly accepted tradeoff — the LLM itself typically -recognizes a duplicate prompt in its recent context and responds -appropriately, and persisting the dedup ledger to snapshot adds -complexity without proportional value. - -#### Scenario: Redelivered reminder hits dedup in Ready phase - -- **GIVEN** the session is in `Ready` phase with - `ProcessedReminderIds = { "check-pr:1712000000000" }` rebuilt from - post-snapshot journal replay -- **WHEN** a `SendUserMessage` arrives with - `MessageSource.ReminderId = "check-pr:1712000000000"` -- **THEN** the session replies `CommandAck` to the sender -- **AND** no `TurnRecorded` event is persisted -- **AND** the LLM is not invoked -- **AND** a `reminder_mode_b_dedup_hit` log entry is emitted - -#### Scenario: Redelivered reminder hits dedup in Processing phase - -- **GIVEN** the session is in `Processing` phase (LLM call in flight) with - a dedup set containing `"nightly-report:1712005000000"` -- **WHEN** a `SendUserMessage` redelivery arrives with the same reminder ID -- **THEN** the session replies `CommandAck` without buffering the message -- **AND** the in-flight turn is unaffected - -#### Scenario: Dedup set rebuilt from post-snapshot event replay - -- **GIVEN** a session journal contains three `TurnRecorded` events after - the most recent snapshot, two with non-null `SourceReminderId` and one - regular user turn -- **WHEN** the session actor recovers from the snapshot and replays - subsequent events -- **THEN** `ProcessedReminderIds` contains exactly the two reminder IDs - from the post-snapshot events -- **AND** subsequent redeliveries of those reminders are deduped - -#### Scenario: Dedup set starts empty on snapshot-only recovery - -- **GIVEN** a session journal where all `TurnRecorded` events are - older than the most recent snapshot -- **WHEN** the session actor recovers from that snapshot -- **THEN** `ProcessedReminderIds` starts empty (the snapshot does not - carry the set) -- **AND** a subsequent redelivery of a pre-snapshot reminder (still - within `MaxDeliveryWindow`) is NOT deduped and is processed as a fresh - turn -- **AND** the outcome is logged but not treated as an error - -#### Scenario: Non-reminder user messages are not deduped - -- **GIVEN** a session with a populated `ProcessedReminderIds` set -- **WHEN** a regular `SendUserMessage` arrives with - `MessageSource.ReminderId = null` -- **THEN** the message is processed normally regardless of the dedup set diff --git a/openspec/specs/netclaw-slack-socket/spec.md b/openspec/specs/netclaw-slack-socket/spec.md index 28e09b880..9fb1285c8 100644 --- a/openspec/specs/netclaw-slack-socket/spec.md +++ b/openspec/specs/netclaw-slack-socket/spec.md @@ -1,31 +1,24 @@ -# netclaw-slack-socket Specification - -## Purpose - -Define Slack transport behavior for Netclaw MVP using Slack Socket Mode. - -## Requirements +## MODIFIED Requirements ### Requirement: Socket Mode transport Netclaw SHALL use Slack Socket Mode as the primary transport for inbound and outbound message handling in MVP. The Slack channel SHALL register a -`BlockAction` event handler to receive interactive responses (button clicks) -through the Socket Mode WebSocket connection. No inbound HTTP endpoint SHALL -be required for interactive responses. +Socket Mode connection for message events and approval replies. No inbound HTTP +endpoint SHALL be required for interactive approval responses. #### Scenario: Socket session established -- GIVEN valid Slack app and bot tokens are configured -- WHEN Netclaw starts -- THEN it opens a Socket Mode connection -- AND reports connection health in operator diagnostics +- **GIVEN** valid Slack app and bot tokens are configured +- **WHEN** Netclaw starts +- **THEN** it opens a Socket Mode connection +- **AND** reports connection health in operator diagnostics -#### Scenario: BlockAction events received via Socket Mode +#### Scenario: Approval replies received via Socket Mode message events - **GIVEN** an active Socket Mode connection -- **WHEN** a user clicks a Block Kit button in a Slack message -- **THEN** the Slack channel receives a `BlockAction` event via WebSocket +- **WHEN** a user replies `A`, `B`, or `C` to an approval prompt in the thread +- **THEN** the Slack channel receives the reply as a Slack message event via WebSocket - **AND** no HTTP endpoint is required ### Requirement: Thread-bound reply delivery @@ -35,9 +28,9 @@ the session command. #### Scenario: In-thread conversation -- GIVEN an allowed sender posts in thread `T` -- WHEN the turn completes -- THEN Netclaw posts the reply in thread `T` +- **GIVEN** an allowed sender posts in thread `T` +- **WHEN** the turn completes +- **THEN** Netclaw posts the reply in thread `T` ### Requirement: No required inbound public webhook @@ -46,240 +39,73 @@ transport operation, including interactive approval responses. #### Scenario: Local-only runtime -- GIVEN Netclaw runs with loopback-only binding -- WHEN Slack Socket Mode is connected -- THEN Slack interaction still functions for inbound and outbound messaging - -### Requirement: Persistent per-thread cursor - -`SlackThreadBindingActor` SHALL be a persistent actor with -`PersistenceId = "slack-thread-cursor-{sessionId}"`. It SHALL persist a single -piece of state — the Slack `ts` of the most recently successfully processed -inbound event for its thread — using an event-sourced `CursorAdvanced` -record. The cursor SHALL be advanced only after an inbound event has been -enqueued onto the session input channel. The actor SHALL truncate its -persistence journal by calling `DeleteMessages` every 10 persisted events to -keep storage bounded; only the latest cursor matters for recovery. - -#### Scenario: Cursor persists across actor passivation - -- **GIVEN** a thread has processed an inbound event with ts `1712700000.000100` -- **WHEN** the binding actor passivates after one hour of idle time -- **AND** a later inbound event arrives for the same thread -- **THEN** the recovered actor restores `_cursorTs = "1712700000.000100"` - before processing the new event - -#### Scenario: Cursor survives daemon restart - -- **GIVEN** a thread has processed events up to cursor `1712700000.000500` -- **WHEN** the daemon restarts and a new inbound event arrives -- **THEN** the binding actor replays `CursorAdvanced` events from persistence -- **AND** the cursor is `1712700000.000500` before the new event is evaluated - -#### Scenario: Journal is truncated periodically - -- **GIVEN** the binding actor has persisted 10 `CursorAdvanced` events -- **WHEN** the 10th event is applied -- **THEN** the actor calls `DeleteMessages(LastSequenceNr - 1)` -- **AND** subsequent recovery replays only the latest event - -### Requirement: Stale inbound event drop - -Before enqueueing an inbound Slack event, `SlackThreadBindingActor` SHALL -extract the event's `ts` and compare it against the persisted cursor. If the -event's `ts` is at or before the cursor, the event SHALL be dropped without -being enqueued, and a `stale_event` telemetry counter SHALL be recorded. This -SHALL apply uniformly to Socket Mode replays and any out-of-order delivery. - -#### Scenario: Replayed event after reconnect is dropped - -- **GIVEN** the cursor is `1712700000.000500` -- **WHEN** Slack Socket Mode replays an inbound event with ts - `1712700000.000400` -- **THEN** the binding actor drops the event -- **AND** records `ChannelTelemetry.RecordSlackEventDropped("stale_event")` -- **AND** the session input channel receives nothing - -#### Scenario: New event advances past the cursor - -- **GIVEN** the cursor is `1712700000.000500` -- **WHEN** a genuinely new inbound event with ts `1712700000.000600` arrives -- **THEN** the event is processed and enqueued -- **AND** the cursor is advanced to `1712700000.000600` after enqueue - -### Requirement: Thread hydration on first inbound per runtime - -When `SlackThreadBindingActor` is freshly initialized (including after -daemon restart), the first non-stale inbound event SHALL trigger a single -thread hydration pass. The actor SHALL call -`IThreadHistoryFetcher.FetchThreadHistoryAsync`, compute the gap of messages -strictly after the cursor and strictly before the triggering event's `ts`, -and merge the surviving gap content into the triggering `ChannelInput`. -Hydration SHALL run at most once per actor runtime; subsequent inbound events -in the same runtime SHALL NOT re-fetch history. - -#### Scenario: First inbound after restart hydrates the gap - -- **GIVEN** a cursor of `1712700000.000500` persisted from a prior run -- **WHEN** the daemon restarts and a new inbound event with ts - `1712700000.000900` arrives -- **THEN** the actor fetches full thread history once -- **AND** includes messages with ts strictly between `500` and `900` in the - merged content -- **AND** sets `_threadHistoryHydrated = true` - -#### Scenario: Subsequent inbound events skip rehydration - -- **GIVEN** hydration has already run in this actor runtime -- **WHEN** a second inbound event arrives -- **THEN** `IThreadHistoryFetcher` is not invoked -- **AND** the event is enqueued as a normal message with its own content only - -#### Scenario: Fresh thread hydration on first mention - -- **GIVEN** no cursor has ever been persisted for this thread -- **WHEN** an `app_mention` inbound event arrives -- **THEN** the actor fetches the full thread history -- **AND** includes all messages with ts strictly before the mention event - -### Requirement: Merge hydrated content into triggering ChannelInput - -Hydrated gap content SHALL be merged directly into the triggering inbound -event's `ChannelInput` rather than delivered as separate messages. The merge -SHALL produce a single `ChannelInput` whose `Contents` contain: - -1. One `TextContent` that begins with the header - `[thread history — messages exchanged before this inbound event]`, - contains one entry per gap message with sender attribution and a UTC - timestamp, ends with `[end thread history]`, and is followed by the - triggering message's live text. -2. Any image `DataContent` items from gap messages. -3. Any image `DataContent` items from the triggering message. - -The session layer SHALL receive exactly one `SendUserMessage` for the -triggering event with no special handling. - -#### Scenario: Single merged message reaches the session - -- **GIVEN** a gap of 3 historical messages and 1 triggering mention -- **WHEN** hydration completes -- **THEN** exactly one `ChannelInput` is written to the input channel -- **AND** its first `TextContent` contains the `[thread history …]` block - followed by the live mention text +- **GIVEN** Netclaw runs with loopback-only binding +- **WHEN** Slack Socket Mode is connected +- **THEN** Slack interaction still functions for inbound and outbound messaging +- **AND** approval text replies are received via Socket Mode -#### Scenario: Historical images included as DataContent +## ADDED Requirements -- **GIVEN** a gap message has one image attachment -- **WHEN** the merge runs -- **THEN** the image bytes appear as a `DataContent` on the merged - `ChannelInput` -- **AND** the text block records `[image attachments: 1]` for that entry +### Requirement: Approval prompt rendering via text reply flow -#### Scenario: Empty gap produces an unmerged inbound +The Slack channel SHALL render `ToolInteractionRequest` outputs as in-thread +text prompts. For approval-type interactions, the prompt SHALL present four +reply options: `A` = Approve Once, `B` = Approve For This Chat, `C` = Approve +Always, and `D` = Deny. The +message SHALL include the tool name and a display of what the tool wants to do +(e.g., the shell command). -- **GIVEN** the fetcher returns history but no messages fall strictly between - the cursor and the triggering event -- **WHEN** the actor builds the merged input -- **THEN** the triggering event is enqueued with its original content only -- **AND** no `[thread history …]` block is added +#### Scenario: Approval prompt posted with A/B/C/D text options -### Requirement: Prompt injection gate on hydrated gap messages - -Each gap message text SHALL be evaluated by `IPromptInjectionDetector` before -being merged. Messages whose detection result is `Risk = High` SHALL be -dropped from the merge and logged as a warning. If the detector itself throws -or otherwise fails for a gap message, that message SHALL be dropped and the -actor SHALL post a `BackfillDetectorWarning` reply to the thread so the user -is informed that some prior context was excluded. - -#### Scenario: High-risk historical message excluded - -- **GIVEN** a gap message contains a prompt-injection attack pattern -- **WHEN** the injection detector returns `Risk = High` -- **THEN** the message is dropped from the merge -- **AND** a warning is logged with sender and message identifiers -- **AND** the rest of the hydration continues - -#### Scenario: Detector failure warns the user - -- **GIVEN** the injection detector throws for a gap message -- **WHEN** the actor processes that message -- **THEN** the message is dropped from the merge -- **AND** the actor posts `BackfillDetectorWarning` to the thread exactly once - per inbound event - -### Requirement: Slack history fetch via conversations.replies - -`SlackThreadHistoryFetcher` SHALL implement `IThreadHistoryFetcher` using -`ISlackApiClient.Conversations.Replies`. It SHALL paginate through all -replies, filter out the bot's own messages and any other messages carrying a -`bot_id`, download image attachments via `url_private_download` with -bot-token Bearer auth, and content-scan each image through `IContentScanner`. -Per-message download or scan failures SHALL be skipped with a warning. API- -level failures (permission denied, server error) SHALL return an empty list. - -#### Scenario: Paginated fetch for long threads - -- **GIVEN** a thread has more than 1000 messages -- **WHEN** the fetcher retrieves the thread -- **THEN** it paginates using the cursor returned by each response until no - cursor remains -- **AND** returns all messages in chronological order - -#### Scenario: Bot messages excluded - -- **GIVEN** a thread contains messages from users, the Netclaw bot, and a CI bot -- **WHEN** the fetcher retrieves the thread -- **THEN** messages matching the Netclaw bot id are excluded -- **AND** messages carrying any other `bot_id` are excluded -- **AND** only human user messages remain - -#### Scenario: API error does not block session creation +- **GIVEN** the session emits a `ToolInteractionRequest` with `Kind=approval` +- **WHEN** the Slack subscriber receives the output +- **THEN** it posts a text message in the session's thread with the tool name, + command, and A/B/C/D approval instructions -- **GIVEN** `conversations.replies` returns a permission error -- **WHEN** the fetcher runs -- **THEN** the fetcher logs a warning and returns an empty list -- **AND** the binding actor enqueues the triggering event with its original - content only +#### Scenario: Only requesting user may reply to approval prompt -- **AND** approval button clicks are received via Socket Mode +- **GIVEN** an approval prompt is displayed in a Slack thread +- **WHEN** a different Slack user replies with an approval choice +- **THEN** the reply is rejected +- **AND** Slack receives a visible warning that only the requesting user can approve the action -### Requirement: Approval prompt rendering via Block Kit +### Requirement: Slack text approval reply routing to session -The Slack channel SHALL render `ToolInteractionRequest` outputs as approval -prompt messages in the session thread. The prompt SHALL include the tool name, -a description of what the tool wants to do, and available response options. -The channel SHALL support both Block Kit interactive buttons and text-based -ABC option lists as fallback rendering. +The Slack channel SHALL route parsed text approval replies back to the +originating session as `ToolInteractionResponse` messages. Routing SHALL use the +pending request state held by the thread binding actor so the reply is matched +to the correct `CallId` and requester. -#### Scenario: Approval prompt posted in thread +#### Scenario: User replies Approve Once -- **GIVEN** the session emits a `ToolInteractionRequest` with `Kind=approval` -- **WHEN** the Slack subscriber receives the output -- **THEN** it posts an approval prompt message in the session's thread -- **AND** the message shows the tool name, command, and response options +- **GIVEN** an approval prompt is displayed in a Slack thread +- **WHEN** the user replies `A` +- **THEN** the Slack channel parses the text reply against the pending approval request +- **AND** sends a `ToolInteractionResponse` with `ApprovedOnce` to the session -#### Scenario: Approval prompt for non-shell tool +#### Scenario: User replies Approve For This Chat -- **GIVEN** the session emits a `ToolInteractionRequest` for an MCP tool -- **WHEN** the Slack subscriber receives the output -- **THEN** it posts an approval prompt showing the tool name and description +- **GIVEN** an approval prompt is displayed in a Slack thread +- **WHEN** the user replies `B` +- **THEN** a `ToolInteractionResponse` with `ApprovedSession` is sent to the session +- **AND** the approval is retained only for the current Slack thread session -### Requirement: Approval response routing to session +#### Scenario: User replies Approve Always -The Slack channel SHALL route approval responses back to the originating session -actor as `ToolInteractionResponse` messages. Responses MAY arrive via -`BlockAction` events (button clicks) or text message parsing (ABC options). +- **GIVEN** an approval prompt is displayed in a Slack thread +- **WHEN** the user replies `C` +- **THEN** a `ToolInteractionResponse` with `ApprovedAlways` is sent to the session +- **AND** the approval is persisted to `tool-approvals.json` -#### Scenario: User approves via text response +#### Scenario: User replies Deny -- **GIVEN** an approval prompt is displayed in a Slack thread -- **WHEN** the user replies "A" or "approve once" -- **THEN** the Slack channel parses the response -- **AND** sends a `ToolInteractionResponse` with `approve_once` to the session +- **GIVEN** an approval prompt is displayed +- **WHEN** the user replies `D` +- **THEN** a `ToolInteractionResponse` with `Denied` is sent to the session +- **AND** the tool receives a denial result -#### Scenario: Approval response from non-existent session ignored +#### Scenario: No pending approval means reply falls through as normal message -- **GIVEN** an approval response references a session that no longer exists -- **WHEN** the routing is attempted -- **THEN** the event is silently discarded +- **GIVEN** no approval request is pending for the Slack thread +- **WHEN** a user sends `A`, `B`, `C`, or `D` +- **THEN** the message is not treated as an approval response diff --git a/openspec/specs/netclaw-subagents/spec.md b/openspec/specs/netclaw-subagents/spec.md index 3af5fd8f7..40398aaac 100644 --- a/openspec/specs/netclaw-subagents/spec.md +++ b/openspec/specs/netclaw-subagents/spec.md @@ -1,237 +1,29 @@ -# netclaw-subagents Specification +## MODIFIED Requirements -## Purpose - -Define subagent execution contract, timeout enforcement, observability events, -model role conventions, and context layer awareness for ephemeral autonomous -LLM actors. - -## Requirements - -### Requirement: Subagent execution contract - -The system SHALL run subagents as ephemeral actors (`SubAgentActor`) that -execute an autonomous LLM tool loop and return a single text result plus an -optional structured findings envelope. A subagent SHALL stop itself after -completing its task. Subagents SHALL NOT persist durable memory, stream direct -durable-memory writes, or participate in session pub/sub by default. - -For subagent execution launched from skill metadata routing, the subagent SHALL -remain an isolated worker by default: - -- It SHALL NOT inherit the main session identity prompt stack unless explicitly - enabled by a future opt-in setting. -- It SHALL NOT auto-load repo-local `AGENTS.md` unless explicitly enabled by a - future opt-in setting. -- It SHALL inherit audience/boundary context from the launching invocation. - -#### Scenario: Subagent completes with text response and findings - -- **GIVEN** a `SubAgentDefinition` with a name, system prompt, and tool list -- **WHEN** the subagent receives a `RunSubAgent` message -- **THEN** the subagent executes its LLM/tool loop and returns a `SubAgentResult` -- **AND** the result MAY include structured findings for the parent session to - review -- **AND** stops itself - -#### Scenario: Subagent executes tool calls in a loop - -- **GIVEN** the LLM returns `FunctionCallContent` tool calls -- **WHEN** the subagent processes the response -- **THEN** it executes the tool calls via `DispatchingToolExecutor` -- **AND** sends tool results back to the LLM -- **AND** continues until the LLM returns a text response - -#### Scenario: Subagent hits maximum tool iterations - -- **GIVEN** the subagent has executed 10 tool iterations -- **WHEN** the LLM returns another tool call -- **THEN** the subagent forces a final LLM call with tools omitted -- **AND** returns the resulting text response - -#### Scenario: Default subagent cannot write durable memory directly - -- **GIVEN** a default subagent is executing within a user-facing session -- **WHEN** it attempts to persist durable cross-session memory directly -- **THEN** the durable write path is unavailable or denied to that subagent -- **AND** the subagent must return findings to the parent session instead - -#### Scenario: Routed subagent does not inherit main identity prompt stack - -- **GIVEN** a slash-invoked skill routes execution via `metadata.subagent` -- **WHEN** the routed subagent prompt is assembled -- **THEN** the main session identity prompt stack is not included by default - -#### Scenario: Routed subagent does not auto-load repo AGENTS - -- **GIVEN** a slash-invoked skill routes execution via `metadata.subagent` -- **WHEN** the routed subagent prompt is assembled -- **THEN** repo-local `AGENTS.md` is not auto-loaded by default - -#### Scenario: Routed subagent inherits launch audience - -- **GIVEN** a routed subagent activation launched from a parent invocation with - audience `team` -- **WHEN** the subagent executes tool calls -- **THEN** tool execution context audience is `team` -- **AND** routed execution does not widen audience to a broader default - -### Requirement: User-facing target validation for routed skill execution - -Subagent targets selected by skill metadata routing SHALL be validated against -the subagent registry before execution. Routed skill execution SHALL only allow -known user-facing subagent targets. Unknown targets and internal-only targets -SHALL fail deterministically and SHALL NOT execute. - -#### Scenario: Unknown subagent target fails deterministically - -- **GIVEN** a slash-invoked skill with `metadata.subagent: missing-agent` -- **WHEN** routed execution is requested -- **THEN** execution fails with a deterministic unknown-target error -- **AND** no subagent actor is spawned - -#### Scenario: Internal-only subagent target fails deterministically - -- **GIVEN** a slash-invoked skill with `metadata.subagent` pointing to an - internal-only subagent -- **WHEN** routed execution is requested -- **THEN** execution fails with a deterministic not-user-facing error -- **AND** no subagent actor is spawned - -### Requirement: Subagent findings handoff to owning session - -When a subagent discovers information that may deserve durable memory, it SHALL -return that information as a structured findings envelope to the owning -session. The owning session SHALL evaluate policy, convert accepted findings -into checkpoints, and remain the default durable-memory owner. - -#### Scenario: Parent session accepts findings for checkpoint review - -- **GIVEN** a subagent returns findings that include stable project information -- **WHEN** the parent session evaluates the subagent result -- **THEN** the parent session converts the accepted findings into a durable - memory checkpoint -- **AND** background curation proceeds under the parent session's policy scope - -#### Scenario: Parent session rejects findings on policy grounds - -- **GIVEN** a subagent returns findings whose domain or sensitivity violates the - parent session's durable-memory policy -- **WHEN** the parent session evaluates the findings envelope -- **THEN** the findings are dropped or kept transient only -- **AND** no durable memory write occurs - -### Requirement: Subagent timeout enforcement - -The system SHALL enforce a wall-clock timeout on subagent execution. When the -timeout fires, the subagent SHALL return a failure result and stop itself. - -#### Scenario: Subagent times out - -- **GIVEN** a `RunSubAgent` message with a `Timeout` of 30 seconds -- **WHEN** 30 seconds elapse without completion -- **THEN** the subagent returns `SubAgentResult` with `Success = false` -- **AND** the output contains "timed out" -- **AND** the subagent stops itself - -#### Scenario: LLM call failure returns failure result - -- **GIVEN** the LLM throws an exception during a subagent call -- **WHEN** the subagent processes the error -- **THEN** it returns `SubAgentResult` with `Success = false` -- **AND** the output contains the error message -- **AND** the subagent stops itself - -### Requirement: Configurable subagent timeouts - -The system SHALL read subagent timeout values from the `SubAgents` section of -`netclaw.json`. When the section is absent, the system SHALL use built-in -defaults that match the current hardcoded values (180s for store, 30s for -search, 60s general default). Timeout values MUST be positive integers -between 5 and 600 seconds. - -#### Scenario: Custom timeout from configuration - -- **GIVEN** `netclaw.json` contains `"SubAgents": { "StoreMemoryTimeoutSeconds": 300 }` -- **WHEN** the `store_memory` tool spawns a subagent -- **THEN** the subagent uses a 300-second timeout - -#### Scenario: Missing config section uses defaults - -- **GIVEN** `netclaw.json` does not contain a `SubAgents` section -- **WHEN** the `store_memory` tool spawns a subagent -- **THEN** the subagent uses the default 180-second timeout - -#### Scenario: Invalid timeout rejected by doctor - -- **GIVEN** `netclaw.json` contains `"SubAgents": { "DefaultTimeoutSeconds": -1 }` -- **WHEN** the operator runs `netclaw doctor` -- **THEN** doctor reports a validation error for the timeout value - -### Requirement: Subagent observability events - -The system SHALL emit structured `SubAgentOutput` events to session subscribers -when a subagent starts and completes. These events SHALL be filtered under the -`OutputFilter.ToolCalls` category. Tools that spawn subagents SHALL notify the -session via `ToolExecutionContext.OnSubAgentActivity`. - -#### Scenario: Subagent start event emitted - -- **GIVEN** a tool spawns a subagent within a session's tool execution pipeline -- **WHEN** the subagent begins execution -- **THEN** a `SubAgentOutput` event with `Phase = Started` is emitted -- **AND** the event includes the agent name and tool count -- **AND** the event is delivered to subscribers with `ToolCalls` in their filter - -#### Scenario: Subagent completion event emitted - -- **GIVEN** a subagent completes (success or failure) -- **WHEN** the result is received by the calling tool -- **THEN** a `SubAgentOutput` event with `Phase = Completed` is emitted -- **AND** the event includes success status and duration - -#### Scenario: Headless CLI renders subagent events - -- **GIVEN** the headless CLI subscribes with `OutputFilter.Full` -- **WHEN** a subagent starts and completes -- **THEN** the CLI renders `[subagent:start] ( tools)` -- **AND** renders `[subagent:done] (, )` - -#### Scenario: Slack adapter suppresses subagent events - -- **GIVEN** the Slack adapter subscribes to session output -- **WHEN** a subagent starts and completes -- **THEN** no subagent-specific messages are posted to Slack - -### Requirement: Subagent model role convention - -Subagents SHALL use `ModelRole.Compaction` by default. This routes to the -configured compaction model (cheaper/faster) rather than the main model. The -`SubAgentDefinition.ModelRole` property SHALL allow override per-definition. - -#### Scenario: Subagent uses compaction model +### Requirement: Context layer subagent awareness -- **GIVEN** `Models.Compaction` is configured in `netclaw.json` -- **WHEN** a subagent is spawned with default `ModelRole` -- **THEN** the subagent uses the compaction model +Subagent discovery and `spawn_agent` exposure SHALL honor the same effective +audience and feature gates as the rest of the session surface. Public sessions +and deployments with `SubAgents.Enabled = false` SHALL not be able to discover +or spawn subagents through prompt layers or tool calls. -#### Scenario: Compaction model falls back to main +#### Scenario: Public session receives no spawn_agent surface -- **GIVEN** `Models.Compaction` is not configured -- **WHEN** a subagent is spawned -- **THEN** the subagent uses the main model as fallback +- **GIVEN** a session with `TrustAudience.Public` +- **WHEN** the session prompt and tool definitions are built +- **THEN** subagent discovery is absent +- **AND** `spawn_agent` is absent or denied -### Requirement: Context layer subagent awareness +#### Scenario: Runtime-disabled subagents unavailable to Team -The `MemorizerConnected` context layer SHALL inform the frontline model that -`store_memory` delegates to a curation subagent. The text SHALL set -expectations about latency (10–30 seconds for `store_memory`) so the model does -not retry or apologize for tool call duration. `find_memories`, `get_memories`, -and `update_memory` are direct MCP pass-throughs and do not use subagents. +- **GIVEN** `SubAgents.Enabled` is `false` in config +- **WHEN** a Team session starts +- **THEN** subagent discovery is absent +- **AND** `spawn_agent` is absent or denied -#### Scenario: Context layer mentions subagent delegation +#### Scenario: Public cannot recover hidden subagents through discovery text -- **GIVEN** the memory provider is `memorizer` and Memorizer is connected -- **WHEN** the context layer is assembled for a session -- **THEN** the context includes a note about `store_memory` subagent delegation -- **AND** mentions expected latency of 10–30 seconds for store operations +- **GIVEN** a session with `TrustAudience.Public` +- **WHEN** context layers are assembled +- **THEN** no discovery text names hidden subagents or instructs the model to + delegate through `spawn_agent` diff --git a/openspec/specs/netclaw-testing/spec.md b/openspec/specs/netclaw-testing/spec.md index 3d91daae6..6e2d6ccee 100644 --- a/openspec/specs/netclaw-testing/spec.md +++ b/openspec/specs/netclaw-testing/spec.md @@ -1,32 +1,21 @@ -# netclaw-testing Specification - -## Purpose - -Define test categorization and CI requirements for provider-independent -verification. - -## Requirements +## MODIFIED Requirements ### Requirement: CI-required tests are provider-independent The required CI suite SHALL not depend on live model providers. +Required CI coverage for channel adapters SHALL also not depend on live external +chat platforms (including Discord). Channel behavior SHALL be verifiable using +offline fakes, fixtures, or deterministic simulators. + #### Scenario: CI execution without provider secrets - **WHEN** CI executes required tests without provider credentials - **THEN** all required tests pass using fakes/mocks/stubs -### Requirement: Optional live smoke tests - -The system SHALL support optional smoke tests against live endpoints. - -#### Scenario: Developer runs live smoke test - -- **WHEN** a developer invokes smoke tests explicitly -- **THEN** live provider checks execute and report actionable diagnostics - -#### Scenario: Tailscale-only Ollama server not reachable in CI +#### Scenario: CI execution without live Discord instance -- **GIVEN** Ollama server is only reachable on Tailscale -- **WHEN** CI runs without Tailscale connectivity -- **THEN** CI-required test suites still pass because live smoke tests are not required +- **GIVEN** CI has no Discord token and no live Discord connectivity +- **WHEN** required test suites run +- **THEN** Discord adapter and approval fallback behavior are validated offline +- **AND** required suites pass without external Discord dependencies diff --git a/openspec/specs/netclaw-tools/spec.md b/openspec/specs/netclaw-tools/spec.md index d6db0504c..c6f999a6a 100644 --- a/openspec/specs/netclaw-tools/spec.md +++ b/openspec/specs/netclaw-tools/spec.md @@ -1,267 +1,4 @@ -# netclaw-tools Specification - -## Purpose - -Define first-party tool access for Netclaw: web search, web fetch, shell -execution, and GitHub CLI. All tools are registered through -Microsoft.Extensions.AI, filtered by policy grants, and audited on invocation. -This capability provides the agent with the ability to act on the world beyond -conversation. - -## Requirements - -### Requirement: Tool registration with MEAI - -All first-party tools SHALL be registered as `Microsoft.Extensions.AI` tool -definitions at startup. Tool metadata (name, description, parameters) SHALL be -defined at registration. Available tools presented to the LLM SHALL be filtered -per session based on ACL policy grants. - -#### Scenario: Tools registered at startup - -- **WHEN** the Netclaw process starts -- **THEN** all configured first-party tools are registered as MEAI tool - definitions -- **AND** each tool definition includes name, description, and parameter schema - -#### Scenario: Session receives filtered tool set - -- **GIVEN** a session has ACL grants for `web_search` and `web_fetch` but not - `shell` -- **WHEN** the session starts and tools are provided to the LLM -- **THEN** only `web_search` and `web_fetch` tool definitions are included -- **AND** `shell` is not offered to the LLM - -#### Scenario: Tool results returned as tool response messages - -- **GIVEN** the LLM issues a tool call during a turn -- **WHEN** the tool executes and produces a result -- **THEN** the result is returned to the LLM as an MEAI tool response message -- **AND** the session continues the turn loop with the tool result in context - -### Requirement: Web search tool - -The system SHALL provide a web search tool that delegates to a configured -`ISearchBackend` implementation. The tool SHALL accept a query and optional -max results parameter and SHALL return structured search results (title, URL, -snippet) suitable for LLM consumption. The tool interface to the agent SHALL -remain identical regardless of which backend is configured. - -#### Scenario: Web search via configured backend - -- **GIVEN** a search backend is configured and registered -- **WHEN** the agent invokes the web search tool with a query -- **THEN** the tool delegates to the configured `ISearchBackend` -- **AND** returns structured results (title, URL, snippet) to the LLM - -#### Scenario: Web search with default backend - -- **GIVEN** no search backend is explicitly configured -- **WHEN** the agent invokes the web search tool -- **THEN** the tool uses the DuckDuckGo backend -- **AND** returns results in the same format as any other backend - -#### Scenario: Backend error returned to agent - -- **GIVEN** the configured search backend returns an error -- **WHEN** the agent invokes the web search tool -- **THEN** the tool returns the backend's error message to the LLM -- **AND** the error does not crash the session - -#### Scenario: Missing API key prevents tool registration - -- **GIVEN** a backend requiring credentials is configured (e.g., Brave Search) -- **WHEN** no credentials are provided in configuration -- **THEN** the web search tool is not registered at startup -- **AND** a warning is logged indicating the tool is unavailable - -### Requirement: Web fetch tool - -The system SHALL provide a web fetch tool that retrieves content from URLs and -saves it to a local file. The tool SHALL support two output formats: `raw` -(default) preserves HTML structure after removing script and style elements, -and `text` extracts plain text. Output is saved to disk and a preview summary -returned to prevent context flooding. - -#### Scenario: Fetch URL in raw mode (default) - -- **GIVEN** the web fetch tool is available -- **WHEN** the agent invokes the tool with a URL (no format or format='raw') -- **THEN** the tool retrieves the page content via HTTP -- **AND** removes `