From ac75777a3cad318592dae9d616a09d824f1398e5 Mon Sep 17 00:00:00 2001 From: mvschwarz Date: Mon, 6 Jul 2026 01:20:09 -0700 Subject: [PATCH] Release 0.4.4 Multi-host + Living Notes. Multi-host foundation: shareable whole-topology staged spin-up brings a rig topology up across multiple hosts in stages; VPS product-factory multi-host hardening ships with rig host verbs (add / list / doctor, capped at three; one built-in product-factory-vps posture, three- valued per item); the transport posture is documented (ssh for pane ops, http-bearer for daemon REST, no cross-transport fallback); the For-You feed aggregates activity across every registered host; and rig file moves files across the topology's registered hosts. Living Notes: durable INTENT / PLAN / DELIVERED signal layer at mission and slice altitude with agent authorship + timestamps; cheap composer surfaces make it the first-class place agents record decisions, plans, and delivered work; the one-structure review contract reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change deletes the old plan and writes a new one - never demotes or stacks multiple competing plans. Operationalize the SDLC control plane: conventions SSOT ships in source at docs/reference/sdlc-conventions.md (Living Notes UI section names, proof-contract format + plannedRef mockup pairing, staged- approval locks, C1 proof header + closed sets, three role contracts, curation rule, elastic-middle doctrine, advisory fail-open audit posture); rig scope slice create scaffolds the convention sections + proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every template kind; rig scope audit + rig workspace doctor gain sdlc- convention advisories (records-and-advises, gates on HIGH only); the mission-slice-sop skill ships in canonical source with byte-parity guard; rig proof / rig scope slice create / rig scope slice approve help + cli-reference teach the SDLC flow. BREAKING: rig ps default flips to consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired - it hid running rigs from the operator's field of view). --json is scope-not-shape (still a bare array with existing per-entry keys; scope widens to all non-archived rigs including stopped ones; one-line migration for the old fleet firehose: rig ps --nodes -A --full). -A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener; bare -A is a structured teaching error). --nodes names its scope everywhere (session default local-only; --host --nodes requires --rig or -A; multi-host fan-out is rollup-only by default; the explicit ladder --all-hosts --nodes -A --full fans out per-node with hostId-stamped projected rows). --all-hosts / --hosts --json emits the shared AggregatedPayload (items + hosts with closed-enum statuses: ok | unreachable | unsupported-transport | auth-failed). Operator UX: agent altitude coordination panel scopes multi-agent state to the right altitude (workspace / mission / slice) so the coordination surface reads without per-seat drowning; composes with the workspace observability tabs from 0.4.1. Docs closeout catches docs/as-built/architecture.md, docs/as-built/cli-reference.md, and the codemaps up to shipped state. Migrations: additive only. Existing v0.4.3 databases upgrade by running rig daemon start. Behavior change (rig ps default view): the compact all-active-rigs projection replaces the current-rig-only default. If your call sites relied on the v0.4.3 default shape, add --full for the rich per-node projection or --rig for the prior scope. Public-safety scrub applied inline to packages/ui/twin/corrective/ fixtures-corrective.ts (one demo-fixture role identifier neutralized to the demo-namespace baseline established at 0.4.1). See CHANGELOG.md and docs/releases/v0.4.4.md. --- .gitignore | 1 + CHANGELOG.md | 69 ++ README.md | 2 +- demo/README.md | 4 +- demo/run-proof.sh | 5 +- demo/run.sh | 5 +- docs/as-built/README.md | 3 +- docs/as-built/architecture.md | 4 +- .../as-built/architecture/content-surfaces.md | 14 +- .../architecture/living-notes-review.md | 162 +++ docs/as-built/cli-reference.md | 122 +- docs/as-built/codemap.md | 3 + docs/as-built/ui/project-and-for-you.md | 86 +- docs/reference/agent-startup-guide.md | 2 +- docs/reference/product-factory-vps-runbook.md | 132 ++ docs/reference/sdlc-conventions.md | 187 +++ docs/releases/v0.4.4.md | 131 ++ package-lock.json | 10 +- package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/build-info.ts | 21 + .../cli/src/commands/config-init-workspace.ts | 24 +- packages/cli/src/commands/doctor.ts | 4 +- packages/cli/src/commands/expand.ts | 2 +- packages/cli/src/commands/file.ts | 93 ++ packages/cli/src/commands/host.ts | 472 ++++++++ packages/cli/src/commands/proof.ts | 344 ++++++ packages/cli/src/commands/ps.ts | 506 ++++++-- packages/cli/src/commands/queue.ts | 99 ++ packages/cli/src/commands/scope.ts | 150 ++- packages/cli/src/commands/send.ts | 17 +- packages/cli/src/commands/up.ts | 69 +- packages/cli/src/commands/workspace.ts | 12 +- packages/cli/src/config-store.ts | 64 + packages/cli/src/cross-host-executor.ts | 5 +- packages/cli/src/daemon-lifecycle.ts | 5 +- packages/cli/src/host-registry.ts | 51 +- packages/cli/src/index.ts | 7 + packages/cli/src/lib/file-transfer.ts | 358 ++++++ packages/cli/src/lib/hosts/fanout-contract.ts | 50 + .../scope-templates/backlog-deprecation.md | 16 + .../lib/scope-templates/backlog-tech-debt.md | 16 + .../cli/src/lib/scope-templates/bug-fix.md | 16 + .../lib/scope-templates/implementation-prd.md | 33 + .../scope-templates/mission-placeholder.md | 2 +- .../lib/scope-templates/mission-progress.md | 2 +- .../lib/scope-templates/mission-release.md | 2 +- .../src/lib/scope-templates/placeholder.md | 18 +- packages/cli/src/lib/scope-templates/proof.md | 6 +- .../lib/scope-templates/release-feature.md | 20 +- .../cli/src/lib/scope-templates/research.md | 16 + .../src/lib/scope-templates/slice-progress.md | 2 +- packages/cli/src/lib/scope/scope-audit.ts | 209 +++- packages/cli/src/lib/scope/templates.ts | 10 + packages/cli/src/version.ts | 12 +- packages/cli/test/client.test.ts | 2 +- packages/cli/test/cross-host-commands.test.ts | 6 +- .../cli/test/fanout-contract-parity.test.ts | 32 + packages/cli/test/file-command.test.ts | 125 ++ packages/cli/test/file-transfer.test.ts | 302 +++++ packages/cli/test/host-registry-write.test.ts | 73 ++ packages/cli/test/host.test.ts | 294 +++++ packages/cli/test/preflight.test.ts | 2 +- packages/cli/test/proof.test.ts | 258 ++++ packages/cli/test/ps-current-rig.test.ts | 128 +- packages/cli/test/ps-http.test.ts | 207 +++- packages/cli/test/ps.test.ts | 80 +- packages/cli/test/scope-audit.test.ts | 198 +++ .../test/scope-convention-scaffold.test.ts | 177 +++ .../test/up-topology-host-rejection.test.ts | 40 + packages/cli/test/version-build-info.test.ts | 33 + .../cli/test/workspace-doctor-cli.test.ts | 9 +- .../daemon/assets/guidance/openrig-start.md | 6 + .../skills/mission-slice-sop/SKILL.md | 70 +- .../openrig-core/skills/openrig-user/SKILL.md | 24 +- packages/daemon/package.json | 2 +- .../specs/agents/conveyor/builder/agent.yaml | 2 +- .../agents/conveyor/builder/guidance/role.md | 1 + .../specs/agents/conveyor/lead/agent.yaml | 2 +- .../agents/conveyor/lead/guidance/role.md | 1 + .../specs/agents/conveyor/planner/agent.yaml | 2 +- .../agents/conveyor/planner/guidance/role.md | 1 + .../specs/agents/conveyor/reviewer/agent.yaml | 2 +- .../agents/conveyor/reviewer/guidance/role.md | 1 + .../agents/design/product-designer/agent.yaml | 2 +- .../design/product-designer/guidance/role.md | 1 + .../agents/development/implementer/agent.yaml | 2 +- .../development/implementer/guidance/role.md | 1 + .../specs/agents/development/qa/agent.yaml | 2 +- .../agents/development/qa/guidance/role.md | 1 + .../orchestration/orchestrator/agent.yaml | 2 +- .../orchestrator/guidance/role.md | 1 + .../agents/product-management/pm/agent.yaml | 1 + .../product-management/pm/guidance/role.md | 13 + .../review/independent-reviewer/agent.yaml | 2 +- .../independent-reviewer/guidance/role.md | 1 + .../daemon/specs/agents/shared/agent.yaml | 2 + .../skills/core/mission-slice-sop/SKILL.md | 115 ++ .../skills/core/openrig-operator/SKILL.md | 8 +- .../shared/skills/core/openrig-user/SKILL.md | 92 +- .../skills/pods/orchestration-team/SKILL.md | 4 +- .../specs/rigs/focused/pm-team/CULTURE.md | 4 + .../agents/operator/agent/guidance/role.md | 2 +- .../agents/operator/agent/startup/context.md | 2 +- .../rigs/preview/product-team/CULTURE.md | 4 + packages/daemon/src/build-info.ts | 30 + .../migrations/048_queue_item_evidence_ref.ts | 23 + .../src/domain/feed/attention-aggregator.ts | 149 +++ .../src/domain/files/file-write-service.ts | 79 +- .../src/domain/hosts/fanout-contract.ts | 50 + .../src/domain/hosts/hosts-registry-reader.ts | 199 +++ .../src/domain/hosts/remote-daemon-http.ts | 131 ++ .../daemon/src/domain/human-route-enforcer.ts | 132 ++ .../domain/mission-control/audit-browse.ts | 27 + .../mission-control-action-log.ts | 4 + .../mission-control-fleet-cli-capability.ts | 2 +- .../mission-control-read-layer.ts | 2 +- .../mission-control-write-contract.ts | 157 +++ packages/daemon/src/domain/ps-projection.ts | 53 +- .../daemon/src/domain/queue-repository.ts | 144 ++- .../daemon/src/domain/review/brief-spine.ts | 103 ++ packages/daemon/src/domain/review/compose.ts | 1072 +++++++++++++++++ packages/daemon/src/domain/review/freeze.ts | 280 +++++ packages/daemon/src/domain/review/gather.ts | 732 +++++++++++ packages/daemon/src/domain/review/types.ts | 320 +++++ .../daemon/src/domain/scope/scope-approve.ts | 246 ++++ .../daemon/src/domain/scope/scope-audit.ts | 209 +++- .../src/domain/topology/multi-rig-launcher.ts | 175 +++ .../src/domain/topology/remote-up-leaf.ts | 85 ++ .../src/domain/topology/topology-manifest.ts | 221 ++++ packages/daemon/src/domain/types.ts | 13 +- .../daemon/src/domain/up-command-router.ts | 38 +- .../domain/user-settings/settings-store.ts | 106 ++ .../workspace/default-workspace-scaffold.ts | 57 +- .../src/domain/workspace/workspace-doctor.ts | 93 ++ packages/daemon/src/routes/config.ts | 19 +- packages/daemon/src/routes/files.ts | 47 +- packages/daemon/src/routes/mission-control.ts | 73 +- packages/daemon/src/routes/queue.ts | 56 +- packages/daemon/src/routes/review.ts | 171 +++ packages/daemon/src/routes/scope-approve.ts | 66 + packages/daemon/src/routes/scope-audit.ts | 28 +- packages/daemon/src/routes/slices.ts | 61 +- packages/daemon/src/routes/terminal-ws.ts | 71 +- packages/daemon/src/routes/up.ts | 94 ++ packages/daemon/src/routes/workspace.ts | 4 +- packages/daemon/src/server.ts | 16 +- packages/daemon/src/startup.ts | 21 +- .../test/attention-aggregate-route.test.ts | 80 ++ .../daemon/test/attention-aggregator.test.ts | 184 +++ packages/daemon/test/build-info.test.ts | 26 + packages/daemon/test/bundle-routes.test.ts | 3 +- .../feed-host-subscriptions-config.test.ts | 116 ++ .../daemon/test/files-asset-range.test.ts | 99 ++ .../getting-started-narrative-parity.test.ts | 25 +- .../daemon/test/hosts-registry-parity.test.ts | 99 ++ .../daemon/test/human-route-enforcer.test.ts | 323 +++++ .../test/mission-control-action-log.test.ts | 4 +- ...ssion-control-fleet-cli-capability.test.ts | 3 +- .../test/mission-control-read-layer.test.ts | 11 +- .../mission-control-remote-action.test.ts | 148 +++ .../mission-control-routes-phase-b.test.ts | 40 + .../test/mission-control-routes.test.ts | 3 +- .../mission-control-write-contract.test.ts | 162 ++- .../mission-slice-sop-plugin-parity.test.ts | 40 + .../test/notification-dispatcher.test.ts | 14 + packages/daemon/test/ps-projection.test.ts | 111 +- .../daemon/test/queue-list-compact.test.ts | 2 +- packages/daemon/test/queue-repository.test.ts | 204 ++++ packages/daemon/test/queue-routes.test.ts | 34 + .../daemon/test/remote-daemon-http.test.ts | 132 ++ packages/daemon/test/remote-up-leaf.test.ts | 160 +++ .../test/resume-metadata-refresher.test.ts | 12 +- .../daemon/test/review-brief-spine.test.ts | 106 ++ packages/daemon/test/review-composer.test.ts | 503 ++++++++ packages/daemon/test/review-fixtures.ts | 188 +++ packages/daemon/test/review-freeze.test.ts | 235 ++++ packages/daemon/test/review-routes.test.ts | 500 ++++++++ .../daemon/test/rig-agents-compose.test.ts | 207 ++++ .../daemon/test/rigspec-preflight.test.ts | 3 +- packages/daemon/test/scope-approve.test.ts | 183 +++ .../daemon/test/scope-audit-parity.test.ts | 76 ++ .../daemon/test/scope-audit-routes.test.ts | 29 +- packages/daemon/test/slices-routes.test.ts | 22 + packages/daemon/test/startup.test.ts | 4 + .../daemon/test/terminal-ws-route.test.ts | 73 ++ .../daemon/test/topology-launcher.test.ts | 318 +++++ .../daemon/test/topology-manifest.test.ts | 234 ++++ packages/daemon/test/topology-route.test.ts | 229 ++++ .../daemon/test/up-command-router.test.ts | 77 ++ packages/daemon/test/workspace-doctor.test.ts | 77 +- packages/daemon/test/workspace-routes.test.ts | 14 +- packages/ui/package.json | 2 +- .../ui/src/components/LiveNodeDetails.tsx | 2 +- .../ui/src/components/SharedDetailDrawer.tsx | 5 + .../components/drawer-viewers/FileViewer.tsx | 15 + packages/ui/src/components/for-you/Feed.tsx | 108 +- .../ui/src/components/for-you/FeedCard.tsx | 111 +- .../components/VerbActions.tsx | 18 + .../hooks/useMissionControlAction.ts | 4 + .../ui/src/components/project/Lightbox.tsx | 33 + .../ui/src/components/project/ProofTab.tsx | 31 +- .../ui/src/components/project/ScopePages.tsx | 28 +- .../src/components/review/AgentsBandView.tsx | 164 +++ .../src/components/review/EvidenceOpener.tsx | 172 +++ .../components/review/MissionReviewTab.tsx | 372 ++++++ .../components/review/NeedsYouAccordion.tsx | 175 +++ .../src/components/review/RigAgentsPage.tsx | 163 +++ .../src/components/review/SliceReviewTab.tsx | 370 ++++++ .../review/TranscriptDrillPanel.tsx | 148 +++ .../components/review/VerifyLineageCard.tsx | 49 + packages/ui/src/components/review/chat.ts | 28 + .../src/components/review/review-actions.ts | 81 ++ packages/ui/src/components/review/vellum.ts | 7 + .../components/slices/tabs/TimelineTab.tsx | 4 +- .../components/terminal/FocusedTerminal.tsx | 20 +- .../terminal/ProgressiveTerminal.tsx | 6 +- packages/ui/src/hooks/useAttentionItems.ts | 60 +- packages/ui/src/hooks/useContextFleet.ts | 3 +- packages/ui/src/hooks/useFeedSubscriptions.ts | 29 + packages/ui/src/hooks/useReview.ts | 264 ++++ packages/ui/src/hooks/useSettings.ts | 10 +- packages/ui/src/lib/attention-feed.ts | 17 +- packages/ui/src/lib/build-setup-prompt.ts | 2 +- packages/ui/src/lib/feed-classifier.ts | 29 +- packages/ui/src/routes.tsx | 13 + packages/ui/test/drawer-primitives.test.tsx | 26 +- .../ui/test/feed-card-kind-structured.test.ts | 71 ++ packages/ui/test/feed-classifier.test.ts | 40 +- packages/ui/test/feed-multi-host.test.tsx | 102 ++ .../fileviewer-resolvable-target.test.tsx | 157 +++ .../focused-terminal-initial-text.test.tsx | 107 ++ .../ui/test/foryou-bare-approve-chat.test.tsx | 198 +++ packages/ui/test/p5-slice-scope.test.tsx | 12 +- packages/ui/test/p5-trigger-sites.test.tsx | 18 +- ...-actions-and-evidence-containment.test.tsx | 87 ++ packages/ui/test/rig-agents-page.test.tsx | 371 ++++++ .../test/slice-16-typography-vellum.test.tsx | 2 +- packages/ui/test/slice-tabs-v1.test.tsx | 41 + .../ui/test/use-needs-input-seats.test.tsx | 2 +- .../ui/test/verb-actions-remote-host.test.tsx | 85 ++ .../ui/twin/corrective/fixtures-corrective.ts | 401 ++++++ packages/ui/twin/corrective/media-webm.ts | 6 + packages/ui/twin/fetch-stub.ts | 53 +- packages/ui/twin/fixtures.ts | 2 +- packages/ui/twin/twin-main.tsx | 19 +- packages/ui/vite.twin.config.ts | 5 + scripts/bootstrap-product-factory-vps.sh | 114 ++ scripts/build-package.sh | 29 + .../core/mission-slice-sop/SKILL.md | 115 ++ .../_canonical/core/openrig-operator/SKILL.md | 8 +- skills/_canonical/core/openrig-user/SKILL.md | 197 ++- .../pods/orchestration-team/SKILL.md | 6 +- 253 files changed, 21023 insertions(+), 587 deletions(-) create mode 100644 docs/as-built/architecture/living-notes-review.md create mode 100644 docs/reference/product-factory-vps-runbook.md create mode 100644 docs/reference/sdlc-conventions.md create mode 100644 docs/releases/v0.4.4.md create mode 100644 packages/cli/src/build-info.ts create mode 100644 packages/cli/src/commands/file.ts create mode 100644 packages/cli/src/commands/host.ts create mode 100644 packages/cli/src/commands/proof.ts create mode 100644 packages/cli/src/lib/file-transfer.ts create mode 100644 packages/cli/src/lib/hosts/fanout-contract.ts create mode 100644 packages/cli/src/lib/scope-templates/implementation-prd.md create mode 100644 packages/cli/test/fanout-contract-parity.test.ts create mode 100644 packages/cli/test/file-command.test.ts create mode 100644 packages/cli/test/file-transfer.test.ts create mode 100644 packages/cli/test/host-registry-write.test.ts create mode 100644 packages/cli/test/host.test.ts create mode 100644 packages/cli/test/proof.test.ts create mode 100644 packages/cli/test/scope-convention-scaffold.test.ts create mode 100644 packages/cli/test/up-topology-host-rejection.test.ts create mode 100644 packages/cli/test/version-build-info.test.ts create mode 100644 packages/daemon/specs/agents/shared/skills/core/mission-slice-sop/SKILL.md create mode 100644 packages/daemon/src/build-info.ts create mode 100644 packages/daemon/src/db/migrations/048_queue_item_evidence_ref.ts create mode 100644 packages/daemon/src/domain/feed/attention-aggregator.ts create mode 100644 packages/daemon/src/domain/hosts/fanout-contract.ts create mode 100644 packages/daemon/src/domain/hosts/hosts-registry-reader.ts create mode 100644 packages/daemon/src/domain/hosts/remote-daemon-http.ts create mode 100644 packages/daemon/src/domain/human-route-enforcer.ts create mode 100644 packages/daemon/src/domain/review/brief-spine.ts create mode 100644 packages/daemon/src/domain/review/compose.ts create mode 100644 packages/daemon/src/domain/review/freeze.ts create mode 100644 packages/daemon/src/domain/review/gather.ts create mode 100644 packages/daemon/src/domain/review/types.ts create mode 100644 packages/daemon/src/domain/scope/scope-approve.ts create mode 100644 packages/daemon/src/domain/topology/multi-rig-launcher.ts create mode 100644 packages/daemon/src/domain/topology/remote-up-leaf.ts create mode 100644 packages/daemon/src/domain/topology/topology-manifest.ts create mode 100644 packages/daemon/src/routes/review.ts create mode 100644 packages/daemon/src/routes/scope-approve.ts create mode 100644 packages/daemon/test/attention-aggregate-route.test.ts create mode 100644 packages/daemon/test/attention-aggregator.test.ts create mode 100644 packages/daemon/test/build-info.test.ts create mode 100644 packages/daemon/test/feed-host-subscriptions-config.test.ts create mode 100644 packages/daemon/test/files-asset-range.test.ts create mode 100644 packages/daemon/test/hosts-registry-parity.test.ts create mode 100644 packages/daemon/test/human-route-enforcer.test.ts create mode 100644 packages/daemon/test/mission-control-remote-action.test.ts create mode 100644 packages/daemon/test/mission-slice-sop-plugin-parity.test.ts create mode 100644 packages/daemon/test/remote-daemon-http.test.ts create mode 100644 packages/daemon/test/remote-up-leaf.test.ts create mode 100644 packages/daemon/test/review-brief-spine.test.ts create mode 100644 packages/daemon/test/review-composer.test.ts create mode 100644 packages/daemon/test/review-fixtures.ts create mode 100644 packages/daemon/test/review-freeze.test.ts create mode 100644 packages/daemon/test/review-routes.test.ts create mode 100644 packages/daemon/test/rig-agents-compose.test.ts create mode 100644 packages/daemon/test/scope-approve.test.ts create mode 100644 packages/daemon/test/topology-launcher.test.ts create mode 100644 packages/daemon/test/topology-manifest.test.ts create mode 100644 packages/daemon/test/topology-route.test.ts create mode 100644 packages/ui/src/components/project/Lightbox.tsx create mode 100644 packages/ui/src/components/review/AgentsBandView.tsx create mode 100644 packages/ui/src/components/review/EvidenceOpener.tsx create mode 100644 packages/ui/src/components/review/MissionReviewTab.tsx create mode 100644 packages/ui/src/components/review/NeedsYouAccordion.tsx create mode 100644 packages/ui/src/components/review/RigAgentsPage.tsx create mode 100644 packages/ui/src/components/review/SliceReviewTab.tsx create mode 100644 packages/ui/src/components/review/TranscriptDrillPanel.tsx create mode 100644 packages/ui/src/components/review/VerifyLineageCard.tsx create mode 100644 packages/ui/src/components/review/chat.ts create mode 100644 packages/ui/src/components/review/review-actions.ts create mode 100644 packages/ui/src/components/review/vellum.ts create mode 100644 packages/ui/src/hooks/useReview.ts create mode 100644 packages/ui/test/feed-card-kind-structured.test.ts create mode 100644 packages/ui/test/feed-multi-host.test.tsx create mode 100644 packages/ui/test/fileviewer-resolvable-target.test.tsx create mode 100644 packages/ui/test/focused-terminal-initial-text.test.tsx create mode 100644 packages/ui/test/foryou-bare-approve-chat.test.tsx create mode 100644 packages/ui/test/review-actions-and-evidence-containment.test.tsx create mode 100644 packages/ui/test/rig-agents-page.test.tsx create mode 100644 packages/ui/test/verb-actions-remote-host.test.tsx create mode 100644 packages/ui/twin/corrective/fixtures-corrective.ts create mode 100644 packages/ui/twin/corrective/media-webm.ts create mode 100755 scripts/bootstrap-product-factory-vps.sh create mode 100644 skills/_canonical/core/mission-slice-sop/SKILL.md diff --git a/.gitignore b/.gitignore index f18717948..2173c379a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +node_modules dist/ .DS_Store *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 266c44e4b..4c9f8046d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,75 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly --- +## [0.4.4] - 2026-07-06 + +**Status**: shipped; multi-host + Living Notes theme. + +### Summary For Installing Agents + +- **Package version**: bumps from `0.4.3`. +- **Migrations**: additive only. Existing v0.4.3 databases upgrade by running `rig daemon start` on the new daemon. +- **Node engines**: unchanged. +- **Backward compatibility**: `rig ps` default view flips to a consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired); progressive-disclosure via `--full` / `-A` / `--rig ` returns the v0.4.3 default shape. `--json` shape unchanged (scope-not-shape). `rig host` gains three new verbs (`add` / `list` / `doctor`); transport posture documented (no behavior change). + +### Headline + +**Multi-host + Living Notes.** A shared rig topology can now span multiple hosts with staged whole-topology spin-up, real cross-host file movement, and a consolidated For-You feed that aggregates activity across every registered host. Living Notes ships as the durable INTENT → PLAN → DELIVERED signal layer with a one-structure review contract (single vertical stack, delete-not-demote) and cheap composer surfaces. Operator UX picks up `rig ps` consolidated default (with progressive disclosure), an agent-altitude coordination panel, and the operationalize-SDLC control plane. As-built docs closeout catches everything up. + +### The SDLC control plane ships in source (OPR.0.4.4.23 — release requirement) + +- **Conventions SSOT**: `docs/reference/sdlc-conventions.md` (copied into the assembled CLI package) — the section names the Living Notes UI projects (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format + `plannedRef` mockup pairing, the two staged-approval locks, the C1 proof header + closed sets, the three role contracts, the curation rule, the elastic-middle doctrine, and the advisory fail-open audit posture. Once shipped, the repo doc is the living SSOT; the corrective-redesign spec it derives from is the historical design record. +- **Scaffold**: `rig scope slice create` emits the convention sections + `proof/` + `PROOF.md` + an `IMPLEMENTATION-PRD.md` skeleton (elastic-middle note in its header) for **EVERY template kind** — enumeration-tested, so a future kind fails until covered. Mission templates carry the conventions pointer. +- **Advisory audit**: `rig scope audit` (both byte-identical classifier copies) gains `missing_intent_section` / `mini_requirements_missing_or_malformed` / `proof_contract_missing_or_malformed` / `ui_slice_missing_mockup` (mockup ref = a real image ref or plannedRef token, never bare prose) — low/info severities by construction (the exit code flips on HIGH findings only; records-and-advises, never gates). `rig workspace doctor` gains check #8 (`sdlc_convention_sections`, advisory warn) — the 7-check diagnostic is now 8. +- **Skill**: `mission-slice-sop` now ships in the canonical product skill source (+ `skills/_canonical` mirror), updated to teach the full flow: intent → mini-requirements + proof contract → mockups (UI slices) → plan-lock (`--scope spec`) → build the locked set → QA mockup↔delivered visual compare → `rig proof add` C1 drops → proof-lock (`--scope delivery`). The bundled openrig-core plugin's copy is now pinned by a CI byte-parity test. **Census (verbatim)**: before this slice, `mission-slice-sop` was absent from the canonical shared skill source and `skills/_canonical` mirror; the bundled plugin carried a stale orphan copy (from `c7f501a7`) with no guard — that orphan is replaced and parity-guarded. KNOWN residue: the plugin's `openrig-user` copy remains a wholesale-stale older edition with no mechanical guard (routed as a follow-up candidate, not swept here). +- **Bootstrap**: the shipped `openrig-start.md` overlay (the CLAUDE.md/AGENTS.md floor every managed seat sees) + the product-team and pm-team rig-spec cultures point fresh seats at the SOP skill and the SSOT at boot. +- **CLI help**: `rig proof`, `rig scope slice create`, and `rig scope slice approve` help text teach the flow and cite the SSOT; cli-reference gains the SDLC control-plane verbs section (`approve` locks, `rig proof add`, the audit advisories). + +### `rig host` verbs + the documented multi-host transport posture (OPR.0.4.4.13) + +- **New verbs (capped at exactly three)**: `rig host add` (registry writes validated by the loader's own rules — no more hand-edited YAML for the standard path), `rig host list` (config pointers, never secret values), `rig host doctor ` (stepwise distinct errors: transport → remote rig binary → daemon health → identity) with `--posture product-factory-vps` — the ONE built-in security baseline, three-valued per item (UNKNOWN is never pass). +- **Transport posture DECIDED + documented** (no behavior change): ssh carries pane ops (`send`/`capture`), http-bearer carries daemon REST (`up`/`down`/`launch`), `ps`/`whoami` follow the host's declared transport, fan-out is http-only; NO cross-transport fallback; NO http parity for send/capture in 0.4.4. Per-command table in cli-reference §Cross-host execution. +- **Product-factory bootstrap**: `scripts/bootstrap-product-factory-vps.sh` + `docs/reference/product-factory-vps-runbook.md` (fresh Ubuntu VPS → factory-ready; the 2026-06-23 OVH smoke-test posture as encoded defaults; safe UI tunnel + restricted fail-closed reverse-path recipes). + +### BREAKING: `rig ps` consolidated all-rigs default + explicit disclosure ladder (OPR.0.4.4.21) + +- **Default scope flips**: bare `rig ps` now shows **every ACTIVE rig on the host** as one compact O(rigs) row (the v0.4.0 current-rig-only default is retired — it hid running rigs from the operator's field of view). New display elements: the host rollup line ("N rigs · M seats · K need attention"), the archived/stopped count line, the drill-ladder footer, and an ATTN column (additive `attentionCount` JSON field). +- **`--json` is scope-not-shape**: still a bare array with the existing per-entry keys; scope widens to ALL non-archived rigs INCLUDING stopped ones (only the human table folds stopped rigs into the count line). Scripts that assumed current-rig-only add the existing `--rig ` flag — same schema, wider scope. **One-line migration for the old fleet firehose: `rig ps --nodes -A --full`.** +- **`-A`/`--all-rigs` keeps exactly ONE meaning** — the `--nodes` fleet widener. Bare `rig ps -A` is now a structured teaching error (all-rigs IS the default; archived history stays behind `--include-archived`). +- **`--nodes` names its scope everywhere**: session default applies locally only; `rig ps --host --nodes` requires an explicit `--rig` or `-A` (implicit scope defaults don't cross host boundaries); multi-host fan-out is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows. +- **`--all-hosts`/`--hosts --json` shape change**: emits the intra-P4 shared `AggregatedPayload` — `items` (per-host O(rigs) rows stamped with their origin `hostId`) + `hosts` (closed-enum per-host statuses: `ok | unreachable | unsupported-transport | auth-failed`). + +### Multi-host foundation (OPR.0.4.4.11 + 13 + 15 + 18) + +- **Shareable whole-topology staged spin-up (S11)** — a rig topology can be brought up in stages across multiple hosts; the spec + the daemon coordinate to reach a green whole-fleet state without requiring single-host bring-up. +- **VPS product-factory multi-host hardening (S13)** — `rig host` verbs (above) + the documented transport posture harden the fresh-Ubuntu-VPS → factory-ready flow. Product-factory bootstrap script + runbook ship for the OVH-smoke-tested posture. +- **Multi-host consolidated For-You feed (S15)** — the For-You feed aggregates activity across all registered hosts in the topology. Real-host feed-subscription capture is sequenced as a lifecycle-post-publish belt-and-suspenders proof. +- **`rig file` cross-host movement (S18)** — files move across registered hosts via the `rig file` surface. Real registered-host two-host round-trip proves the flow on top of the VM stand-ins already merged. + +### Living Notes signal layer (OPR.0.4.4.19 + 20 + corrective rebuild) + +- **Living Notes signal layer (S19)** — durable agent-authored notes at mission and slice altitude; INTENT / PLAN / DELIVERED entries thread down the mission tree with agent authorship + timestamps. +- **Living Notes composer surfaces (S20)** — cheap authoring surfaces make Living Notes the first-class place agents record decisions, plans, and delivered work. +- **One-structure review contract §3.1 (corrective rebuild)** — the review surface reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change **deletes** the old plan and writes a new one — never demotes / stacks multiple competing plans. Replaced an earlier three-column layout after human review. + +### Operator UX (OPR.0.4.4.22) + +- **Agent altitude coordination panel (S22)** — a coordination surface scoped to the right altitude (workspace / mission / slice) so the operator sees actual coordination state without drowning in per-seat detail. Composes with the workspace observability tabs shipped at 0.4.1. + +### Docs closeout (OPR.0.4.4.24) + +- **As-built docs closeout (S24)** — the as-built documentation family (`docs/as-built/architecture.md`, `docs/as-built/cli-reference.md`, codemaps) is caught up to what shipped through 0.4.4. + +### Known Follow-ons + +- **R2 — iOS-Safari S20** — deferred to 0.4.5 for iOS Safari verification. +- **R4 — S18 symlink footgun** — routed to the 0.4.5+ backlog. +- **`openrig-user` stale plugin copy + `whoami --all-hosts` silent host filter** — 0.4.5 candidates. +- **Wider mission-template prose sweep** — post-cut sequencing. +- **Belt-and-suspenders real-host proofs (R13-1 / R1 / R3)** — published-npm Linux install smoke, real registered-host two-host e2e for S13 + S18, and S15 real-host feed-subscription capture sequenced as post-publish lifecycle validation lanes. Shipped code already proven via VM/SSH stand-ins per PM rulings; real-host lanes are defense-in-depth belt-and-suspenders, not release gates. + +--- + ## [0.4.3] - 2026-07-03 **Status**: shipped; "rigs that survive" theme. diff --git a/README.md b/README.md index f8026b3f5..1afa0271f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ rig up conveyor ## First Run -After `product-team` boots, `rig ps --nodes` shows the running pods and seats. When you're ready to shut the team down and bring it back, use the same rig name: +After `product-team` boots, `rig ps` shows the fleet map and `rig ps --nodes --rig product-team` shows the running pods and seats. When you're ready to shut the team down and bring it back, use the same rig name: ```bash rig ps diff --git a/demo/README.md b/demo/README.md index cefd024bd..700e6d87e 100644 --- a/demo/README.md +++ b/demo/README.md @@ -80,7 +80,7 @@ This produces automated proof artifacts in `demo/proof/`: | Artifact | Source | Type | |----------|--------|------| | `up-transcript.txt` | `rig up demo/rig.yaml` output | Automatic | -| `ps-nodes.txt` | `rig ps --nodes` after boot | Automatic | +| `ps-nodes.txt` | `rig ps --nodes --rig ` after boot | Automatic | | `health-after-boot.json` | `check-demo-health.ts` after boot | Automatic | | `native-resume-after-boot.txt` | immediate native probe after boot | Automatic | | `native-resume-after-boot.json` | immediate native probe machine output | Automatic | @@ -91,7 +91,7 @@ This produces automated proof artifacts in `demo/proof/`: | `down-transcript.txt` | `rig down` output | Automatic | | `tmux-check.txt` | `tmux ls` after teardown | Automatic | | `restore-transcript.txt` | `rig restore --rig ` output | Automatic | -| `ps-restored.txt` | `rig ps --nodes` after restore | Automatic | +| `ps-restored.txt` | `rig ps --nodes --rig ` after restore | Automatic | | `browser-screenshot.png` | Explorer + Graph + Detail Panel | **Manual** | | `resume-test.txt` | Post-restore agent context check | **Manual** | diff --git a/demo/run-proof.sh b/demo/run-proof.sh index 24a6e4400..0262d96f2 100755 --- a/demo/run-proof.sh +++ b/demo/run-proof.sh @@ -35,7 +35,8 @@ echo "" # 2. Node status echo "Step 2: Node status after boot..." -rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt" +# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session. +rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-nodes.txt" echo "" # 3. Health check after boot @@ -95,7 +96,7 @@ echo "" # 10. Node status after restore echo "Step 10: Node status after restore..." -rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt" +rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-restored.txt" echo "" echo "=== Automated proof artifacts produced ===" diff --git a/demo/run.sh b/demo/run.sh index 3d8d10944..99b3430d1 100755 --- a/demo/run.sh +++ b/demo/run.sh @@ -32,7 +32,8 @@ fi # Show node status echo "" echo "Node status:" -rig ps --nodes +# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session. +rig ps --nodes --rig "$RIG_ID" # Establish a restore-safe baseline for the demo rig. echo "" @@ -55,6 +56,6 @@ echo "=== Demo topology is running ===" echo "Dashboard: http://localhost:5173" echo "" echo "Next steps:" -echo " rig ps --nodes # Check node status" +echo " rig ps --nodes --rig '$RIG_ID' # Check node status" echo " rig down $RIG_ID # Tear down (auto-snapshots)" echo " rig restore --rig $RIG_ID # Restore the exact snapshot you just created" diff --git a/docs/as-built/README.md b/docs/as-built/README.md index 622072372..ab041c5c3 100644 --- a/docs/as-built/README.md +++ b/docs/as-built/README.md @@ -42,7 +42,7 @@ docs/as-built/ ├── codemap.md ← navigation index (use-case lookup, source-root pointers) ├── frontmatter-schema.md← the frontmatter convention these docs follow ├── cli-reference.md ← full rig CLI surface (kept whole) -├── architecture/ ← 13 backend/runtime modules +├── architecture/ ← 14 backend/runtime modules └── ui/ ← 4 operator-surface modules ``` @@ -67,6 +67,7 @@ at the repo `docs/` root** by design — it is referenced by many existing | [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. | | [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. | | [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. | +| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. | | [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. | | [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. | | [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. | diff --git a/docs/as-built/architecture.md b/docs/as-built/architecture.md index 1cd5598a3..dedc156fd 100644 --- a/docs/as-built/architecture.md +++ b/docs/as-built/architecture.md @@ -32,7 +32,8 @@ resolve. ## Where the major content went -The architecture content is now the **13 modules under `architecture/`**: +The architecture content is now the **14 modules under `architecture/`** +(13 from the slice-08 reorg + `living-notes-review.md`, added v0.4.4): | Module | What moved here | |---|---| @@ -49,6 +50,7 @@ The architecture content is now the **13 modules under `architecture/`**: | [`architecture/plugin-agent-image-context-pack.md`](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs, the Claude auto-compaction enforcer. | | [`architecture/packaging-bootstrap-bundles.md`](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. | | [`architecture/architecture-rules-and-event-system.md`](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules (incl. **rule 15**, restore honesty), the RigEvent union + SSE delivery, intentional compatibility limits. | +| [`architecture/living-notes-review.md`](architecture/living-notes-review.md) | **Added v0.4.4** (not part of the original monolith): the Living Notes review surface — the one intent→plan→delivered projection, staged-approval locks, proof artifacts, freeze export, ranged media serving. | The UI half of the old `### UI architecture` section is now the **4 modules under `ui/`** (see [`ui.md`](ui.md), also a redirect stub, and the `ui/` index diff --git a/docs/as-built/architecture/content-surfaces.md b/docs/as-built/architecture/content-surfaces.md index 0e9f4bcb3..533270a11 100644 --- a/docs/as-built/architecture/content-surfaces.md +++ b/docs/as-built/architecture/content-surfaces.md @@ -19,6 +19,11 @@ last-updated: 2026-05-16 # Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer +> **See also (v0.4.4):** the Living Notes review surface — the composed +> intent→plan→delivered projection over this same allowlisted content layer — +> is documented in [`living-notes-review.md`](living-notes-review.md). + + The **content surfaces** are the operator-allowlisted, filesystem-canonical read/write layer that the Project / Steering UI sits on: a fail-closed file browser, an atomic conflict-checked write service with a JSONL edit audit, @@ -141,7 +146,14 @@ surfaces it as a 500 so the failure is honest, not swallowed.) detection stays honest even on a truncated read; response carries `truncated` / `truncatedAtBytes` / `totalBytes`. - `GET /asset?root&path` — raw bytes for embedded images/video/pdf (inferred - `Content-Type`, 5-min cache). + `Content-Type`, 5-min cache). **v0.4.4 (OPR.0.4.4.20 FR-5):** serves HTTP + **byte ranges** — single-range form, `206` + `Content-Range` + + `Accept-Ranges: bytes` (malformed/unsatisfiable → `416`) — the + iOS-Safari-class media-playback requirement; and `.html` renders as + `text/html` ONLY under the explicit `?render=1` opt-in (text/plain stays + the default). The slice proof-asset route (`/api/slices/:name/proof-asset/*`) + carries the same range semantics (living-notes corrective). See + [`living-notes-review.md`](living-notes-review.md) §5. - `POST /write` — atomic write (§2); 503 when no write service (`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale mtime/hash with the current values for the UI's refresh prompt. diff --git a/docs/as-built/architecture/living-notes-review.md b/docs/as-built/architecture/living-notes-review.md new file mode 100644 index 000000000..8f6cb29cc --- /dev/null +++ b/docs/as-built/architecture/living-notes-review.md @@ -0,0 +1,162 @@ +--- +kind: as-built +title: Living Notes Review Surface — the intent→plan→delivered projection +status: active +topics: [knowledge-and-context, observability, sdlc] +domains: [engineering-advisor, operating-advisor, product-advisor] +applies-when: | + Working on or consuming the composed slice/mission review surface — the + /api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC + convention it projects, staged-approval locks, proof artifacts and the C1 + header, the freeze export, or media serving for review evidence. +siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md] +prerequisite-reads: [workspace-primitive.md] +last-verified-against-source: bb5ad219 +last-updated: 2026-07-06 +--- + +# Living Notes review surface (v0.4.4) + +The review surface is a **pure projection of on-disk markdown into one +reviewable structure per slice**: INTENT → PLAN → DELIVERED. Agents change +files; the daemon re-projects; nothing here writes product state except the +two deliberate approval stamps and the freeze export. Shipped across the +0.4.4 living-notes slices (19 signal layer, 20 composer/surfaces, 22 rig +agents altitude, 23 SDLC operationalization) and the **corrective rebuild** +(2026-07-05/06) that collapsed the surface to the one structure described +here. Everything below is verified at `bb5ad219`. + +## 1. The ONE contract — `ComposedSliceReview` + +`packages/daemon/src/domain/review/types.ts` defines the read contract every +consumer shares (slice Review tab, mission U5 row expansion, For-You +expansion — never a second endpoint per consumer): + +- **identity + phase**: `slice`, `sliceId`, `title`, `missionId`, `phase` + (five-way, derived read-time: `locked > review > building > spec > intent`, + `derivePhase` in `compose.ts`), `laneLabel` (SS14 vocabulary INTENT / PLAN / + BUILD / REVIEW / LOCKED). +- **`intent`**: `{text, media[], ssotPath, degrade}` — the slice README's + `## Intent` section verbatim (`extractSection`), plus any media it embeds. +- **`plan`**: `{concise: {text, media[]}, lockedArtifacts[], lock, ssotPath}` — + the PRD's pinned `## Mini-requirements` tier + planned mockups; the pinned + artifact set is a **frontmatter READ** (`locked-artifacts:` list on the + slice README — no new write machinery); `lock` is the spec-scope + staged-approval stamp (§3). +- **`delivered`**: `{items[], extraProof[], lock, proofDirPath}` — the + redesigned join (§4): each `## Proof contract` deliverable paired with its + curated proof media and QA's recorded comparison signal. `lock` is the + delivery-scope stamp; `proofDirPath` is the drill-in door to the full + fix-loop history. +- **KEPT orthogonal bands**: `needsYou` (two sources, one queue: agent-routed + items ∪ derived ▲ exceptions with inline evidence), `agents` + (scope-parameterized `slice: | mission: | rig` — one contract, all + scopes), `lineage` (`VerifyLineage`: candidate/merge/tip facts + the four + gate `VerdictCell`s rendering recorded tokens verbatim), `defects` + (out-of-slice media refs surfaced, never silently dropped), `composedAt`. + +**Deleted by the corrective (2026-07-05, founder-blocked original):** the +four parallel renderable structures (`sections`, `acceptance`, `compare`, +`join`) and the coequal `green` field. They do not exist in the types, the +composer output, the UI hook, or the fixtures; the daemon route tests assert +the keys are ABSENT on the wire (`test/review-composer.test.ts`, +`test/review-routes.test.ts`). Recorded-verdict green survives in exactly two +places: the per-deliverable `verified` signal (§4) and the mission ledger's +completion fact (§6) — never a slice-review structure. + +## 2. Composer + gatherer split + +- `compose.ts` is **PURE**: `(gathered inputs) → composed doc`; identical + inputs (including caller-supplied `nowIso`/git facts) produce byte-identical + output — idempotence holds by construction and is pinned by tests. +- `gather.ts` (`ReviewGatherer`) is the impure shell: reads slice docs + + `proof/*.md` artifacts from disk, attention/agent rows from SQLite, + approval stamps from frontmatter (cross-checked against the audit log, §3), + git lineage facts from the workspace default repo. Every unreadable source + degrades honestly (nulls / "unknown") — the composer renders the named + degrade, never invented content. + +## 3. The two locks = the shipped staged-approval stamps + +`plan.lock` and `delivered.lock` are projections of the **staged-approval +verb** (`rig scope slice|mission approve --scope spec|delivery` — see +cli-reference "SDLC control plane verbs"): frontmatter stamps +(`approved-spec-by/-at`, `approved-by/-at`) written daemon-side together with +an append-only `mission_control_actions` audit row (no half-stamp). The +gatherer cross-checks each stamp against the pinned scope-approval +`audit_notes_json` shape (`approval_scope` + scope identity); a stamp with no +matching row projects `auditVerified: false` and renders as an UNVERIFIED +stamp — visible, never a block. Approval is freeze/sign-off, **never** +proven-green (BR-6). + +## 4. DELIVERED — promised ↔ curated proof ↔ verified + +`composeDelivered` joins the PRD's `## Proof contract` checkbox items +(`extractProofContract`; an optional markdown image on the checkbox line +becomes the deliverable's `plannedRef` mockup) to the C1-headed proof +artifacts in `/proof/`: + +- An artifact **covers** a deliverable when its C1 `evidences:` list names the + item (exact text or 1-based index). +- `verified` binds to the shipped C1 fields, never mere presence: + **verified** = a covering `qa|adjudication` artifact records the comparison + (`self_check`) AND its recorded verdict is passing; **unverified** = some + covering artifact without a passing recorded QA comparison (QA's + why-kicked-back `note` still surfaces); **missing** = promised, nothing + delivered. All three are render states — fail-open by construction. +- The curated proof set is the covering artifacts' body media refs + (`ProofArtifact.mediaRefs`, populated by `rig proof add --media` or markdown + refs in the artifact body), normalized slice-relative; refs escaping the + slice dir become `defects` findings and are never served or inlined. + Unmapped-artifact media renders bounded as `extraProof`. +- The ▲ insufficient-proof NEEDS-YOU exception fires from the delivered + MISSING count (`deriveExceptions`). + +## 5. Routes, freeze, media serving + +- `packages/daemon/src/routes/review.ts`, mounted at `/api/review` + (`server.ts` route mounts): `GET /slice/:name`, `GET /mission/:name`, + `GET /rig` (the OPR.0.4.4.22 rig-agents altitude root), `GET + /agents?scope=slice:|mission:|rig`, `POST /freeze`. +- **Freeze** (`freeze.ts`): the ONE synchronous compose-and-freeze path, + invoked after the delivery stamp + audit row commit. Renders the composed + review to a single self-contained HTML file (`REVIEW--.html` in + the slice dir): CSS inline, images data-URI-inlined under slice-dir + containment (resolve-prefix + realpath — a traversal/symlink ref renders + the muted outside-slice branch, never inlined), video by link + poster. + Exclusive-create through the allowlist-governed atomic write service; + re-invocation is an idempotent no-op; a failed render never un-approves. + The freeze moment also folds the mission brief spine (`brief-spine.ts`) + into `MISSION_BRIEF.md`, section-scoped, schema-order-preserving. +- **Media serving** for review evidence is ranged on BOTH asset route + families (iOS-Safari-class players require `206`): `GET /api/files/asset` + (`routes/files.ts` — single-range parse, `206`/`416`, `Accept-Ranges: + bytes`; `.html` renders as text/html only under the explicit `?render=1` + opt-in) and `GET /api/slices/:name/proof-asset/*` (`routes/slices.ts` — + same range semantics via `fileAssetResponse`, added by the corrective QA + fixback; immutable-cache posture retained). + +## 6. Mission + rig altitudes + +`composeMissionReview` consumes `{review, green}` entries per slice: the +board (stage-specific cells re-bound to the collapsed contract — spec-stamp +state, delivered n/m, GREEN·merge pair, stamp), the completion **ledger** +(a query over the slice set, never an authored list; `green` here is the +recorded-verdict fact from `computeRecordedGreen` — regime 1 = four passing +gate verdicts, regime 2 = passing adjudication), `cutComplete` (TRUE only +when every in-cut slice is green AND merged AND has zero open needs-human +items), and the union NEEDS-YOU (distinct identities — one item seen from N +altitudes is one item). `composeRigAgents` serves the standalone rig +altitude (roster ∪ recently-holding, park/health/settled from the queue +transitions log). + +## 7. The on-disk convention it projects (SDLC control plane) + +The surface projects the markdown-control-plane shapes operationalized by +OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`: +`## Intent` (README) / `## Mini-requirements` + `## Proof contract` (PRD) / +`proof/` C1-headed artifacts + `PROOF.md`, scaffolded for every template kind +by `rig scope slice create` and checked advisory-only by `rig scope audit` +(+ the `rig doctor` SDLC row). The UI half of this surface (the slice Review +tab, mission board, For-You approve+chat) is documented in +[`../ui/project-and-for-you.md`](../ui/project-and-for-you.md). diff --git a/docs/as-built/cli-reference.md b/docs/as-built/cli-reference.md index 095edf7ef..e72f90843 100644 --- a/docs/as-built/cli-reference.md +++ b/docs/as-built/cli-reference.md @@ -59,6 +59,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex | `unarchive` | Unarchive a rig (reverse of `rig archive`): returns it to the default view | | `add` | Add a member to an existing pod in a running rig (`add_member` converge op) | | `env` | Inspect and control rig environment services | +| `file` | Cross-host file movement over ssh/rsync (v0.4.4; one explicit verb: `copy`) | | `ps` | List rigs and their status | | `mcp` | MCP server for agent integration | | `agent` | Manage agent specs | @@ -117,6 +118,7 @@ Subcommands: Notes: - `start` launches the daemon process and accepts runtime overrides for port, host, and DB path. - `logs` reads daemon log output and can follow it. +- **Deploy identity (v0.4.4, OPR.0.4.4.11 FR-6/7)**: a PACKAGED build (built via `scripts/build-package.sh`) is stamped with `{semver, commit, dirty, builtAt}`; the daemon's `/healthz` payload carries the four stamp fields additively and `rig --version` renders ` ([, dirty])`. A source/dev run has NO stamp and adds NOTHING (never an invented SHA) — `/healthz` keeps its legacy body and `--version` prints the plain semver. This is the 30-second stale-deploy diagnostic: an unstamped or old-commit `/healthz` on a long-running host means you are looking at an older deployed build, not the source tree. Source: `packages/{daemon,cli}/src/build-info.ts` (`stampFields`), `packages/daemon/src/server.ts` (`/healthz`), `packages/cli/src/version.ts`. ### `rig status` @@ -153,6 +155,8 @@ Supported keys: - `snapshots.periodic.enabled` (default `true`) — daemon-side periodic snapshot scheduler on/off (v0.3.4) - `snapshots.periodic.interval_seconds` (default `300`) — interval between periodic snapshots - `snapshots.periodic.retention_keep` (default `10`) — number of periodic snapshots to retain per rig +- `feed.subscriptions.{action_required|approvals|shipped|progress|audit_log}` (booleans) — the For-You feed's five flat lens toggles (`OPENRIG_FEED_SUBSCRIPTIONS_*` env mapping) +- `feed.subscriptions..enabled` (boolean; **v0.4.4, OPR.0.4.4.15**) — ONE registered dynamic key CLASS (not a general dynamic-key mechanism): per-host feed subscription toggles for the aggregated multi-host For-You feed. `hostId` segment charset `[A-Za-z0-9_-]+` (dotted host ids are inexpressible in dotted keys and reject as unknown); the flat toggle tails + `enabled` are RESERVED segments in both spellings, so a host id can never shadow a flat key. No env-var mapping for the dynamic class in v1 — file/API only. The CLI config store carries the same class (parity-pinned against the daemon store). Precedence: - CLI flag @@ -328,6 +332,7 @@ Current behavior notes: - If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`. - Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library. - v0.3.2 paper-cut fix-round (slice-22): pre-launch failures now return structured HTTP 4xx (`cycle_error` / `preflight_failed` / `validation_failed` / `service_boot_failed`) instead of bare 500; failed boots no longer leave orphan rig records on disk. +- **v0.4.4 (OPR.0.4.4.11) — whole-topology sources**: a `.rigtopology` manifest (or a YAML file whose body declares the topology form) boots MULTIPLE rigs in one staged spin-up. v0 manifest entries are **spec paths only** (a closed-key manifest: `.rigbundle` and bare library-name entries are rejected at parse time with per-entry what/why/fix naming the v0 boundary). Per-entry `host: ` is the ONLY placement mechanism for topology entries — `rig up --host ` is REJECTED pre-dispatch (two placement mechanisms must not coexist). The launcher acquires per-rig launch locks route-side and reports a CLOSED per-entry aggregate `{ok | failed | skipped}` (skipped is explicit — a lock conflict or upstream failure never reads as silent success). Source: `packages/cli/src/commands/up.ts` (`.rigtopology` sniff + `--host` rejection), `packages/daemon/src/domain/topology/{topology-manifest,multi-rig-launcher,remote-up-leaf}.ts`, `packages/daemon/src/routes/up.ts`. - v0.3.4: `rig up` is resume-original-by-default for existing rigs. Per-seat opt-in to deliberate fresh-prime is via `--fresh `. The five-term restore status vocabulary surfaced per-node is `resumed` / `fresh-primed` / `awaiting-decision` / `attention_required` / `failed`. On TTY, `awaiting-decision` nodes trigger an interactive [y/N] ASK; in headless mode they are reported honestly with the exact `rig up --existing --fresh ` follow-up command. Success modes: @@ -402,20 +407,20 @@ Usage: - `rig ps --nodes [--json] [--full] [--rig ] [-A | --all-rigs] [--session ] [--limit ] [--fields ] [--summary] [--filter ] [--active] [--host ]` Notes: -- **v0.4.0 — current-rig default + compact-by-default (slices 25 + 34)**: the default breadth is **current-rig** (derived from `OPENRIG_SESSION_NAME`'s `@` suffix), not all-rigs. `-A` / `--all-rigs` widens to fleet breadth. `--rig ` overrides the current-rig default explicitly. `--session ` narrows further. The default `--json` output is a compact TL;DR projection per node: `session`, `rig` (to disambiguate under `-A`), `activity` (state + reason), `assigned` / `pending` counts, resume summary as `resumeType` + `resumeTokenPresent` (boolean — NOT the token value, per the slice-34 security correction). `--full` returns the complete per-node record (raw byte-equivalent passthrough — preserves the prior shape including `tmuxAttachCommand`, `resumeCommand`, `contextUsage`, `agentActivity` full, `restoreOutcome`, etc.; `resumeToken` value is still part of `--full` for downstream consumers that need it). All-states stays the default (per the orch-lead-grounded ruling: ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal — unlike queue-list which defaults to active items only). `--active` / `--running` is the opt-in active-filter (already existed). Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb. +- **v0.4.4 — consolidated all-rigs default + disclosure ladder (OPR.0.4.4.21)**: the default is **every ACTIVE rig, one compact row each** — O(rigs), never a fleet node fan-out — plus three load-bearing display elements: the host rollup line ("N rigs · M seats · K need attention"), the archived/stopped count line (history folds to ONE line), and the affordance footer teaching the drill ladder. The v0.4.0 current-rig default is RETIRED (it hid running rigs from the operator's field of view); the session-rig default now applies ONLY to `--nodes`, and only locally — **implicit scope defaults don't cross host boundaries** (remote `--nodes` requires explicit `--rig` or `-A`). `-A`/`--all-rigs` keeps exactly ONE meaning: the `--nodes` fleet widener; bare `-A` is a structured teaching error naming `--include-archived` for history. STATED contract: default `--json` is a bare array of ALL non-archived rigs INCLUDING stopped ones (existing keys preserved; additive `attentionCount`); only the human table folds stopped rigs. Fan-out (`--all-hosts`/`--hosts`) emits the intra-P4 shared `AggregatedPayload` (hostId-stamped `items` + closed-enum per-host `hosts[]` statuses) and is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows. Migration from the old firehose: `rig ps --nodes -A --full`. The default `--json` output is a compact TL;DR projection per node: `session`, `rig` (to disambiguate under `-A`), `activity` (state + reason), `assigned` / `pending` counts, resume summary as `resumeType` + `resumeTokenPresent` (boolean — NOT the token value, per the slice-34 security correction). `--full` returns the complete per-node record (raw byte-equivalent passthrough — preserves the prior shape including `tmuxAttachCommand`, `resumeCommand`, `contextUsage`, `agentActivity` full, `restoreOutcome`, etc.; `resumeToken` value is still part of `--full` for downstream consumers that need it). All-states stays the default (per the orch-lead-grounded ruling: ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal — unlike queue-list which defaults to active items only). `--active` / `--running` is the opt-in active-filter (already existed). Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb. - **Daemon node-list payload trimmed at source (slice 26)**: `recoveryGuidance` is no longer serialized as near-identical templated prose on every node — relocated to a guidance-by-reference map at the top level so the 4 current consumers still resolve it. `contextUsage` is a compact summary in the list payload (full telemetry remains retrievable per-node via `rig whoami` / detail queries). Even `--full` and the UI consumers stop paying for the redundant per-node blobs. -- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`. -- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command. +- `rig ps` lists rig summaries. Default human columns (v0.4.4): `RIG`, `NODES`, `RUNNING`, `ACTIVE`, `WORK`, `ATTN`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`; `ATTN` is the additive attention count. +- `rig ps --nodes` expands into the current (or `--rig`-named) rig's node inventory — `--nodes -A` for the cross-rig inventory (v0.4.4 scoping). Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command. - JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set. - Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit ` sets an explicit bound. - `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead. -- `--fields ` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream. +- `--fields ` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `activeCount`, `hasWorkCount`, `attentionCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `oriented`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `terminalActive`, `hasAssignedWork`, `pendingWorkCount`, `agentActivity`, `contextUsage`, `heldReason`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream. - `--filter ` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do). - `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture. - `--host ` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract. - Exit codes: - `0` success - - `1` daemon not running, or invalid `--filter` / `--limit` + - `1` daemon not running, or invalid `--filter` / `--limit` / `--fields` - `2` daemon fetch failure ### `rig snapshot` @@ -786,11 +791,10 @@ Notes: ### Cross-host execution (`--host `) -`rig send` and `rig capture` accept a `--host ` flag that routes the -command to a remote host over single-hop SSH. v0 is CLI-side shell-out -only — the local daemon is not involved in routing, and there is no -daemon-to-daemon networking. The remote host is expected to have its own -managed `rig` available on `$PATH`. +Cross-host commands route to a remote host declared by id. SSH-transport +commands use single-hop SSH CLI-side shell-out; HTTP-transport commands talk to +the remote daemon API. The local daemon is not involved in SSH routing. The +remote host is expected to have its own managed `rig` available on `$PATH`. Hosts are declared by the operator in `~/.openrig/hosts.yaml`: @@ -801,18 +805,19 @@ hosts: target: vm-claude-test.local user: your-username # optional notes: "Tart VM" # optional - - id: laptop-b - transport: ssh - target: laptop-b.tail-scale-net - user: your-username + - id: factory-http + transport: http + url: http://100.64.1.2:7433 + bearer_env: FACTORY_HTTP_TOKEN ``` Validation rules: - `hosts` is required and must be a non-null array. -- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP). -- `user` and `notes` are optional. -- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly). +- Each entry: `id` required (non-empty, unique), `transport` required (`ssh` or `http`). +- SSH entries require `target` (non-empty — DNS name, SSH config alias, or IP); `user` and `notes` are optional. +- HTTP entries require `url` plus exactly one bearer pointer (`bearer_env` or `bearer_file`); pointers are config names/paths, never resolved token values. +- `rig host add/list/doctor` covers the standard path; hand-editing remains the path for exotica. - A missing or invalid file returns a clear error pointing at the canonical path. The CLI distinguishes four structured failure modes (operators get an @@ -823,7 +828,70 @@ actionable error per mode; JSON output preserves the `failedStep` enum): - `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh rig daemon start`. - `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced. -Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover. +**The transport posture (OPR.0.4.4.13 FR-4 — DECIDED, pm-ruled: document, no parity).** The +partition is the intended posture, not an accident of history: **ssh carries interactive pane +ops, http-bearer carries daemon REST ops, `ps`/`whoami` follow the host's DECLARED transport.** +There is NO cross-transport fallback, and NO http parity for `send`/`capture` ships in 0.4.4 +(parity would be new attack surface with no scope-locked need). Per-command: + +| Command | ssh transport | http transport | Fan-out (`--all-hosts`/`--hosts`) | +| --- | --- | --- | --- | +| `rig send` | ✓ (ssh-ONLY) | ✗ | ✗ | +| `rig capture` | ✓ (ssh-ONLY) | ✗ | ✗ | +| `rig up` / `rig down` / `rig launch` | ✗ | ✓ (http-ONLY) | ✗ | +| `rig file copy` (v0.4.4) | ✓ (ssh-ONLY, rsync-over-ssh) | ✗ | ✗ | +| `rig ps --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts appear as STRUCTURED `unsupported-transport` statuses in `hosts[]` | +| `rig whoami --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts are currently SILENTLY FILTERED from the fan-out (a shipped gap, routed for 0.4.5 triage — differs from ps's structured status) | +| `rig host doctor` | ✓ | ✓ | n/a (single host) | + +Out of scope in 0.4.4: cross-transport fallback; http parity for `send`/`capture`; connection +pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover. + +### `rig file` (v0.4.4 — OPR.0.4.4.18) + +Usage: `rig file copy [--dry-run] [--json]` + +Cross-host file movement over ssh/rsync — v0 ships ONE explicit verb, `copy`, +for a single file. + +Operand grammar (parsed, never guessed): +- `:` = remote (the `` must resolve in the ssh + hosts registry; remote paths must be absolute). +- Bare path = local. A LOCAL file whose name contains a colon needs the `./` + prefix (`./weird:name.txt`) — the grammar refuses the ambiguous form with a + structured error instead of guessing. +- Valid shapes: local→remote, remote→local, local→local. remote→remote is not + a v0 shape. + +Semantics + safety wall (source: `packages/cli/src/lib/file-transfer.ts`): +- An existing destination is **OVERWRITTEN** (v0 copy semantics, stated) — + preview with `--dry-run`, which prints the exact planned transfer (src, dst, + host, files/bytes) and moves nothing. +- **Default-deny wall over live agent/credential state**: paths resolving into + the closed deny set `~/.openrig`, `~/.ssh`, `~/.codex`, `~/.claude` — plus + the ACTIVE `OPENRIG_HOME` and the active hosts registry file — are refused + with a named what/why error (extension of the deny set requires a ruling, + never a silent widening). +- Traversal is rejected on the RAW operand: a `..` path segment refuses + BEFORE any normalization (normalization collapses `..`, so a post-normalize + check would be dead code); remote paths are additionally restricted to a + shell-inert charset (`A-Za-z0-9._/-` — restriction over escaping, because + both rsync implementations in the fleet differ on quoting flags), and every + rsync invocation pins operands behind `--` and spawns argv-style with no + shell. +- Transport is ssh-only (see the transport-posture table above); the remote + side uses the same ssh registry entries `rig send`/`capture` use. + +### `rig host` + +Usage: `rig host ` — the multi-host registry verbs (OPR.0.4.4.13; capped at +exactly these three — no edit/remove/tunnel/bootstrap verbs; hand-editing `hosts.yaml` remains +the path for exotica, and the factory bootstrap ships as script + runbook at +`docs/reference/product-factory-vps-runbook.md`). + +- `add --id --transport [--target --user | --url --bearer-env |--bearer-file

] [--notes ] [--json]` — writes the entry validated by the registry loader's OWN rules (add-time errors are load-time errors, verbatim; duplicate ids refused). Rewrites `hosts.yaml` canonically (hand-authored comments are not preserved). +- `list [--json]` — id/transport/target plus AUTH as a config POINTER (`env:NAME` / `file:PATH` / `ssh-key`); never a resolved secret value. +- `doctor [--posture product-factory-vps] [--public-addr ] [--json]` — stepwise, honest verification: transport reachability → remote `rig` binary (+version) → remote daemon health → remote identity; each failing step is a DISTINCT actionable error, and unknown host ids surface as the registry error class. `--posture` runs the ONE built-in baseline (`product-factory-vps`): every item reports pass/fail/**unknown** individually with a fix per non-pass — UNKNOWN is never pass; the public `:7433`/`:22` probes need `--public-addr` (outside vantage) and a reachable public daemon port FAILS loudly. Exit `1` on any fail. ### `rig broadcast` @@ -1049,11 +1117,11 @@ Notes: ## Mission Control / Queue Observability (PL-005 Phase A) -Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only." +Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. PL-005 originally named the read-only node surface as `rig ps --nodes --json`; under the v0.4.4 disclosure ladder, the fleet-wide projected node source is `rig ps --nodes -A --json`. Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture/mission-control.md`. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`. -Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator. +Mission Control consumes `rig ps --nodes -A --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator. ## Agent Images, Context Packs, and Workspace (v0.3.0) @@ -1101,7 +1169,7 @@ Usage: `rig workspace ` — Workspace Primitive (PL-007), v0 typed-k Subcommands: - `validate [root] [--kind ] [--no-recursive] [--require-frontmatter] [--max-files ] [--json]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`. `--kind` validates against a specific workspace kind (`user | project | knowledge | lab | delivery`). `--max-files` (default `10000`) hard-caps the walk; v0.3.2 slice-01 GA enforces strict-int regex on this flag. -- `doctor [--workspace ] [--strict] [--json]` — v0.3.2 slice-21 FR-5. Run a 7-check workspace-readiness diagnostic against the daemon's resolved workspace (workspace root, missions folder, file allowlist, daemon alignment, daemon reload, optional slice docs, MISSION_NOTES presence). Read-only. Default exit-code: non-zero only on `fail`; `--strict` makes warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST` from the operator's shell env so doctor reflects the operator's intended allowlist even when the daemon's env is unchanged. +- `doctor [--workspace ] [--strict] [--json]` — v0.3.2 slice-21 FR-5 (+1 check v0.4.4 slice 23). Run an 8-check workspace-readiness diagnostic against the daemon's resolved workspace (workspace root, missions folder, file allowlist, daemon alignment, daemon reload, optional slice docs, MISSION_NOTES presence, SDLC convention sections — the last advisory-warn per `docs/reference/sdlc-conventions.md`). Read-only. Default exit-code: non-zero only on `fail`; `--strict` makes warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST` from the operator's shell env so doctor reflects the operator's intended allowlist even when the daemon's env is unchanged. Notes: - v0 surface was intentionally narrow (`validate` only); v0.3.2 added `doctor` as the operator-facing readiness diagnostic. @@ -1150,7 +1218,7 @@ Two subcommand groups: `slice` and `mission`. `rig scope slice ` — slice-tier commands: - `ls [--mission ] [--state ] [--json]` — list slices in a mission (or across all missions). `--state` filter: `active | closed | shipped | all` (default `active`). - `show [--mission ] [--json]` — inspect a single slice (frontmatter + README + children). `slice-path` is absolute, relative-to-substrate, or `NN-slug`; `--mission` hints the mission when path is just `NN-slug`. -- `create [--template ] [--title ] [--json]` — create a new slice in a mission. `--template` default `placeholder`. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` (the structure the OpenRig PROGRESS UI page parses) plus a convention-correct README with proper frontmatter per `conventions/scope-and-versioning/README.md`, for EVERY template. Fixes the v0.3.x bug where newly-created slices were missing `PROGRESS.md` entirely. +- `create [--template ] [--title ] [--json]` — create a new slice in a mission. `--template` default `placeholder`. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` (the structure the OpenRig PROGRESS UI page parses) plus a convention-correct README with proper frontmatter, for EVERY template. **v0.4.4 (slice 23)**: EVERY template kind additionally emits the SDLC convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`, kind-specific body below them), a `proof/` dir, `PROOF.md`, and an `IMPLEMENTATION-PRD.md` skeleton carrying the elastic-middle note — the shapes the Living Notes UI projects. Conventions SSOT: `docs/reference/sdlc-conventions.md`. - `progress [--mission ] [--status ] [--milestone ] [--owner ] [--note ] [--json]` — **v0.4.0 (slice 33)** new verb: append / set / update progress entries deterministically. Writes the canonical structure the OpenRig PROGRESS UI page reads. Replaces hand-editing `PROGRESS.md` with markdown. - `stage [--mission ] [--successor ] [--json]` — **v0.4.0 (slice 35)** new verb: set the slice's `stage` frontmatter (wip / provisional / established / canonical / superseded / retired) deterministically. `superseded` REQUIRES `--successor ` (rejected otherwise + records the successor); `retired` warns "do not use"; invalid stages rejected with the valid set named. - `verified --against "" [--mission ] [--json]` — **v0.4.0 (slice 35)** new verb: stamp the slice's `verified` line with `verified: against `. `--against` is MANDATORY (bare timestamps rejected — the anti-stale keystone per `conventions/scope-and-versioning` §2). Overwrites the prior verified line. @@ -1170,8 +1238,16 @@ Two subcommand groups: `slice` and `mission`. Convention compliance: `rig scope` together with slice 33 (`PROGRESS.md` + scaffolding) and slice 35 (`stage` / `verified` / `reconcile`) makes `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` (§1 dot-IDs, §2 maturity vocabulary). Agents update the convention through commands rather than hand-editing markdown. +### SDLC control plane verbs (v0.4.4) + +The conventions these verbs operate live in ONE shipped document: `docs/reference/sdlc-conventions.md` (copied into the assembled CLI package); the operating procedure is the packaged `mission-slice-sop` skill. + +- `rig scope slice|mission approve [--scope spec|delivery] [--actor ] [--on-behalf-of ] [--json]` — **v0.4.4 (slice 19 FR-9)** the two staged-approval locks, one daemon-side write path (frontmatter stamp + append-only audit row land together; no half-stamp). `--scope spec` = the **plan-lock** ("the PRD matches my intent; this artifact set gets built"); `--scope delivery` (default) = the **proof-lock** (terminal sign-off; fires the freeze). Approval is freeze/sign-off — never proven-green (proven-green requires recorded C1 verdicts). +- `rig proof add --artifact-type --verdict --candidate-sha --money-evidence "" [--file |--body ] [--evidences ] [--media ] [--self-check ] [--json]` — **v0.4.4 (slice 19 FR-8; `--media` via the corrective §3.4)** drop a proof artifact into `/proof/` with the machine-readable C1 header, validated at drop time (closed sets above). `--evidences` (item text or 1-based index) joins the drop to the slice's `## Proof contract` items — the pairing the Living Notes DELIVERED section renders; `--media` (proof/-relative refs, containment-checked, never absolute) names the curated media the drop stands behind, projected into the DELIVERED items' proof set. Contract/self-check outputs are advisories (exit 0), never gates. +- `rig scope audit [--json]` — extended **v0.4.4 (slice 19 FR-10 + slice 23)** with C1-header and IMPLEMENTATION-PRD backstops plus the convention-section advisories (missing `## Intent`, missing/malformed `## Mini-requirements`, missing/malformed `## Proof contract`, UI-slice-without-mockup — a mockup ref means a real image ref or plannedRef token, never bare prose). All advisory/fail-open: the exit code flips on HIGH findings only; the convention advisories are low/info by construction. + Notes: -- Surface source-verified against `packages/cli/src/commands/scope.ts` at `51554eee` (v0.4.0 post-slice-35). +- Surface source-verified against `packages/cli/src/commands/scope.ts` at `51554eee` (v0.4.0 post-slice-35); SDLC control-plane verbs source-verified against `scope.ts` / `proof.ts` / `scope-audit.ts` in OPR.0.4.4.23. ## Skill Cascade Audit (v0.4.0) diff --git a/docs/as-built/codemap.md b/docs/as-built/codemap.md index e25049f8f..8821c966a 100644 --- a/docs/as-built/codemap.md +++ b/docs/as-built/codemap.md @@ -66,6 +66,7 @@ One row per module. "Reach for this when…" mirrors each module's actual | `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. | | `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. | | `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. | +| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. | ### ui/ @@ -150,6 +151,7 @@ Reading-order rule: open a module's `prerequisite-reads` first | rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` | | The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` | | The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` | +| The composed slice/mission review (`/api/review/*`), staged-approval locks, proof artifacts + `verified`, freeze export, ranged media | `architecture/living-notes-review.md` | | Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` | | Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` | | The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` | @@ -180,6 +182,7 @@ Source-grounding for each claim lives inside the module with file:line cites. | `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` | | `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` | | `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` | +| `architecture/living-notes-review.md` | `packages/daemon/src/domain/review/{types,compose,gather,freeze,brief-spine}.ts`, `packages/daemon/src/routes/{review,files,slices}.ts`, `packages/cli/src/commands/{scope,proof}.ts`, `packages/ui/src/components/review/` | | `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` | | `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` | | `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) | diff --git a/docs/as-built/ui/project-and-for-you.md b/docs/as-built/ui/project-and-for-you.md index faa1bab3b..63b60df2b 100644 --- a/docs/as-built/ui/project-and-for-you.md +++ b/docs/as-built/ui/project-and-for-you.md @@ -14,7 +14,7 @@ applies-when: | siblings: [shell-and-routing.md, ../architecture/mission-control.md] prerequisite-reads: [../README.md, shell-and-routing.md] last-verified-against-source: 7eaf524c -last-updated: 2026-05-16 +last-updated: 2026-07-06 --- # UI Project Observability, For You, Dashboard @@ -166,19 +166,29 @@ handoff]` — defined at the Mission Control system level (slice-00 > annotate→annotation L98; hold/drop→reason L99) @HEAD; slice-00 > 0.3.0-GT §1.6 + seam (c). -> **OPEN — For-You verb-subset (slice-00 0.3.0-GT OPEN-3; NOT smoothed).** -> `VerbActions` accepts an `enabledVerbs` prop to restrict the offered -> verbs (`VerbActions.tsx:21-22`). The For-You `FeedCard` currently -> passes `enabledVerbs={["approve", "deny", "route"]}` -> (`FeedCard.tsx:492`). **The exact shipped For-You-surface verb subset -> is an unresolved velocity slice-01 ruling** (slice-00 0.3.0-GT OPEN-3: -> CHANGELOG `[0.3.1]` enumerates the full 7 on `FeedCard`, while -> source-material §6 frames it as a "subset" — the discrepancy is OPEN). -> Per the slice-08 contract this module describes the **7-verb -> system-level vocabulary only** and does NOT assert a definitive -> For-You verb subset. The `enabledVerbs` prop value above is reported -> as the literal current source state, explicitly NOT as a resolution -> of OPEN-3. +> **OPEN-3 RESOLVED (v0.4.4 living-notes corrective, founder ruling +> N-1, 2026-07-05).** The For-You actionable-card surface is **bare +> one-tap APPROVE + CHAT — nothing else**. `FeedCard` passes +> `enabledVerbs={["approve"]}` + `oneClickVerbs={["approve"]}` + the +> v0.4.4 `bare` prop (JSX-only: skips the "Choose response" header +> chrome; the mutation/receipt/error paths are byte-identical — pinned +> by a raw-body test), beside a CHAT button that opens the **shared +> `ProgressiveTerminal`** seeded via `buildChatPreamble` (terminal, +> never a chat panel — BR-12). Deny/route are RETIRED from this +> surface (including the action-required lens's empty-state copy: +> "one-tap approve and chat with the owning agent"); the card-level +> kind tag is the status label "Action required", never instruction +> chrome. The 7-verb vocabulary above remains the MISSION-CONTROL +> system-level vocabulary; For-You offers the two-action subset. +> +> Source @`bb5ad219`: `FeedCard.tsx:547-593` (bare `VerbActions` +> `enabledVerbs=["approve"]` :561-566 + chat button + inline +> `ProgressiveTerminal`), `:87-94` (`resolveCardTerminalSession` — +> human-action cards chat with the SENDER), `Feed.tsx:76-82` +> (`EMPTY_COPY["action-required"]`), `VerbActions.tsx` (`bare` prop), +> `review/chat.ts:21` (`buildChatPreamble`); tests +> `test/foryou-bare-approve-chat.test.tsx` (byte-identical mutation + +> chatBtns:1/denyRoute:0 + retired-copy source-scan). On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed` keeps an optimistic-outcome map keyed by `qitemId` so the @@ -295,7 +305,8 @@ misconfiguration). `progress` folds in `AcceptanceTab` (acceptance is the canonical slice-scope progress proof); `story` renders `TimelineTab` with curated `timeline.md` (`useSliceTimelineMarkdown`) above the auto-captured event feed; `topology` renders the slice's -workflow-instance-aware `TopologyTab`. +workflow-instance-aware `TopologyTab`; **`review` (v0.4.4) renders the +Living Notes `SliceReviewTab`** (§3.5). > Source: `components/project/ScopePages.tsx:1187-1297` (`SliceScopePage`; > default `overview` `:1192`, timeline md `:1204`, loading-state @@ -318,6 +329,51 @@ active tab (`active !== "overview"`) so the overview path stays cheap. > rollup components `:219-532`; gate `:679`,`:754` (`active !== "overview"`) > @HEAD. +### 3.5 Review tabs (v0.4.4 — the Living Notes surface) + +Both the slice and mission scope pages mount a `Review` tab +(`SLICE_TABS`/mission tabs, `ScopePages.tsx:99-113`). The slice tab +(`review/SliceReviewTab.tsx`) renders the ONE reviewable structure per +slice — bands in order **NEEDS YOU → AGENTS → INTENT / PLAN / DELIVERED → +verify-lineage → SETTLED** — from `GET /api/review/slice/:name` +(`hooks/useReview.ts`): + +- **DELIVERED** pairs each planned deliverable with its curated proof down + the column (planned mockup above delivered artifact), media inline at text + height expanding on tap one-at-a-time (`?item=` deep link); `verified` + renders QA's recorded comparison in plain words (✓ QA-verified · ◇ + unverified · ✗ missing + QA's note) — visible, never blocking. "See all + proof" drills into `proof/`. +- **Media actually plays**: video renders inline with native controls + (`?seek`/`?play` deep links for capture verification); images open the + shipped `Lightbox`; evidence and the "full PRD →" door open the shipped + `FileViewer` in the shared **right-edge** drawer (the v0.4.4 corrective + reverted the FR-11.1 left flip at its three tokens). +- **Two locks** (§4 of the architecture module) render as deliberate stamps: + plan-lock in PLAN, both stamps in SETTLED; an unaudited stamp renders + loudly as UNVERIFIED. +- Review cards ride ONE vellum recipe (`review/vellum.ts` — + background-matched translucency + backdrop blur, token-driven for both + themes); DELIVERED rows stack vertically below the `sm` breakpoint + (phone-first single-column scan). +- The mission tab (`review/MissionReviewTab.tsx`) is board-first: stage + cells from the collapsed contract, the completion ledger, cut-complete, + and a U5 row expansion reading the SAME contract (intent verbatim + a + bounded per-item verified summary; the full pairing lives on the slice + page). The rig altitude (`review/RigAgentsPage.tsx`) reads + `GET /api/review/rig`. + +Backend contract + composition: see +[`../architecture/living-notes-review.md`](../architecture/living-notes-review.md). + +> Source @`bb5ad219`: `components/review/{SliceReviewTab,MissionReviewTab, +> NeedsYouAccordion,AgentsBandView,VerifyLineageCard,EvidenceOpener, +> RigAgentsPage}.tsx`, `review/vellum.ts`, `hooks/useReview.ts`, +> `SharedDetailDrawer.tsx` (right-edge anchor + pre-flip z); +> tests `test/{fileviewer-resolvable-target,drawer-primitives}.test.tsx`. +> The rejected three-column compare and the separate item-join table are +> DELETED files (corrective delete-not-demote; zero references remain). + ## 4. Dashboard — the landing surface (`/` → `Dashboard`) `Dashboard` is a thin composition over the **0.3.1 vellum brand diff --git a/docs/reference/agent-startup-guide.md b/docs/reference/agent-startup-guide.md index 4880385a5..e73d27061 100644 --- a/docs/reference/agent-startup-guide.md +++ b/docs/reference/agent-startup-guide.md @@ -311,7 +311,7 @@ This prompts the agent to actually invoke the skills, not just have them as pass ## System Check After identity recovery, verify: -1. `rig ps --nodes` shows your rig running +1. `rig ps --nodes` shows your rig running (scoped to your session's rig by default; outside a managed session name it explicitly: `rig ps --nodes --rig `) 2. `rig env status` shows services healthy (if applicable) 3. Your working directory is correct 4. Required tools are available (node, npm, git, etc.) diff --git a/docs/reference/product-factory-vps-runbook.md b/docs/reference/product-factory-vps-runbook.md new file mode 100644 index 000000000..bd0b9b8dc --- /dev/null +++ b/docs/reference/product-factory-vps-runbook.md @@ -0,0 +1,132 @@ +# Product-Factory VPS Runbook (product-factory-vps) + +OPR.0.4.4.13 — the productized form of the 2026-06-23 OVH smoke test: a fresh +Ubuntu VPS becomes an OpenRig product factory managed from a home base, with +the proven security posture as the encoded default. The bootstrap is a +SCRIPT + THIS RUNBOOK by design — host verbs are capped at `add`/`list`/ +`doctor`, and the acceptance anchor is the observable end state: home-base +`rig host doctor ` all green. + +## 1. Prerequisites + +- A fresh Ubuntu 22.04/24.04 VPS (any provider; the lived reference was OVH). +- A Tailscale tailnet you administer; the home base already on it. +- A home-base SSH keypair for the factory (`ssh-keygen -t ed25519 -f ~/.ssh/openrig-factory`). +- The published OpenRig artifact (`@openrig/cli@`) or the packaged + tarball a release branch builds (`npm pack` output) for pre-release proof. +- Dedicated Claude/Codex accounts for THIS factory. Never copy home-base + credentials (`~/.claude`, `~/.codex`, `~/.openrig`) onto a VPS. + +## 2. Tailscale tag + ACL (one-time per tailnet) + +In the Tailscale admin console (the UI's words, mapped): +- **tag name**: `openrig-vps` (enter as `tag:openrig-vps` in ACL JSON). +- **tag owner**: your tailnet admin group/user (`"tagOwners": {"tag:openrig-vps": ["autogroup:admin"]}`). +- ACL: home base may reach `tag:openrig-vps` on 22/7433 over the tailnet; + `tag:openrig-vps` gets NO grants toward the home base (the reverse path is + the explicit recipe in §6, never standing policy). +- Auth keys for `tailscale up` must be EPHEMERAL or revoked after use. + +## 3. Bootstrap (fresh VPS → factory-ready) + +On the VPS, as root: + +```bash +curl -fsSLO /scripts/bootstrap-product-factory-vps.sh +chmod +x bootstrap-product-factory-vps.sh +./bootstrap-product-factory-vps.sh \ + --artifact @openrig/cli@ \ + --authorized-key "$(cat ~/.ssh/openrig-factory.pub)" \ + --ts-authkey tskey-... # ephemeral +``` + +The script is stepwise and loud: non-root `openrig` user (key-only) → sshd +hardening drop-in (`PermitRootLogin no`, `PasswordAuthentication no`, +`KbdInteractiveAuthentication no`, `X11Forwarding no`) → UFW default-deny + +tailnet ingress (public SSH stays open as a BOOTSTRAP ESCAPE HATCH only) → +Tailscale with `tag:openrig-vps`, no routes/exit-node/Tailscale SSH → Node 22 → +`npm install -g` of the artifact (must succeed with NO manual package +patching) → daemon + kernel. + +On the home base: + +```bash +rig host add --id ovh-01 --transport ssh --target --user openrig +rig host doctor ovh-01 # expect 4/4 green +rig host doctor ovh-01 --posture product-factory-vps --public-addr +``` + +Then close the escape hatch on the VPS: `sudo ufw delete allow OpenSSH` and +make sure Tailscale SSH is off: `tailscale set --ssh=false`. Re-run the +posture check. UNKNOWN posture items are NOT passes — each carries the command +that verifies it from the right vantage. + +## 4. Transport posture (the decided partition) + +See the per-command transport table in `docs/as-built/cli-reference.md` +(§Cross-host execution). The partition IS the intended posture: ssh carries +interactive pane ops (`send`/`capture`); http-bearer carries daemon REST ops +(`up`/`down`/`launch`); `ps`/`whoami` follow the host's DECLARED transport; +fan-out (`--all-hosts`) is http-only. There is NO cross-transport fallback and +NO http parity for send/capture in 0.4.4 (parity = new attack surface with no +scope-locked need). + +## 5. Safe UI access (recipe, not a verb) + +The daemon binds loopback/tailnet — never public. View the factory UI through +a local tunnel (the smoke test's exact form): + +```bash +ssh -N -L 127.0.0.1:17433:127.0.0.1:7433 +# then open http://127.0.0.1:17433 +``` + +Tear the tunnel down when done. Do not add public allow rules for :7433. + +## 6. Restricted reverse path (supported recipe — NOT always-on) + +Boundary first: home-base-initiated ONLY; no broad VPS→home SSH; the tunnel is +torn down after use. The factory cannot freely reach into the home base. + +1. On the home base, create a dedicated key the FACTORY will use through the + reverse tunnel, and gate it with a forced command in + `~/.ssh/authorized_keys`: + +```text +command="$HOME/.openrig/bin/openrig-reverse-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...factory-reverse-key +``` + +2. The gate script (generatable template — allowlists EXACTLY the four verbs + the smoke test proved; everything else is refused, fail-closed): + +```bash +#!/usr/bin/env bash +# openrig-reverse-gate — forced-command allowlist for the reverse path. +set -euo pipefail +case "${SSH_ORIGINAL_COMMAND:-}" in + "rig send "*|"rig capture "*|"rig ps"*|"rig whoami"*) + exec $SSH_ORIGINAL_COMMAND ;; + *) + echo "refused: only rig send/capture/ps/whoami are allowed on this key" >&2 + exit 77 ;; +esac +``` + +3. Open the reverse tunnel FROM the home base when you want the channel: + +```bash +ssh -N -R 127.0.0.1:2222:127.0.0.1:22 # home-base initiated +``` + +4. On the factory, agents reach home through it: + `ssh -p 2222 -i operator@127.0.0.1 rig send "..."`. +5. Kill the tunnel after use. The REFUSAL of any out-of-allowlist verb is the + proof artifact that the gate works — test it on purpose. + +## 7. Credential + teardown discipline + +- Runtime credentials (Claude/Codex) live ON the VPS, provisioned for it — + never copied from the home host. +- Snapshot/backup expectations and clean teardown follow the provider's + tooling; `rig down --snapshot` before destroying a factory. +- Revoke the Tailscale node + one-time keys when a factory is retired. diff --git a/docs/reference/sdlc-conventions.md b/docs/reference/sdlc-conventions.md new file mode 100644 index 000000000..c3b3489f8 --- /dev/null +++ b/docs/reference/sdlc-conventions.md @@ -0,0 +1,187 @@ +# SDLC conventions — the markdown control plane + +> **Succession rule (source of truth):** this document was derived from the +> corrective review-surface redesign spec +> (`CORRECTIVE-REDESIGN-review-surface-2026-07-05`, DRAFT v0.2, §2–§6, +> grounded against openrig main `c27f2aff`, 2026-07-05/06). **Once shipped, +> THIS repo document is the living SSOT for the SDLC conventions going +> forward**; the corrective spec is the historical design record. Scaffold +> templates, the advisory scope audit, the `mission-slice-sop` skill, and the +> shipped bootstrap overlay all point HERE — they must not restate this +> document in full (restatement is how drift is born). + +OpenRig is a software factory the human steers from a high altitude. The human +records intent; agents turn intent into a plan, the plan into a build, the +build into proof. The Living Notes UI is a **plain projection of well-formed +markdown on disk** — agents change the files, the UI re-projects. These +conventions define "well-formed." Everything here is **advisory / fail-open +for agents**: nothing below blocks a write; the audit records and advises. + +## 1. The flow in one pass + +``` +intent → mini-requirements + proof contract → (UI slices: mockups) + → plan-lock (rig scope slice approve --scope spec) + → build the LOCKED set + → QA: mockup ↔ delivered VISUAL compare + → proof drops (rig proof add …) + → proof-lock (rig scope slice approve --scope delivery) +``` + +The human should almost never see something that doesn't match what was +planned — QA catches mismatches and kicks them back. When the human looks, +they map intent → plan → delivered at a glance, mostly by scanning screenshots +down a single column, and give the final 1% approval. + +## 2. Slice artifact conventions (what the UI projects) + +Each slice directory carries: + +- **`README.md`** (or the slice body) opening with the three convention + sections, in this order and with these exact headings: + - **`## Intent`** — the recorded intent, verbatim. The UI projects this + text as the INTENT section. + - **`## Mini-requirements`** — the concise, one-glance requirement tier + (numbered list). This is the founder's first structured catchpoint; + approval starts here. + - **`## Proof contract`** — a checkbox list of promised deliverables (see + §3). The UI's DELIVERED section pairs each item with its proof. +- **`IMPLEMENTATION-PRD.md`** — the full PRD. It OPENS with the + mini-requirements; everything between intent and proof is **elastic** + (see §7 — for a small slice the mini-requirements may BE the whole PRD). +- **`proof/`** — the proof-artifact directory. Curated canonical evidence + lands here via `rig proof` with valid C1 headers (§5). + +The three sections project into the UI's one review structure: a vertical +stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still +renders (the projection degrades to a muted "—", never invents content) — +but it does not carry its weight in review. + +## 3. The proof contract format + +`## Proof contract` is a markdown checkbox list; each item is one promised +deliverable, written as an observable outcome: + +```markdown +## Proof contract + +- [ ] The consolidated `rig ps` default renders all rigs with a rollup footer — captured. +- [ ] UI: the slice review tab renders the three-section stack — screenshot vs the locked mockup. +``` + +- Each item is joined (by item text or 1-based index) to the proof artifacts + that evidence it — that pairing is what the DELIVERED section renders, so + the human never hunts through dozens of artifacts to find which one proves + what. +- **UI deliverables carry a planned mockup** (`plannedRef`): the planning + agent produces the mockup and attaches it to the locked set. A UI slice + with no mockup in its locked set is an incomplete plan. Non-UI slices + (backend, skills, markdown) have no mockup and no `plannedRef` — that is + not a gap and not a gate. +- A deliverable QA did not actually verify shows as `unverified`/`missing` + in the UI — visible, never hard-blocking. + +## 4. The two locks (shipped verb — not new machinery) + +Two deliberate stamps, both written by the SAME shipped verb +(`rig scope slice approve`, one daemon-side write path: frontmatter stamp + +append-only audit row land together): + +- **Plan-lock:** `rig scope slice approve --scope spec` — "the PRD + matches my intent; THIS artifact set is what gets built." Pins the locked + artifact set (spec/PRD/mockups) out of everything else in the folder. +- **Proof-lock:** `rig scope slice approve --scope delivery` + (the default scope) — the terminal "this is done" sign-off; fires the + freeze. + +Approval is freeze/sign-off — **never** proven-green. Proven-green requires a +recorded verdict (a C1 proof artifact, §5); presence of an approval stamp does +not assert the work was proven. `--on-behalf-of` records delegation honestly +(the actor stays the real invoking session). + +## 5. Proof drops and the C1 header (closed sets) + +Proof artifacts land in `proof/` via the shipped verb: + +```bash +rig proof add \ + --artifact-type qa \ + --verdict PASS \ + --candidate-sha \ + --money-evidence "one line of money evidence" \ + --file \ + --evidences "1,3" \ + --media "walk.webm,panel.png" \ + --self-check "I looked at the captures; they show the claim" +``` + +The C1 header's five required fields: `slice`, `candidate_sha`, +`artifact_type`, `verdict`, `money_evidence`. Two **ratified closed sets** +(extending either is a convention change, not a local edit): + +- `artifact_type`: `guard | qa | rev1-r1 | rev1-r2 | adjudication` +- `verdict`: `CLEAR | BLOCKING | CONCERNING | PASS | NOT-CLEAR` + +`candidate_sha` is the join key: the proven candidate tip this artifact +judges. `--evidences` names which proof-contract deliverable(s) the drop +covers (item text or 1-based index) — that reference populates the +planned↔delivered pairing. `--self-check` is the agent's recorded assertion +that it LOOKED at the evidence. `--media` names the curated media files +(relative to the slice's `proof/` dir — co-located, never absolute) this +drop stands behind; the composer projects them into the DELIVERED items' +proof set. Validation happens at drop time; the audit +(§6) backstops artifacts that arrived by other paths. **Hand-placing files +in `proof/` without a drop is the anti-pattern**: the deliverable stays +unpaired and `unverified` in the DELIVERED view — always attach media via +`--media` on a drop. + +## 6. Role contracts (what makes the structure self-enforcing) + +- **Planning agent:** authors intent verbatim, the mini-requirements, and the + `## Proof contract` (each UI deliverable with its mockup `plannedRef`); + produces the mockups; locks the plan (`--scope spec`). +- **Build agent:** builds against the LOCKED set only; looks at the mockups, + not just the spec text. +- **QA agent (owns the compare):** for each deliverable — load the locked + `plannedRef`, produce the real artifact in a test/demo environment, + **visually compare**, record the verdict + note via a proof drop, and + **curate** the canonical proof set. On mismatch beyond minutiae: fix and + have another agent review, or kick back with the reason — never escalate a + raw mismatch to the human. A proof drop with no recorded comparison leaves + the deliverable `unverified` — visibly. + +**Curation rule:** the primary proof set is the curated canonical "this is +what it looks like now" evidence the agent stands behind — bounded, mapped +1:1 (or few:1) to deliverables. The fix-loop's full artifact history stays in +`proof/`, one drill-in down, NEVER in the primary view. The anti-pattern: +an append-only pile where the human can't tell final from superseded. + +## 7. The elastic middle (proportionality — no minted ceremony) + +The SDLC has exactly three fixed capture points: **intent** → a +**proportional structured requirement** → **proof**. Everything between is +elastic. For a small slice (a bug fix, a research note), the +mini-requirements may BE the whole PRD — the convention sections must be +present so the slice projects, but their contents scale to the work. Gates +are losslessness checks on the decompression from intent to delivery, not +paperwork. Scaffolding emits the sections; it must never mint ceremony. + +## 8. The audit (advisory, fail-open — always) + +`rig scope audit` (and the advisory rows in `rig workspace validate` / +`doctor`) checks these conventions: the section headings present, the proof +contract well-formed, `proof/` artifacts carrying valid C1 headers, UI slices +referencing a mockup. Every finding **records and advises — it never blocks a +write path and never changes exit semantics into a gate**. Unknown is +reported as unknown, not failure. + +## 9. Where the knowledge lives (the four pointers) + +- **This document** — the SSOT. +- **Scaffold**: `rig scope slice create` emits the convention sections + + `proof/` + an `IMPLEMENTATION-PRD.md` skeleton for every template kind; + `rig scope mission create` emits the convention pointer. +- **Skill**: `mission-slice-sop` teaches the full flow (shipped in the + product skill tree and the bundled plugin, mechanically mirrored). +- **Bootstrap**: the shipped agent overlay points fresh seats at the skill + and this document at boot. diff --git a/docs/releases/v0.4.4.md b/docs/releases/v0.4.4.md new file mode 100644 index 000000000..9b56da6e4 --- /dev/null +++ b/docs/releases/v0.4.4.md @@ -0,0 +1,131 @@ +# OpenRig v0.4.4 + +## Summary — Multi-host + Living Notes + +OpenRig v0.4.4 lands the multi-host foundation: a shared rig topology +can now span multiple hosts, with a staged spin-up flow, real cross- +host file movement, and a consolidated For-You activity feed that +merges signal across every host in the topology. The VPS product- +factory story hardens for real multi-host operation. + +Alongside the multi-host foundation, this release ships the **Living +Notes signal layer** — a durable, agent-authored notes surface that +threads INTENT → PLAN → DELIVERED down the mission tree, with a +one-structure review contract that reads left-to-right as a single +vertical stack (INTENT above, PLAN in the middle, DELIVERED at the +bottom; delete-not-demote when a plan changes). Composer surfaces make +authoring cheap; the review surface makes reading intent-versus-build +one-glance. + +Operator UX picks up **`rig ps` consolidated default** (the token- +efficient-defaults family gains progressive disclosure that keeps the +default view glanceable without hiding the fleet), an **agent altitude +coordination panel** that surfaces multi-agent state at the right +altitude for the operator, and the **operationalize-SDLC control +plane** which pulls the release / delivery / lifecycle levers into a +single control surface. As-built docs closeout catches everything up. + +Migrations, if any, are additive. Existing v0.4.3 databases upgrade by +running `rig daemon start` on the new daemon. + +## What Shipped + +### Multi-host foundation + +- **Shareable whole-topology staged spin-up** — a rig topology can be + brought up in stages across multiple hosts; the spec + the daemon + coordinate to reach a green whole-fleet state without requiring a + single-host bring-up. Prior to this release, a whole-topology spin- + up assumed a single host. +- **VPS product-factory multi-host hardening** — the shipped + `openrig-user`/`openrig-architect` operator flows work correctly + across VPS/self-hosted product-factory scenarios; multi-host + handshake + registration hardens. +- **Multi-host consolidated For-You feed** — the For-You feed + aggregates activity across all registered hosts in the topology, not + just the local daemon. The stream classifier + real-data fidelity + from 0.4.3 apply per-host and merge upstream. A real-host feed- + subscription capture ships as belt-and-suspenders proof (see + Known Follow-ons). +- **`rig file` cross-host movement** — files move across the + topology's registered hosts via the `rig file` surface. Real + registered-host two-host end-to-end round-trip proves the flow on + top of the VM stand-ins already merged. + +### Living Notes + +- **Signal layer** — a durable, agent-authored notes surface at + mission and slice altitude; INTENT / PLAN / DELIVERED entries thread + down the mission tree with agent authorship + timestamps. +- **Composer surfaces** — cheap authoring surfaces make Living Notes + the first-class place agents record decisions, plans, and delivered + work. +- **One-structure review contract (§3.1) — corrective rebuild** — the + Living Notes review surface reads left-to-right as a **single + vertical stack** (INTENT above, PLAN + mockup in the middle, + DELIVERED with paired proof at the bottom); a plan change **deletes** + the old plan and writes a new one — never demotes / stacks multiple + competing plans. The corrective rebuild replaced an earlier + three-column layout after human review. + +### Operator UX + +- **`rig ps` consolidated default + progressive disclosure** — the + default `rig ps` now consolidates the fleet's readable rows into a + single glanceable default; progressive disclosure via `--full` / + `-A` / per-scope filters keeps the rich per-node record retrievable. + Follows the token-efficient-defaults family established at 0.4.0. +- **Agent altitude coordination panel** — a coordination surface + scoped to the right altitude (workspace / mission / slice) so the + operator sees the actual coordination state without drowning in + per-seat detail. Composes with the workspace observability tabs + shipped at 0.4.1. + +### Doctrine + +- **Operationalize the SDLC control plane** — release / delivery / + lifecycle levers pull into a single surface. The lanes that shipped + 0.4.1 → 0.4.3 (release-cut squash + bounded-diff, sidecar-operator + host cutover, curation pass) are now first-class control-plane + actions rather than lifecycle-tribal-wisdom. +- **As-built docs closeout** — the as-built documentation family + (`docs/as-built/architecture.md`, `docs/as-built/cli-reference.md`, + codemaps) is caught up to what shipped through 0.4.4. + +## Behavior + Compatibility + +- **Migrations** — additive only. Existing v0.4.3 databases upgrade + by running `rig daemon start` on the new daemon. +- **Multi-host registration** — the multi-host handshake is + backward-compatible with single-host deployments; single-host + operation is unchanged. +- **`rig ps` default view** — the consolidated default is a + compact readable view; `--full` returns the v0.4.3 default shape. +- **For-You feed** — multi-host feed aggregation is opt-in via + registered-host membership; a single-host topology sees no + behavior change. + +## Known Follow-ons + +- **R2 — iOS-Safari S20 (Living Notes composer)** — deferred to + 0.4.5 for iOS Safari verification. +- **R4 — S18 symlink footgun** — routed to the 0.4.5+ backlog. +- **`openrig-user` stale plugin copy + `whoami --all-hosts` silent + host filter** — 0.4.5 candidates. +- **Wider mission-template prose sweep** — post-cut sequencing. +- **Belt-and-suspenders real-host proofs** — R13-1 (published-npm + Linux install smoke + daemon-start / `/healthz` / WebSocket + handshake), R1 (real registered-host two-host e2e for S13 + S18 on + top of the merged VM stand-ins), and R3 (S15 real-host feed- + subscription capture) are sequenced as post-publish lifecycle + validation lanes. The shipped code was already proven via VM/SSH + stand-ins per PM rulings; the real-host lanes are defense-in-depth + belt-and-suspenders proofs, not release gates. + +## See Also + +- `CHANGELOG.md` — the standard changelog entry for `0.4.4`. +- `docs/releases/v0.4.3.md` — the prior release ("rigs that + survive"). +- `docs/releases/v0.4.2.md` — the daemon-status hotfix. +- `docs/releases/v0.4.1.md` — the workspace observability overhaul. diff --git a/package-lock.json b/package-lock.json index eaca9fb18..5dc9088a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openrig", - "version": "0.4.3", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openrig", - "version": "0.4.3", + "version": "0.4.4", "license": "Apache-2.0", "workspaces": [ "packages/daemon", @@ -7449,7 +7449,7 @@ }, "packages/cli": { "name": "@openrig/cli", - "version": "0.4.3", + "version": "0.4.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -7481,7 +7481,7 @@ }, "packages/daemon": { "name": "@openrig/daemon", - "version": "0.4.3", + "version": "0.4.4", "license": "Apache-2.0", "dependencies": { "@hono/node-server": "^1.13.0", @@ -7506,7 +7506,7 @@ }, "packages/ui": { "name": "@openrig/ui", - "version": "0.4.3", + "version": "0.4.4", "license": "Apache-2.0", "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", diff --git a/package.json b/package.json index 9e60bd340..c0f2d8f06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openrig", - "version": "0.4.3", + "version": "0.4.4", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e27660db..1ba26455e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrig/cli", - "version": "0.4.3", + "version": "0.4.4", "type": "module", "description": "Local control plane for multi-agent coding topologies", "keywords": [ diff --git a/packages/cli/src/build-info.ts b/packages/cli/src/build-info.ts new file mode 100644 index 000000000..4cf0fd2a0 --- /dev/null +++ b/packages/cli/src/build-info.ts @@ -0,0 +1,21 @@ +// OPR.0.4.4.11 FR-6 — the CLI's build-identity module. +// +// COMMITTED DEV STUB: unstamped runs report absent identity — never an +// invented SHA. scripts/build-package.sh computes the stamp ONCE and +// overwrites the compiled counterpart (dist/build-info.js) at package time; +// this is the CLI's own generated twin of the daemon module (arch ruling 6 — +// per-package modules, no runtime cross-package import). + +export interface BuildInfo { + semver: string | null; + commit: string | null; + dirty: boolean | null; + builtAt: string | null; +} + +export const BUILD_INFO: BuildInfo = { + semver: null, + commit: null, + dirty: null, + builtAt: null, +}; diff --git a/packages/cli/src/commands/config-init-workspace.ts b/packages/cli/src/commands/config-init-workspace.ts index 5a14c3a47..52c666039 100644 --- a/packages/cli/src/commands/config-init-workspace.ts +++ b/packages/cli/src/commands/config-init-workspace.ts @@ -463,8 +463,18 @@ slice: ${slice.id} # ${slice.title} +## Intent + ${slice.objective} +## Mini-requirements + +1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`. + +## Proof contract + +- [ ] The work is visible in the Project slice — captured (see IMPLEMENTATION-PRD.md). + ## Queue Mapping Queue items for this slice should mention or tag: @@ -511,15 +521,21 @@ slice: ${slice.id} # ${slice.title} Implementation Notes -## Goal +## Intent ${slice.objective} -## Acceptance +## Mini-requirements -- [ ] The work is visible in the Project slice. +1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`. + +## Proof contract + +- [ ] The work is visible in the Project slice — captured. - [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`. -- [ ] Any proof artifacts are referenced from the slice before closure. +- [ ] Proof artifacts are referenced from the slice before closure (drop via \`rig proof add\`). + +> Conventions SSOT: \`docs/reference/sdlc-conventions.md\` — section names, proof-contract format, the two locks, C1 headers. For a small slice the mini-requirements may BE the whole PRD. `; } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 5bfbf23ae..dc31e4364 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -192,13 +192,14 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port }); // 7. Port availability (async) — daemon already running on that port counts as OK + const fetchFn = deps.fetch ?? globalThis.fetch; const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise => { if (available) { return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` }; } // Port in use — check if it's our daemon via healthz try { - const res = await fetch(`http://127.0.0.1:${DEFAULT_PORT}/healthz`); + const res = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`); if (res.ok) { return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} in use by OpenRig daemon.` }; } @@ -215,7 +216,6 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port // 8. Daemon cmux control (async, only when shell cmux passed) const asyncChecks: Promise[] = [portCheck]; if (shellCmuxPassed) { - const fetchFn = deps.fetch ?? globalThis.fetch; const daemonCmuxCheck = (async (): Promise => { try { const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`); diff --git a/packages/cli/src/commands/expand.ts b/packages/cli/src/commands/expand.ts index 3810c8194..e0c193304 100644 --- a/packages/cli/src/commands/expand.ts +++ b/packages/cli/src/commands/expand.ts @@ -110,7 +110,7 @@ export function expandCommand(depsOverride?: StatusDeps): Command { console.log(""); console.log(" Failed nodes cannot be relaunched directly because pod-aware launch"); console.log(" bypasses startup orchestration. Recover by:"); - console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`); + console.log(` 1. Verify other seats are healthy: rig ps --nodes --rig ${rigId}`); if (data.podNamespace) { console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`); } else { diff --git a/packages/cli/src/commands/file.ts b/packages/cli/src/commands/file.ts new file mode 100644 index 000000000..b3a794e76 --- /dev/null +++ b/packages/cli/src/commands/file.ts @@ -0,0 +1,93 @@ +// OPR.0.4.4.18 — `rig file` (v0: ONE explicit verb, copy). +// +// Every transfer names its source, destination, and policy: : +// is remote (resolving the registry's ssh entries), a bare path is local, +// and NOTHING is inferred from cwd/env/session (FR-2). Existing destination +// files are OVERWRITTEN — v0 copy semantics, stated (arch R18-2); --dry-run +// is the guard rail. The security wall lives in lib/file-transfer.ts. + +import { Command } from "commander"; +import { + planFileCopy, + buildRsyncArgv, + runFileCopy, + type CopyPlan, + type PlanDeps, +} from "../lib/file-transfer.js"; + +export interface FileCommandDeps extends PlanDeps { + run?: typeof runFileCopy; +} + +function operandLabel(side: CopyPlan["src"]): string { + return side.kind === "remote" ? `${side.host!.id}:${side.path}` : side.path; +} + +export function fileCommand(deps: FileCommandDeps = {}): Command { + const file = new Command("file").description( + "Cross-host file movement over ssh/rsync (v0: one explicit verb — copy)", + ); + + file + .command("copy ") + .description( + "Copy one file. : = remote (ssh registry entry); bare path = local; local files with a colon need the ./ prefix. Valid shapes: local→remote, remote→local, local→local. An existing destination is OVERWRITTEN — preview with --dry-run.", + ) + .option("--dry-run", "Print the exact planned transfer (src, dst, host, files/bytes) and move nothing") + .option("--json", "JSON output for agents") + .action(async (src: string, dst: string, opts: { dryRun?: boolean; json?: boolean }) => { + const planned = planFileCopy(src, dst, { dryRun: opts.dryRun, registryLoader: deps.registryLoader }); + if (!planned.ok) { + if (opts.json) { + console.log(JSON.stringify({ ok: false, code: planned.code, error: planned.error })); + } else { + console.error(planned.error); + } + process.exitCode = 1; + return; + } + const plan = planned.plan; + const srcLabel = operandLabel(plan.src); + const dstLabel = operandLabel(plan.dst); + const run = deps.run ?? runFileCopy; + const result = await run(plan); + + if (opts.json) { + console.log( + JSON.stringify({ + ok: result.ok, + dryRun: plan.dryRun, + src: srcLabel, + dst: dstLabel, + failedStep: result.failedStep, + bytesTransferred: result.bytesTransferred ?? null, + filesTransferred: result.filesTransferred ?? null, + exitCode: result.exitCode, + error: result.ok ? null : result.stderr.trim() || `rsync failed (${result.failedStep})`, + hint: result.hint ?? null, + rsyncArgv: buildRsyncArgv(plan), // transparency for agents; the builder is closed + }), + ); + if (!result.ok) process.exitCode = 1; + return; + } + + if (result.ok) { + if (plan.dryRun) { + console.log(`DRY RUN — nothing moved. Planned transfer:`); + console.log(` ${srcLabel} → ${dstLabel}`); + if (result.filesTransferred !== undefined) console.log(` files: ${result.filesTransferred}${result.bytesTransferred !== undefined ? `, bytes: ${result.bytesTransferred}` : ""}`); + const itemized = result.stdout.split("\n").filter((l) => /^[<>ch.*][fdLDS]/.test(l)); + for (const line of itemized) console.log(` ${line}`); + } else { + console.log(`Copied ${srcLabel} → ${dstLabel}${result.bytesTransferred !== undefined ? ` (${result.bytesTransferred} bytes)` : ""}`); + } + return; + } + console.error(`file copy failed [${result.failedStep}]: ${result.stderr.trim() || `exit ${result.exitCode}`}`); + if (result.hint) console.error(`Hint: ${result.hint}`); + process.exitCode = 1; + }); + + return file; +} diff --git a/packages/cli/src/commands/host.ts b/packages/cli/src/commands/host.ts new file mode 100644 index 000000000..48a32f938 --- /dev/null +++ b/packages/cli/src/commands/host.ts @@ -0,0 +1,472 @@ +// OPR.0.4.4.13 — the host-registry verbs. CAPPED at exactly three +// (add / list / doctor — arch R13-2a): no edit/remove/tunnel/bootstrap +// verbs; hand-editing hosts.yaml remains the path for everything else, +// and the bootstrap ships as script + runbook. +// +// Secret hygiene (FR-1): every render carries bearer POINTERS (env var / +// file NAMES) only — no code path in this file resolves a bearer value +// for display. + +import { Command } from "commander"; +import { existsSync } from "node:fs"; +import { connect } from "node:net"; +import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js"; +import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js"; + +// --------------------------------------------------------------------------- +// rig host doctor — stepwise, honest, three-valued (FR-1 + FR-2). +// --------------------------------------------------------------------------- + +export type CheckStatus = "pass" | "fail" | "unknown"; + +export interface CheckRow { + step: string; + status: CheckStatus; + detail: string; + /** Actionable next step; REQUIRED for fail, encouraged for unknown. */ + fix?: string; +} + +export interface DoctorDeps { + run: (host: HostEntry, argv: readonly string[]) => Promise; + httpGet: (url: string, headers?: Record) => Promise<{ status: number; body: string }>; + /** TCP connect probe: "open" (connected) | "closed" (refused/filtered/timeout) | "unknown" (probe itself failed). */ + tcpProbe: (target: string, port: number, timeoutMs: number) => Promise<"open" | "closed" | "unknown">; +} + +function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined { + let cur: unknown = obj; + for (const part of path) { + if (typeof cur !== "object" || cur === null) return undefined; + cur = (cur as Record)[part]; + } + return typeof cur === "boolean" ? cur : undefined; +} + +function tailscaleRunSshFromPrefs(text: string): boolean | undefined { + if (!text.trim().startsWith("{")) return undefined; + const parsed = JSON.parse(text) as unknown; + return nestedBoolean(parsed, ["RunSSH"]) + ?? nestedBoolean(parsed, ["Prefs", "RunSSH"]) + ?? nestedBoolean(parsed, ["CurrentProfile", "RunSSH"]); +} + +function defaultDoctorDeps(): DoctorDeps { + return { + run: (host, argv) => runCrossHostCommand(host, argv), + httpGet: async (url, headers) => { + const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) }); + return { status: res.status, body: await res.text() }; + }, + tcpProbe: (target, port, timeoutMs) => + new Promise((resolve) => { + const sock = connect({ host: target, port, timeout: timeoutMs }); + sock.on("connect", () => { sock.destroy(); resolve("open"); }); + sock.on("timeout", () => { sock.destroy(); resolve("closed"); }); + sock.on("error", () => { sock.destroy(); resolve("closed"); }); + }), + }; +} + +/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error: + * "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠ + * "remote rig binary missing/old". */ +export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise { + const rows: CheckRow[] = []; + + if (host.transport === "ssh") { + const reach = await deps.run(host, ["true"]); + if (!reach.ok && (reach.failedStep === "ssh-unreachable" || reach.failedStep === "permission-gate")) { + rows.push({ + step: "transport-reachability", + status: "fail", + detail: `ssh to ${host.target} failed (${reach.failedStep}): ${reach.sshStderr.trim()}`, + fix: reach.failedStep === "permission-gate" + ? "SSH auth/permission gate — check the key, agent, and remote authorized_keys" + : "verify the ssh target/alias, tailnet connectivity, and that sshd is running", + }); + return rows; // later legs are meaningless without the shell + } + rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` }); + + const version = await deps.run(host, ["rig", "--version"]); + if (!version.ok) { + rows.push({ + step: "remote-rig-binary", + status: "fail", + detail: "reached the remote shell, but `rig --version` failed — remote rig binary missing or broken", + fix: "install the published artifact on the host: npm install -g @openrig/cli (see the product-factory runbook)", + }); + return rows; + } + rows.push({ step: "remote-rig-binary", status: "pass", detail: `remote rig ${version.stdout.trim() || "(version unreadable)"}` }); + + const daemon = await deps.run(host, ["rig", "daemon", "status"]); + const daemonUp = daemon.ok && /running/i.test(daemon.stdout); + rows.push(daemonUp + ? { step: "remote-daemon-health", status: "pass", detail: "remote daemon reports running" } + : { + step: "remote-daemon-health", + status: "fail", + detail: "SSH works and rig is installed, but the remote daemon is not running", + fix: "on the host: rig daemon start (then re-run doctor)", + }); + if (!daemonUp) return rows; + + const whoami = await deps.run(host, ["rig", "ps", "--json", "--limit", "5"]); + let identityOk = false; + if (whoami.ok) { + try { identityOk = typeof JSON.parse(whoami.stdout) === "object"; } catch { identityOk = false; } + } + rows.push(identityOk + ? { step: "remote-identity", status: "pass", detail: "remote daemon identity/list resolves (rig ps --json parses)" } + : { + step: "remote-identity", + status: "fail", + detail: "daemon is up but remote identity/list did not resolve", + fix: "on the host: check `rig ps --json` directly; the daemon API may be unhealthy even though status says running", + }); + return rows; + } + + // http transport + const bearer = resolveRemoteBearer(host as HttpHostEntry); + if (!bearer.ok) { + rows.push({ step: "transport-reachability", status: "fail", detail: bearer.error, fix: "set the bearer env var / file the registry entry points at (pointer names: rig host list)" }); + return rows; + } + try { + const health = await deps.httpGet(`${(host as HttpHostEntry).url}/healthz`, { Authorization: `Bearer ${bearer.token}` }); + if (health.status === 401 || health.status === 403) { + rows.push({ step: "transport-reachability", status: "fail", detail: `daemon reachable but rejected the bearer (HTTP ${health.status})`, fix: "registry is reachable but the token is wrong — rotate/set the bearer the entry points at" }); + return rows; + } + if (health.status < 200 || health.status >= 300) { + rows.push({ step: "transport-reachability", status: "fail", detail: `healthz returned HTTP ${health.status}`, fix: "the daemon answered abnormally — check remote daemon logs" }); + return rows; + } + rows.push({ step: "transport-reachability", status: "pass", detail: `healthz ok at ${(host as HttpHostEntry).url}` }); + rows.push({ step: "remote-daemon-health", status: "pass", detail: "healthz IS the daemon health check on http transport" }); + } catch (err) { + rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" }); + return rows; + } + rows.push({ + step: "remote-rig-binary", + status: "unknown", + detail: "not determinable over http transport (no shell)", + fix: "verify via an ssh-transport entry for this host, or on the host directly: rig --version", + }); + try { + const ps = await deps.httpGet(`${(host as HttpHostEntry).url}/api/ps`, { Authorization: `Bearer ${bearer.token}` }); + rows.push(ps.status >= 200 && ps.status < 300 + ? { step: "remote-identity", status: "pass", detail: "authenticated daemon API answers (/api/ps)" } + : { step: "remote-identity", status: "fail", detail: `/api/ps returned HTTP ${ps.status}`, fix: "daemon healthz is up but the API is unhealthy — check remote daemon logs" }); + } catch (err) { + rows.push({ step: "remote-identity", status: "fail", detail: `/api/ps unreachable: ${(err as Error).message}`, fix: "check remote daemon logs" }); + } + return rows; +} + +/** FR-2 — THE one built-in posture profile (arch R13-2b: a named constant + * table, no framework). Every item is three-valued; UNKNOWN is never + * smoothed to pass, and every non-pass carries its fix. */ +export const POSTURE_PROFILE_ID = "product-factory-vps"; + +export async function postureCheck( + host: HostEntry, + deps: DoctorDeps, + opts: { publicAddr?: string } = {}, +): Promise { + if (host.transport !== "ssh") { + // Posture needs a shell. Report every item unknown — honestly. + return POSTURE_ITEM_IDS.map((id) => ({ + step: id, + status: "unknown" as const, + detail: "posture checks need shell access; this host is registered with http transport", + fix: "register an ssh-transport entry for this host (or run the checks on the host per the runbook)", + })); + } + + const rows: CheckRow[] = []; + const sh = async (cmd: string) => deps.run(host, ["sh", "-c", cmd]); + + // 1. non-root openrig user + const idOut = await sh("id -u openrig 2>/dev/null"); + if (idOut.ok && idOut.stdout.trim() !== "") { + rows.push(idOut.stdout.trim() === "0" + ? { step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' resolves to uid 0", fix: "create a non-root openrig user per the runbook" } + : { step: "nonroot-openrig-user", status: "pass", detail: `user 'openrig' uid ${idOut.stdout.trim()}` }); + } else { + rows.push({ step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' does not exist", fix: "adduser openrig + key-only SSH per the runbook" }); + } + + // 2+3. sshd effective config (root SSH off, password auth off) + const sshd = await sh("sudo -n sshd -T 2>/dev/null || sshd -T 2>/dev/null || true"); + const sshdOut = sshd.ok ? sshd.stdout.toLowerCase() : ""; + const sshdItem = (step: string, key: string, want: string): CheckRow => { + if (!sshdOut.includes(key)) { + return { step, status: "unknown", detail: "effective sshd config not readable from this vantage (sshd -T needs root)", fix: "on the host: sudo sshd -T | grep " + key }; + } + return sshdOut.includes(`${key} ${want}`) + ? { step, status: "pass", detail: `${key} ${want}` } + : { step, status: "fail", detail: `sshd -T reports ${key} != ${want}`, fix: `set ${key} ${want} in /etc/ssh/sshd_config.d/99-openrig-hardening.conf and reload sshd` }; + }; + rows.push(sshdItem("root-ssh-disabled", "permitrootlogin", "no")); + rows.push(sshdItem("key-only-ssh", "passwordauthentication", "no")); + + // 4+5. UFW default-deny + tailnet ingress + const ufw = await sh("sudo -n ufw status verbose 2>/dev/null || true"); + const ufwOut = ufw.ok ? ufw.stdout.toLowerCase() : ""; + if (ufwOut.trim() === "") { + const unknownUfw = (step: string): CheckRow => ({ step, status: "unknown", detail: "ufw status not readable from this vantage (needs sudo)", fix: "on the host: sudo ufw status verbose" }); + rows.push(unknownUfw("ufw-default-deny-incoming")); + rows.push(unknownUfw("tailnet-ingress-allowed")); + } else { + rows.push(/default:\s*deny\s*\(incoming/.test(ufwOut) + ? { step: "ufw-default-deny-incoming", status: "pass", detail: "ufw default deny (incoming)" } + : { step: "ufw-default-deny-incoming", status: "fail", detail: "ufw incoming default is not deny", fix: "sudo ufw default deny incoming" }); + // R2-B1: mentioning tailscale0 is NOT enough — a DENY on tailscale0 + // must never pass. Pass requires an ALLOW IN rule on the interface. + const tailnetAllowIn = ufwOut.split("\n").some((line) => line.includes("tailscale0") && /allow\s+in/.test(line)); + if (tailnetAllowIn) { + rows.push({ step: "tailnet-ingress-allowed", status: "pass", detail: "ufw has an ALLOW IN rule on tailscale0" }); + } else if (ufwOut.includes("tailscale0")) { + rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "tailscale0 appears in ufw rules but with no ALLOW IN rule (deny/other only)", fix: "sudo ufw allow in on tailscale0" }); + } else { + rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "no tailscale0 allow rule found", fix: "sudo ufw allow in on tailscale0" }); + } + } + + // 6. tailscale flags (no subnet routes / exit node / ts-SSH) + const ts = await sh("tailscale status --json 2>/dev/null || true"); + const prefs = await sh("tailscale debug prefs --json 2>/dev/null || true"); + let routes: number | undefined; + let exitNode: boolean | undefined; + let runSsh: boolean | undefined; + const unknownReasons: string[] = []; + try { + if (ts.ok && ts.stdout.trim().startsWith("{")) { + const parsed = JSON.parse(ts.stdout) as { Self?: { PrimaryRoutes?: unknown[]; ExitNodeOption?: boolean }; ExitNodeStatus?: unknown }; + routes = parsed.Self?.PrimaryRoutes?.length ?? 0; + exitNode = (parsed.ExitNodeStatus !== undefined && parsed.ExitNodeStatus !== null) || parsed.Self?.ExitNodeOption === true; + } else { + unknownReasons.push("tailscale status unreadable"); + } + } catch { + unknownReasons.push("tailscale status did not parse"); + } + try { + runSsh = prefs.ok ? tailscaleRunSshFromPrefs(prefs.stdout) : undefined; + if (runSsh === undefined) unknownReasons.push("tailscale debug prefs unreadable"); + } catch { + unknownReasons.push("tailscale debug prefs did not parse"); + } + if ((routes ?? 0) > 0 || exitNode === true || runSsh === true) { + rows.push({ + step: "tailscale-minimal-trust", + status: "fail", + detail: `subnet routes: ${routes ?? "unknown"}; exit node in use: ${exitNode ?? "unknown"}; Tailscale SSH enabled: ${runSsh ?? "unknown"}`, + fix: "remove advertised routes / exit-node use (tailscale set) and disable Tailscale SSH: tailscale set --ssh=false", + }); + } else if (routes === 0 && exitNode === false && runSsh === false) { + rows.push({ step: "tailscale-minimal-trust", status: "pass", detail: "no advertised subnet routes; no exit-node use; Tailscale SSH disabled" }); + } else { + rows.push({ + step: "tailscale-minimal-trust", + status: "unknown", + detail: `tailscale posture not fully determined (${unknownReasons.join("; ")})`, + fix: "on the host: tailscale status --json && tailscale debug prefs --json; disable Tailscale SSH with tailscale set --ssh=false", + }); + } + + // 7. daemon bound loopback/tailnet — never public. R2-B1: a SPECIFIC + // public IP bind must fail exactly like a wildcard — pass is an + // ALLOWLIST (loopback or the tailnet CGNAT range 100.64.0.0/10), never + // "not wildcard". + const ss = await sh("ss -tln 2>/dev/null | grep ':7433' || true"); + const ssOut = ss.ok ? ss.stdout : ""; + const listenAddrs = ssOut + .split("\n") + .map((line) => line.match(/(\S+):7433\b/)?.[1]) + .filter((a): a is string => a !== undefined); + if (listenAddrs.length === 0) { + rows.push({ step: "daemon-bind-not-public", status: "unknown", detail: "no listener on :7433 observed (daemon may be down)", fix: "start the daemon, then re-check: ss -tln | grep 7433" }); + } else { + const isLoopbackOrTailnet = (addr: string): boolean => { + const a = addr.replace(/^\[|\]$/g, ""); + if (a.startsWith("127.") || a === "::1") return true; + const m = a.match(/^100\.(\d+)\./); + return m !== null && Number(m[1]) >= 64 && Number(m[1]) <= 127; + }; + const offending = listenAddrs.filter((a) => !isLoopbackOrTailnet(a)); + rows.push(offending.length === 0 + ? { step: "daemon-bind-not-public", status: "pass", detail: `listener(s) bound loopback/tailnet only: ${listenAddrs.join(", ")}` } + : { step: "daemon-bind-not-public", status: "fail", detail: `daemon listens on a non-loopback/non-tailnet address: ${offending.join(", ")}`, fix: "bind the daemon to loopback/tailnet only (OPENRIG daemon host config); never expose :7433 publicly" }); + } + + // 8+9. public reachability probes — only meaningful with a known PUBLIC address. + if (opts.publicAddr) { + for (const [step, port] of [["public-daemon-port-unreachable", 7433], ["public-ssh-unreachable-or-accepted", 22]] as const) { + const probe = await deps.tcpProbe(opts.publicAddr, port, 5000); + rows.push(probe === "open" + ? { step, status: "fail", detail: `${opts.publicAddr}:${port} is publicly reachable`, fix: "remove the public allow rule / firewall the port (the smoke-test hardening bar: public :22/:7433 time out)" } + : { step, status: "pass", detail: `${opts.publicAddr}:${port} not reachable (${probe})` }); + } + } else { + const unknownProbe = (step: string, port: number): CheckRow => ({ + step, status: "unknown", + detail: `public reachability of :${port} not probed (no public address known — the registry target may be a tailnet alias)`, + fix: `re-run with --public-addr to probe from this vantage`, + }); + rows.push(unknownProbe("public-daemon-port-unreachable", 7433)); + rows.push(unknownProbe("public-ssh-unreachable-or-accepted", 22)); + } + + return rows; +} + +export const POSTURE_ITEM_IDS = [ + "nonroot-openrig-user", + "root-ssh-disabled", + "key-only-ssh", + "ufw-default-deny-incoming", + "tailnet-ingress-allowed", + "tailscale-minimal-trust", + "daemon-bind-not-public", + "public-daemon-port-unreachable", + "public-ssh-unreachable-or-accepted", +] as const; + +function renderRows(rows: CheckRow[], json: boolean | undefined): void { + if (json) { + console.log(JSON.stringify(rows)); + return; + } + const glyph = { pass: "✓", fail: "✗", unknown: "?" } as const; + for (const r of rows) { + console.log(`${glyph[r.status]} ${r.step}: ${r.detail}${r.fix ? `\n fix: ${r.fix}` : ""}`); + } +} + +function authPointer(h: HostEntry): string { + if (h.transport === "ssh") return "ssh-key"; + return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—"; +} + +export function hostCommand(doctorDepsOverride?: DoctorDeps): Command { + const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)"); + + cmd + .command("add") + .description("Add a host entry (validated with the registry loader's own rules)") + .requiredOption("--id ", "Unique host id") + .requiredOption("--transport ", "ssh or http") + .option("--target ", "SSH target (DNS name, ssh-config alias, or IP) — ssh transport") + .option("--user ", "SSH user — ssh transport") + .option("--url ", "Remote daemon base URL — http transport") + .option("--bearer-env ", "Env var NAME holding the bearer token — http transport (pointer, never a value)") + .option("--bearer-file ", "File PATH holding the bearer token — http transport (pointer, never a value)") + .option("--notes ", "Free-form operator note") + .option("--json", "JSON output") + .action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => { + const rawEntry: Record = { id: opts.id, transport: opts.transport }; + if (opts.target !== undefined) rawEntry["target"] = opts.target; + if (opts.user !== undefined) rawEntry["user"] = opts.user; + if (opts.url !== undefined) rawEntry["url"] = opts.url; + if (opts.bearerEnv !== undefined) rawEntry["bearer_env"] = opts.bearerEnv; + if (opts.bearerFile !== undefined) rawEntry["bearer_file"] = opts.bearerFile; + if (opts.notes !== undefined) rawEntry["notes"] = opts.notes; + + const res = addHostEntry(rawEntry); + if (!res.ok) { + console.error(res.error); + process.exitCode = 1; + return; + } + if (opts.json) { + console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry })); + return; + } + console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`); + console.log(`Verify it: rig host doctor ${res.entry.id}`); + }); + + cmd + .command("list") + .description("List registered hosts (config pointers only — never secret values)") + .option("--json", "JSON output") + .action((opts: { json?: boolean }) => { + const registryPath = defaultHostRegistryPath(); + if (!existsSync(registryPath)) { + if (opts.json) { + console.log(JSON.stringify([])); + return; + } + console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id --transport ...`); + return; + } + const loaded = loadHostRegistry(registryPath); + if (!loaded.ok) { + console.error(loaded.error); + process.exitCode = 1; + return; + } + if (opts.json) { + // Entries as validated: bearer fields are NAMES by construction. + console.log(JSON.stringify(loaded.registry.hosts)); + return; + } + if (loaded.registry.hosts.length === 0) { + console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id --transport ...`); + return; + } + const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n)); + console.log(`${pad("ID", 20)} ${pad("TRANSPORT", 10)} ${pad("TARGET", 36)} ${pad("AUTH", 24)} NOTES`); + for (const h of loaded.registry.hosts) { + console.log(`${pad(h.id, 20)} ${pad(h.transport, 10)} ${pad(hostDisplayTarget(h), 36)} ${pad(authPointer(h), 24)} ${h.notes ?? "—"}`); + } + }); + + cmd + .command("doctor") + .description("Stepwise host verification (+ --posture for the product-factory-vps baseline)") + .argument("", "Registered host id") + .option("--posture ", `Run the posture baseline (only built-in profile: ${POSTURE_PROFILE_ID})`) + .option("--public-addr ", "Public address for the outside-vantage reachability probes (posture items 8-9)") + .option("--json", "JSON output") + .action(async (id: string, opts: { posture?: string; publicAddr?: string; json?: boolean }) => { + const deps: DoctorDeps = doctorDepsOverride ?? defaultDoctorDeps(); + const loaded = loadHostRegistry(); + if (!loaded.ok) { + console.error(loaded.error); + process.exitCode = 1; + return; + } + const resolved = resolveHost(loaded.registry, id); + if (!resolved.ok) { + // The DISTINCT "registry is wrong" error class. + console.error(`registry: ${resolved.error}`); + process.exitCode = 1; + return; + } + if (opts.posture !== undefined && opts.posture !== POSTURE_PROFILE_ID) { + console.error(`unknown posture profile '${opts.posture}'. The only built-in profile is: ${POSTURE_PROFILE_ID}`); + process.exitCode = 1; + return; + } + + const rows = await doctorLegs(resolved.host, deps); + if (opts.posture) { + rows.push(...await postureCheck(resolved.host, deps, { publicAddr: opts.publicAddr })); + } + renderRows(rows, opts.json); + if (rows.some((r) => r.status === "fail")) process.exitCode = 1; + const unknowns = rows.filter((r) => r.status === "unknown").length; + if (!opts.json && unknowns > 0) { + console.log(`${unknowns} item${unknowns === 1 ? "" : "s"} UNKNOWN — unknown is not pass; see the fix lines to verify.`); + } + }); + + return cmd; +} diff --git a/packages/cli/src/commands/proof.ts b/packages/cli/src/commands/proof.ts new file mode 100644 index 000000000..2c12b25bd --- /dev/null +++ b/packages/cli/src/commands/proof.ts @@ -0,0 +1,344 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; +import YAML from "yaml"; +import { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js"; +import { ScopeCliError } from "../lib/scope/types.js"; + +/** + * `rig proof` — the proof-drop write path (OPR.0.4.4.19 FR-8 + FR-11; + * conventions C1 + C2 + C8, D2 attestation). + * + * CLI-side filesystem only (plan-review + arch-lead confirmed): the drop + * validates the C1 header AT THE MOMENT THE EVIDENCE IS IN-HAND, writes the + * artifact into the slice's proof/ dir, and echoes the parsed header (the + * seat sees what the composer will see). No daemon involvement, no synthetic + * qitems, no DB writes. + * + * LOAD-BEARING boundaries: + * - Validation applies ONLY to drops made through this path. An artifact + * written by any other means (raw file write, existing workflows) is + * NEVER blocked at write time — the backstop is `rig scope audit` + * (FR-10), not a write-path gate on ordinary file I/O. + * - The D2 proof contract + self_check are AGENT JUDGMENT recorded here; + * the drop path ADVISES (exit 0) and never blocks on them. There is no + * configuration that makes any advisory blocking (BR-7). + */ + +/** C1 ratified closed sets (BR-4 — extending them is a convention change + * owned by pm-lead, not a code decision). */ +export const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const; +export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const; + +/** Video extensions for the C8 UX advisory (screencast evidence). */ +const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]); + +export interface C1Header { + slice: string; + candidate_sha: string; + artifact_type: string; + verdict: string; + money_evidence: string; + /** D2 optional attestation fields — advise-never-block. */ + evidences?: string[]; + self_check?: string; +} + +export interface C1ValidationResult { + ok: boolean; + missing: string[]; + invalid: Array<{ field: string; value: string; allowed: readonly string[] }>; +} + +/** Validate the five required C1 fields + closed sets. Pure. */ +export function validateC1Header(header: Partial): C1ValidationResult { + const missing: string[] = []; + for (const field of ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const) { + const value = header[field]; + if (typeof value !== "string" || value.trim().length === 0) missing.push(field); + } + const invalid: C1ValidationResult["invalid"] = []; + if (header.artifact_type && !(C1_ARTIFACT_TYPES as readonly string[]).includes(header.artifact_type)) { + invalid.push({ field: "artifact_type", value: header.artifact_type, allowed: C1_ARTIFACT_TYPES }); + } + if (header.verdict && !(C1_VERDICTS as readonly string[]).includes(header.verdict)) { + invalid.push({ field: "verdict", value: header.verdict, allowed: C1_VERDICTS }); + } + return { ok: missing.length === 0 && invalid.length === 0, missing, invalid }; +} + +/** + * Parse the pinned `## Proof contract` section out of a slice's + * IMPLEMENTATION-PRD.md (the C7 pinned name; PM-lane authored). Returns the + * promised items (checkbox-item form, one promised item per line), or null + * when the slice declares no contract (tier-1 degrade — zero noise). + */ +export function parseProofContract(prdContent: string): string[] | null { + const lines = prdContent.split("\n"); + const start = lines.findIndex((l) => /^##\s+Proof contract\s*$/i.test(l.trim())); + if (start === -1) return null; + const items: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]!; + if (/^##\s/.test(line)) break; // next section + const m = line.match(/^\s*-\s*(?:\[[ xX]\]\s*)?(.+\S)\s*$/); + if (m) items.push(m[1]!); + } + return items; +} + +function isVideoFile(filePath: string): boolean { + return VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +export function proofCommand(): Command { + const cmd = new Command("proof").description( + "Proof artifacts — drop gate/proof evidence into a slice's proof/ dir with the machine-readable C1 header, validated at the natural moment (OPR.0.4.4.19 FR-8). The drop is the SDLC's proof leg: --evidences joins it to the slice's ## Proof contract items (what the Living Notes DELIVERED section pairs + renders); proof-lock afterwards via rig scope slice approve --scope delivery. Conventions SSOT: docs/reference/sdlc-conventions.md." + ); + cmd.option("--workspace ", "Override workspace root (else cwd walk or $OPENRIG_WORK_ROOT)"); + + cmd + .command("add ") + .description("Drop a proof artifact: authors the C1 frontmatter from flags, writes /proof/, echoes the parsed header. Contract/self-check/C8 outputs are advisories (exit 0) — never gates.") + .option("--mission ", "Hint mission when slice-path is just NN-slug") + .requiredOption("--artifact-type ", `C1 artifact_type, one of: ${C1_ARTIFACT_TYPES.join(" | ")}`) + .requiredOption("--verdict ", `C1 verdict, one of: ${C1_VERDICTS.join(" | ")}`) + .requiredOption("--candidate-sha ", "C1 candidate_sha — the join key (convention C2): the proven candidate tip this artifact judges") + .requiredOption("--money-evidence ", "C1 money_evidence — the one line of money evidence") + .option("--slice-id ", "C1 slice dot-ID (defaults to the slice frontmatter id)") + .option("--file ", "Artifact body from a file (mutually exclusive with --body)") + .option("--body ", "Artifact body inline (mutually exclusive with --file)") + .option("--name ", "Artifact filename in proof/ (defaults to the --file basename, else --.md)") + .option("--evidences ", "D2 attestation: comma-separated proof-contract item refs this artifact covers (item text or 1-based index)") + .option("--self-check ", "D2 attestation: the agent's assertion that it LOOKED at the evidence and confirmed it shows the claim") + .option("--media ", "Corrective §3.4: comma-separated media refs (relative to the slice proof/ dir) this drop stands behind — appended to the artifact body as markdown refs so the composer curates them into delivered.items[].proof") + .option("--json", "JSON output for agents") + .action(async (slicePath: string, opts: { + mission?: string; + artifactType: string; + verdict: string; + candidateSha: string; + moneyEvidence: string; + sliceId?: string; + file?: string; + body?: string; + name?: string; + evidences?: string; + selfCheck?: string; + media?: string; + json?: boolean; + }, command: Command) => { + const json = Boolean(opts.json); + const advisories: string[] = []; + const warns: string[] = []; + try { + if (opts.file && opts.body) { + throw new ScopeCliError({ + fact: "Both --file and --body were provided.", + consequence: "The artifact body is ambiguous.", + action: "Pass exactly one of --file or --body .", + }); + } + const parentOpts = (command.parent?.opts() ?? {}) as { workspace?: string }; + const missionsRoot = resolveMissionsRoot({ override: parentOpts.workspace }); + const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null); + + // Resolve the artifact body. + let body = ""; + if (opts.file) { + if (!fs.existsSync(opts.file)) { + throw new ScopeCliError({ + fact: `--file ${opts.file} does not exist.`, + consequence: "No artifact body to drop.", + action: "Point --file at the evidence file, or use --body.", + }); + } + body = fs.readFileSync(opts.file, "utf8"); + } else if (opts.body) { + body = opts.body; + } + + // Corrective §3.4 — attach curated media refs (proof/-relative) to + // the artifact body as markdown refs; the composer projects them + // into delivered.items[].proof for the covered deliverables. Same + // containment discipline as --name: nothing outside the slice dir. + const sliceProofDir = path.join(slice.absPath, "proof"); + const mediaRefs = opts.media + ? opts.media.split(",").map((s) => s.trim()).filter(Boolean) + : []; + const mediaLines: string[] = []; + for (const ref of mediaRefs) { + if (path.isAbsolute(ref)) { + throw new ScopeCliError({ + fact: `--media ref '${ref}' is absolute.`, + consequence: "The artifact was NOT dropped — proof media is co-located slice content (FR-5), referenced relative to the slice's proof/ dir.", + action: "Copy the media into the slice's proof/ dir and pass the relative name.", + }); + } + const resolved = path.resolve(sliceProofDir, ref); + if (!resolved.startsWith(path.resolve(slice.absPath) + path.sep)) { + throw new ScopeCliError({ + fact: `--media ref '${ref}' resolves outside the slice dir.`, + consequence: "The artifact was NOT dropped — out-of-slice media can never be served or frozen with the review (FR-5).", + action: "Move the media under the slice dir (proof/ is the natural home) and re-run.", + }); + } + if (!fs.existsSync(resolved)) { + warns.push(`--media ref '${ref}' does not exist yet (${resolved}) — the review will show it as unavailable until the file lands`); + } + const ext = path.extname(ref).toLowerCase(); + if (VIDEO_EXTENSIONS.has(ext)) mediaLines.push(``); + else mediaLines.push(`![${ref}](${ref})`); + } + if (mediaLines.length > 0) { + body = `${body.trimEnd()}\n\n## Media\n\n${mediaLines.join("\n")}\n`; + } + + // Author the C1 header from flags + slice identity. + const header: Partial = { + slice: opts.sliceId ?? (typeof slice.id === "string" ? slice.id : undefined), + candidate_sha: opts.candidateSha, + artifact_type: opts.artifactType, + verdict: opts.verdict, + money_evidence: opts.moneyEvidence, + }; + const evidences = opts.evidences + ? opts.evidences.split(",").map((s) => s.trim()).filter(Boolean) + : undefined; + if (evidences && evidences.length > 0) header.evidences = evidences; + if (opts.selfCheck) header.self_check = opts.selfCheck; + + // Validate the closed sets AT DROP TIME (while the evidence is + // in-hand) — the one REJECTING validation this path performs. + const validation = validateC1Header(header); + if (!validation.ok) { + const parts: string[] = []; + if (validation.missing.length > 0) parts.push(`missing required C1 field(s): ${validation.missing.join(", ")}`); + for (const inv of validation.invalid) { + parts.push(`${inv.field}='${inv.value}' is not in the ratified closed set (${inv.allowed.join(" | ")})`); + } + throw new ScopeCliError({ + fact: `C1 header invalid — ${parts.join("; ")}.`, + consequence: "The artifact was NOT dropped (post-hoc reconstruction is the failure mode this fights).", + action: "Provide the named fields with allowed values and re-run while the evidence is in-hand. Extending the closed sets is a pm-lead convention change (BR-4).", + }); + } + + // D2 — validate evidences refs against the slice's declared proof + // contract (unknown refs = a named WARN, never a rejection), and + // emit the coverage/self_check ADVISORY when a contract exists. + const prdPath = path.join(slice.absPath, "IMPLEMENTATION-PRD.md"); + const contractItems = fs.existsSync(prdPath) + ? parseProofContract(fs.readFileSync(prdPath, "utf8")) + : null; + let coveredItems: string[] = []; + if (contractItems && contractItems.length > 0) { + if (evidences && evidences.length > 0) { + for (const ref of evidences) { + const byIndex = /^\d+$/.test(ref) ? contractItems[Number.parseInt(ref, 10) - 1] : undefined; + const byText = contractItems.find((item) => item === ref); + const match = byText ?? byIndex; + if (match) coveredItems.push(match); + else warns.push(`evidences ref '${ref}' matches no declared proof-contract item (known items: ${contractItems.map((_, i) => i + 1).join(", ")} or exact text)`); + } + } + if (coveredItems.length === 0 || !header.self_check) { + const uncovered = contractItems.filter((item) => !coveredItems.includes(item)); + const reasons: string[] = []; + if (coveredItems.length === 0) reasons.push("this drop covers no declared contract item"); + if (!header.self_check) reasons.push("self_check attestation omitted"); + advisories.push( + `ADVISORY (D2, advise-never-block): ${reasons.join(" and ")}. ` + + `Uncovered contract item(s): ${uncovered.map((u) => `"${u}"`).join(", ")}. ` + + `The Packet-2 promised→delivered join will show these as MISSING (the ▲ insufficient-proof signal).` + ); + } + } + + // FR-11 / C8 — the UX-slice video advisory (SHOULD/steer, exit 0, + // no configuration can make it blocking). Trigger: slice frontmatter + // ux-change: true (spec-time flag; never a qitem tag, never + // diff-inference). Satisfied when this drop is a video or the + // proof/ dir already holds one. + const uxChange = slice.frontmatter["ux-change"] === true; + if (uxChange) { + const proofDir = path.join(slice.absPath, "proof"); + const existingVideo = fs.existsSync(proofDir) + && fs.readdirSync(proofDir).some((f) => isVideoFile(f)); + const droppingVideo = (opts.file ? isVideoFile(opts.file) : false) || mediaRefs.some((r) => isVideoFile(r)); + if (!existingVideo && !droppingVideo) { + advisories.push( + "ADVISORY (C8, SHOULD/steer): this slice is UX-tagged (ux-change: true) and its proof set has no video. " + + "UX-change slices SHOULD produce screenshot + video together — capture a screencast via the agent-browser-screencast method " + + "and hold it to the money-shot-edit bar. This never blocks a drop." + ); + } + } + + // Write the artifact: YAML frontmatter + body into proof/. + const proofDir = path.join(slice.absPath, "proof"); + const defaultName = `${opts.artifactType}-${opts.verdict}-${new Date().toISOString().replace(/[:.]/g, "-")}.md`; + const fileName = opts.name ?? (opts.file ? path.basename(opts.file) : defaultName); + // rev1-r2 BLOCKING fix (a7dedd93 review): --name is a FILENAME, never + // a path. Reject separators / dot-dot / absolute shapes BEFORE any + // filesystem effect, so the drop can only land inside proof/ (the + // FR-8 contract) — a traversal name like ../README.md must not reach + // slice control files. + if (fileName.includes("/") || fileName.includes("\\") || fileName.startsWith("..") || path.isAbsolute(fileName)) { + throw new ScopeCliError({ + fact: `--name '${fileName}' is not a plain filename (path separators, '..', and absolute paths are rejected).`, + consequence: "The artifact was NOT dropped — proof drops land inside the slice proof/ dir only (FR-8).", + action: "Pass a bare filename like qa-clear.md; the drop path owns the directory.", + }); + } + const target = path.resolve(proofDir, fileName); + // Defense-in-depth: even a name that slips the shape check must + // resolve INSIDE proof/ (same containment discipline as + // scope-approve's path-escape guard). + if (!target.startsWith(path.resolve(proofDir) + path.sep)) { + throw new ScopeCliError({ + fact: `--name '${fileName}' resolves outside the slice proof/ dir.`, + consequence: "The artifact was NOT dropped.", + action: "Pass a bare filename; the drop path owns the directory.", + }); + } + fs.mkdirSync(proofDir, { recursive: true }); + const frontmatter = YAML.stringify(header).trimEnd(); + fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, "utf8"); + + // Echo the parsed header — the seat sees what the composer will see. + const echo = { + dropped: path.relative(process.cwd(), target), + header: header as C1Header, + contractItemsDeclared: contractItems?.length ?? 0, + contractItemsCovered: coveredItems, + mediaRefs, + warnings: warns, + advisories, + }; + if (json) { + console.log(JSON.stringify(echo, null, 2)); + } else { + console.log(`Dropped: ${echo.dropped}`); + console.log(`Parsed C1 header:\n${frontmatter}`); + if (contractItems) console.log(`Proof contract: ${coveredItems.length}/${contractItems.length} item(s) covered by this drop.`); + for (const w of warns) console.error(`warning: ${w}`); + for (const a of advisories) console.error(a); + } + // Advisories + warns NEVER change the exit code (BR-7). + } catch (err) { + if (err instanceof ScopeCliError) { + if (json) { + console.log(JSON.stringify({ ok: false, error: { fact: err.fact, consequence: err.consequence, action: err.action } }, null, 2)); + } else { + console.error(`${err.fact}\n${err.consequence}\n${err.action}`); + } + process.exitCode = 1; + return; + } + throw err; + } + }); + + return cmd; +} diff --git a/packages/cli/src/commands/ps.ts b/packages/cli/src/commands/ps.ts index eb3c8bea0..26e2e7a99 100644 --- a/packages/cli/src/commands/ps.ts +++ b/packages/cli/src/commands/ps.ts @@ -7,6 +7,7 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer, import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js"; import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js"; import { readOpenRigEnv } from "../openrig-compat.js"; +import type { AggregatedPayload, PerHostStatus } from "../lib/hosts/fanout-contract.js"; interface PsEntry { rigId: string; @@ -24,6 +25,9 @@ interface PsEntry { * derived from tmux output. */ hasWorkCount?: number; status: "running" | "partial" | "stopped"; + /** OPR.0.4.4.21 — additive: seats needing attention (lifecycle/startup + * attention, needs_input, held, startup error) folded daemon-side. */ + attentionCount?: number; lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required"; uptime: string | null; latestSnapshot: string | null; @@ -145,6 +149,7 @@ const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="]; // rig-level only; `rigName` is the alias and exists at both levels (per the // closed `rig ps trust and scale-safety` slice at openrig 0a9fb43). const ALLOWED_RIG_FIELDS = new Set([ + "attentionCount", "rigId", "name", "rigName", @@ -338,6 +343,56 @@ function parseFields(input: string, allowed: Set, level: "rig" | "nodes" return fields; } +interface ParsedPsControls { + parsedFilter: ParsedFilter | null; + limit: number | null; + fields: string[] | null; + useEnvelope: boolean; +} + +function parsePsControls(opts: PsCliOptions): ParsedPsControls | { error: string } { + let effectiveFilter = opts.filter; + if (opts.active) { + if (effectiveFilter) { + return { + error: + `--active and --filter cannot be combined. ` + + `--active is sugar for --filter agentActivity.state=running. ` + + `Pick one form, or compose by upgrading to --filter directly.`, + }; + } + effectiveFilter = "agentActivity.state=running"; + } + + let parsedFilter: ParsedFilter | null = null; + if (effectiveFilter) { + const result = parseFilter(effectiveFilter); + if ("error" in result) return result; + parsedFilter = result; + } + + const limit = opts.limit !== undefined ? Number(opts.limit) : null; + if (limit !== null && (!Number.isInteger(limit) || limit < 0)) { + return { error: `--limit must be a non-negative integer; got '${opts.limit}'` }; + } + + let fields: string[] | null = null; + if (opts.fields !== undefined) { + const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig"; + const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS; + const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel); + if ("error" in fieldsResult) return fieldsResult; + fields = fieldsResult; + } + + return { + parsedFilter, + limit, + fields, + useEnvelope: parsedFilter !== null || limit !== null || fields !== null || opts.summary === true, + }; +} + function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] { return entries.filter((e) => { if (filter.key === "status") return e.status === filter.value; @@ -390,6 +445,71 @@ function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[ // OPR.0.3.3.19 - build the /api/ps path, opting archived rigs back in when // --include-archived is set. Default (no flag) leaves the daemon's // archived-excluding default in force. +function fanOutNodesError(): string { + return [ + "rig ps --all-hosts/--hosts --nodes: per-node fan-out requires the FULL explicit ladder.", + " rig ps --all-hosts --nodes -A (fleet nodes per host, projected rows)", + " rig ps --all-hosts --nodes -A --full (complete per-node records — the last rung)", + "One rig's seats on one host: rig ps --host --nodes --rig .", + ].join("\n"); +} + +/** + * OPR.0.4.4.21 FR-2/FR-3 — THE centralized disclosure-ladder validator. + * Runs BEFORE the local/HTTP-host/SSH/fan-out dispatch split so every path + * obeys one grammar (qa1 plan-review: remote mode bypassed the session-rig + * injection and fanned wide). Principle (arch-ratified): IMPLICIT SCOPE + * DEFAULTS DON'T CROSS HOST BOUNDARIES — the session-rig default has no + * stable referent on a remote host (a same-named rig there would silently + * misresolve), so remote per-node views are explicit-or-error. + * Returns the teaching error text, or null when the invocation is valid. + */ +export function validatePsLadder(opts: PsCliOptions, callerRig: string | undefined): string | null { + const isFanOut = !!(opts.allHosts || opts.hosts); + const isSingleRemote = !!opts.host; + + // FR-3: -A/--all-rigs has exactly ONE meaning — the --nodes fleet widener. + if (opts.allRigs && !opts.nodes) { + return [ + "rig ps -A: '-A/--all-rigs' now has exactly one meaning — the --nodes fleet widener.", + "The consolidated all-rigs view IS the default: just run 'rig ps'.", + "Fleet nodes: rig ps --nodes -A (add --full for complete per-node records).", + "Archived history: rig ps --include-archived.", + ].join("\n"); + } + + // FR-5: multi-host fan-out is rollup-only UNLESS the full explicit + // ladder is requested — `--nodes -A` per host (`--full` for complete + // records), exactly as the PRD writes it. Anything less explicit under + // --nodes errors; never silent rig-tier data under a --nodes flag. + if (opts.nodes && isFanOut && !opts.allRigs) { + return fanOutNodesError(); + } + + // FR-2: --nodes always names its scope — session default locally, + // explicit --rig / -A everywhere else. Never an implicit fan-out. + if (opts.nodes && !opts.rig && !opts.allRigs) { + if (isFanOut) { + return fanOutNodesError(); + } + if (isSingleRemote) { + return [ + `rig ps --host ${opts.host} --nodes: no target — the local session's rig is not a remote scope`, + "(implicit scope defaults don't cross host boundaries; a same-named remote rig would silently misresolve).", + `Name one: rig ps --host ${opts.host} --nodes --rig , or that host's fleet explicitly: rig ps --host ${opts.host} --nodes -A.`, + ].join("\n"); + } + if (!callerRig) { + return [ + "rig ps --nodes: no target — outside a managed session there is no current rig to default to.", + "Name one: rig ps --nodes --rig , or go fleet-wide explicitly: rig ps --nodes -A.", + ].join("\n"); + } + } + + return null; +} + function psApiPath(opts: PsCliOptions): string { return opts.includeArchived ? "/api/ps?includeArchived=true" : "/api/ps"; } @@ -402,6 +522,13 @@ function selectFields>(entries: T[], fields: s }); } +function selectFanOutFields(entries: Array>, fields: string[]): Array> { + return entries.map((e) => { + const [selected] = selectFields([e], fields); + return { ...selected, hostId: e.hostId }; + }); +} + function needsAttention(node: NodeEntry): boolean { return node.lifecycleState === "attention_required" || node.startupStatus === "attention_required" @@ -508,22 +635,39 @@ function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): st } /** - * `rig ps` — list running rigs and optionally their nodes. + * `rig ps` — the consolidated fleet map + explicit disclosure ladder. * - * L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`, - * `--filter`, `--full`) for context-window-safe output on large hosts. Default - * `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals - * and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape - * for back-compat; the truncation/envelope shape only applies when at least - * one of `--limit`/`--summary`/`--fields`/`--filter` is specified. + * OPR.0.4.4.21: the default is ALL active rigs, one compact O(rigs) row each + * (the current-rig-only default is retired). The token-safety invariant: no + * invocation returns per-node detail across all rigs unless explicitly + * flagged (--nodes -A), and even then rows are projected unless --full. + * Default `rig ps --json` keeps the bare-array shape for back-compat; the + * truncation/envelope shape only applies when at least one of + * `--limit`/`--summary`/`--fields`/`--filter` is specified. */ export function psCommand(depsOverride?: PsDeps): Command { const cmd = new Command("ps") .description("List rigs and their status") .addHelpText("after", ` -Default: current-rig scope (derived from OPENRIG_SESSION_NAME's @ suffix). -Shows ALL states (stopped, recoverable, attention, idle — non-running IS the -actionable signal for topology/readiness). Use -A/--all-rigs for fleet breadth. +Default (OPR.0.4.4.21): ALL active rigs, ONE compact row each — O(rigs), never +a fleet node fan-out — plus the host rollup line ("N rigs · M seats · K need +attention"), the archived/stopped count line, and the drill-ladder footer. +STATED contract: default --json is a bare array of ALL non-archived rigs +INCLUDING stopped ones (existing keys preserved; additive attentionCount); +only the HUMAN table folds stopped rigs into the count line. + +The disclosure ladder (each heavier view is an explicit step): + rig ps the consolidated map (this default) + rig ps --rig one rig's detail + rig ps --nodes per-node, CURRENT rig (session default, local only) + rig ps --nodes --rig per-node, named rig + rig ps --nodes -A fleet nodes, projected rows + rig ps --nodes -A --full complete per-node records (the only full fan-out) + +-A/--all-rigs has exactly ONE meaning: the --nodes fleet widener. Bare -A +errors (all-rigs IS the default; archived history stays behind +--include-archived). The session-rig default never crosses host boundaries: +remote --nodes requires an explicit --rig or -A. Compact defaults: 'rig ps --nodes' shows a compact summary per node (rig, session, lifecycle, activity state, reason when attention, queue counts, @@ -537,11 +681,10 @@ null even with --full; fetch the full recovery guidance + currentUsage from the single-node detail (/api/rigs/:rigId/nodes/:logicalId) or 'rig whoami'. Examples: - rig ps Current rig summary (compact) - rig ps -A All rigs (fleet breadth) - rig ps --full Current rig, full detail - rig ps --json Current rig, compact JSON - rig ps -A --json All rigs, compact JSON + rig ps All active rigs, one row each + rollup + rig ps --rig One rig's detail + rig ps --full All rigs, no table truncation + rig ps --json All non-archived rigs, bare JSON array rig ps --json --limit 20 Bounded JSON envelope rig ps --json --summary Aggregate-only JSON rig ps --json --fields rigName,status,lifecycleState @@ -561,12 +704,16 @@ Examples: rig ps --nodes --running Same as --active rig ps --nodes --filter agentActivity.state=running Same as --active (the explicit form) - rig ps --host vm-claude-test --nodes --json Remote host (no current-rig default) + rig ps --host vm-1 --nodes --rig --json Remote host per-node (explicit target required) + rig ps --all-hosts Per-host O(rigs) rollups (AggregatedPayload JSON) rig ps --include-archived Include archived rigs (marked with *); hidden by default ---rig overrides the current-rig default. -A/--all-rigs disables it. ---session filters within the effective rig scope; use -A if the target -session is in a different rig. +--rig scopes to one rig. -A/--all-rigs is ONLY the --nodes fleet widener. +--session filters within the effective rig scope; use --nodes -A if the +target session is in a different rig. +Multi-host fan-out (--all-hosts/--hosts) is rollup-only by default; the full +explicit ladder (--all-hosts --nodes -A, --full for complete records) fans out +per-node with hostId-stamped projected rows. --active/--running narrow to agentActivity.state=running. Cannot combine with --filter. @@ -574,7 +721,8 @@ session is in a different rig. contextUsage.percent, contextUsage.state. Other keys are rejected. --fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount, -activeCount, hasWorkCount, status, lifecycleState, uptime, latestSnapshot. +activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime, +latestSnapshot. --fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId, podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand, @@ -612,15 +760,53 @@ Exit codes: if (opts.verbose) opts.full = true; if (opts.running) opts.active = true; const isRemote = !!(opts.host || opts.allHosts || opts.hosts); - if (!opts.allRigs && !opts.rig && !isRemote) { - const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"); - const callerRig = sessionName ? extractRigName(sessionName) : undefined; - if (callerRig) opts.rig = callerRig; + // OPR.0.4.4.21 FR-1: the rig tier is consolidated ALL-ACTIVE-RIGS by + // default (the current-rig-only default is RETIRED — it hid running + // rigs from the operator's field of view). The session-rig default + // now applies ONLY to the node tier (FR-2's scoped --nodes), and + // ONLY locally (the ladder validator enforces explicit-or-error on + // every remote path). + const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"); + const callerRig = sessionName ? extractRigName(sessionName) : undefined; + const ladderError = validatePsLadder(opts, callerRig); + if (ladderError) { + console.error(ladderError); + process.exitCode = 1; + return; + } + if (opts.nodes && !opts.allRigs && !opts.rig && !isRemote && callerRig) { + opts.rig = callerRig; } const deps = getDepsF(); + // OPR.0.4.4.21 rev1-r2 fixback: the shared shaping controls + // (--active/--filter/--limit/--fields/--summary) parse + validate + // BEFORE any dispatch — local, single-host, or fan-out — so every + // path honors the same composition contract and rejections stay + // pre-HTTP on remote paths too. + // Parse shared composition controls once up front so local, single-host, + // and fan-out paths reject malformed --filter/--limit/--fields before + // any HTTP call and apply the same shaping semantics. + const controls = parsePsControls(opts); + if ("error" in controls) { + console.error(controls.error); + process.exitCode = 1; + return; + } + const { parsedFilter, limit, fields, useEnvelope } = controls; + if (opts.allHosts || opts.hosts) { - await runFanOutPs(opts, deps); + // AggregatedPayload is the closed fan-out contract (items + hosts). + // Do not add an ad-hoc summary member here; teach the per-host form. + if (opts.summary) { + console.error( + "rig ps --all-hosts/--hosts --summary: summary does not compose with the merged fan-out payload.\n" + + "Summarize one host: rig ps --host --summary; or drop --summary for the merged AggregatedPayload.", + ); + process.exitCode = 1; + return; + } + await runFanOutPs(opts, deps, { parsedFilter, limit, fields }); return; } @@ -636,62 +822,6 @@ Exit codes: return; } - // Parse filter once up front so unknown keys/malformed filters surface - // as exit-1 errors before any HTTP call. - // - // PL-019 item 1: --active is sugar for --filter agentActivity.state=running. - // Combining --active with --filter is rejected (composition is - // ambiguous — pick one explicit form). Parity is verified end-to-end - // by the focused test: `--active` and the explicit filter must yield - // identical output on the same fixture. - let effectiveFilter = opts.filter; - if (opts.active) { - if (effectiveFilter) { - console.error( - `--active and --filter cannot be combined. ` + - `--active is sugar for --filter agentActivity.state=running. ` + - `Pick one form, or compose by upgrading to --filter directly.` - ); - process.exitCode = 1; - return; - } - effectiveFilter = "agentActivity.state=running"; - } - let parsedFilter: ParsedFilter | null = null; - if (effectiveFilter) { - const result = parseFilter(effectiveFilter); - if ("error" in result) { - console.error(result.error); - process.exitCode = 1; - return; - } - parsedFilter = result; - } - - const limit = opts.limit !== undefined ? Number(opts.limit) : null; - if (limit !== null && (!Number.isInteger(limit) || limit < 0)) { - console.error(`--limit must be a non-negative integer; got '${opts.limit}'`); - process.exitCode = 1; - return; - } - // C9a: validate --fields against the per-level allow-list before any HTTP - // call, mirroring the --filter validation above. Level is determined by - // --nodes; both branches downstream use the same `fields` variable, so - // one up-front validation covers both paths. - let fields: string[] | null = null; - if (opts.fields !== undefined) { - const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig"; - const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS; - const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel); - if ("error" in fieldsResult) { - console.error(fieldsResult.error); - process.exitCode = 1; - return; - } - fields = fieldsResult; - } - const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true; - const client = deps.clientFactory(getDaemonUrl(status)); if (opts.nodes) { @@ -699,7 +829,10 @@ Exit codes: return; } - const res = await client.get(psApiPath(opts)); + // OPR.0.4.4.21 FR-1: ONE O(rigs) fetch including archived so the + // count line can be computed; visibility is split client-side below + // (JSON keeps today's default of excluding archived — parity). + const res = await client.get("/api/ps?includeArchived=true"); if (res.status >= 400) { console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`); @@ -709,9 +842,17 @@ Exit codes: const all = res.data; + // OPR.0.4.4.21 — archived visibility parity: the daemon call above + // always includes archived (for the count line); without + // --include-archived they are dropped from BOTH renders here, exactly + // as the daemon default used to do. archivedCount feeds the FR-1 + // count line only. + const archivedCount = all.filter((e) => e.isArchived === true).length; + const visible = opts.includeArchived ? all : all.filter((e) => e.isArchived !== true); + const rigScoped = opts.rig - ? all.filter((e) => (e.rigName ?? e.name) === opts.rig) - : all; + ? visible.filter((e) => (e.rigName ?? e.name) === opts.rig) + : visible; // Apply CLI-side filter (Amendment A: prefer CLI shaping). const filtered = parsedFilter ? applyRigFilter(rigScoped, parsedFilter) : rigScoped; @@ -756,17 +897,35 @@ Exit codes: if (limited.length === 0) { if (parsedFilter) { console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`); + } else if (archivedCount > 0 && !opts.includeArchived) { + // Proven-empty with the history pointer, never a bare "No rigs" + // while archived history exists. + console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)`); } else { console.log("No rigs"); } return; } - // Human output: apply default truncation budget unless --full. - const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET); - const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET; + // OPR.0.4.4.21 FR-1 (human table ONLY — JSON keeps ALL non-archived + // entries including stopped; this scope split is a STATED contract + // sentence in the help text): on the BARE default (no filter, no + // --rig, no --include-archived) stopped rigs fold into the count + // line — history is what the default drops, not field of view. + const bareDefault = !parsedFilter && !opts.rig && !opts.includeArchived; + const tableRows = bareDefault ? limited.filter((e) => e.status !== "stopped") : limited; + const stoppedCount = bareDefault ? limited.length - tableRows.length : 0; - const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT"); + // FR-1 display element 1: the host rollup line. + const rollupSeats = tableRows.reduce((n, e) => n + e.nodeCount, 0); + const rollupAttention = tableRows.reduce((n, e) => n + (e.attentionCount ?? 0), 0); + console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention`); + + // Human output: apply default truncation budget unless --full. + const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET); + const humanTruncated = !opts.full && limit === null && tableRows.length > HUMAN_RIG_BUDGET; + + const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT"); console.log(header); let anyArchivedShown = false; for (const e of humanList as PsEntry[]) { @@ -781,6 +940,9 @@ Exit codes: // Slice 15 — "—" when daemon predates the field; honest absence. e.activeCount !== undefined ? String(e.activeCount) : "—", e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—", + // OPR.0.4.4.21 — the founder's field-of-view anchor: where is + // something that might concern me. "—" = daemon predates the field. + e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—", e.status, abbrevRigLifecycle(e.lifecycleState), e.uptime ?? "—", @@ -790,6 +952,17 @@ Exit codes: if (anyArchivedShown) { console.log("* = archived (hidden from the default view; shown via --include-archived). Reverse with: rig unarchive "); } + // FR-1 display element 2: history as ONE count line, never rows. + if (stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0)) { + const parts: string[] = []; + if (stoppedCount > 0) parts.push(`${stoppedCount} stopped (rig ps --filter status=stopped)`); + if (!opts.includeArchived && archivedCount > 0) parts.push(`${archivedCount} archived (rig ps --include-archived)`); + console.log(`not shown: ${parts.join(" · ")}`); + } + // FR-1 display element 3: the affordance footer — the drill ladder. + if (bareDefault) { + console.log("drill: rig ps --rig (one rig) · rig ps --nodes --rig (its seats) · --full (everything)"); + } if (humanTruncated) { const remaining = filtered.length - HUMAN_RIG_BUDGET; console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`); @@ -835,7 +1008,12 @@ async function handleNodes( console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`); continue; } - allNodes.push(...nodesRes.data); + const parentRigName = rig.rigName ?? rig.name; + allNodes.push(...nodesRes.data.map((n) => ({ + ...n, + rigId: n.rigId ?? rig.rigId, + rigName: n.rigName ?? parentRigName, + }))); } let narrowed = allNodes; @@ -961,7 +1139,7 @@ function fitCell(value: string, width: number): string { return truncate(value, width).padEnd(width); } -function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, status: string, lifecycle: string, uptime: string, snapshot: string): string { +function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, attn: string, status: string, lifecycle: string, uptime: string, snapshot: string): string { return [ fitCell(rig, 24), fitCell(nodes, 7), @@ -972,6 +1150,8 @@ function padRigRow(rig: string, nodes: string, running: string, active: string, // operators see which dimension differs at a glance. fitCell(active, 8), fitCell(work, 6), + // OPR.0.4.4.21 — ATTN: seats needing attention (the field-of-view anchor). + fitCell(attn, 6), fitCell(status, 10), fitCell(lifecycle, 11), fitCell(uptime, 11), @@ -1077,7 +1257,10 @@ async function runCrossHostPs( // OPR.0.4.0.34: forward the breadth flag so `--host h -A` keeps all-rigs // breadth across the hop (the current-rig default is local-only and never // applied to a remote call, but an explicit -A must still reach the remote). - if (opts.allRigs) argv.push("--all-rigs"); + // OPR.0.4.4.21 FR-3: -A is legal only alongside --nodes (the validator + // already rejected the bare form before any dispatch; this guard keeps + // the reconstructed remote argv obeying the same grammar). + if (opts.allRigs && opts.nodes) argv.push("--all-rigs"); if (opts.limit !== undefined) argv.push("--limit", opts.limit); if (opts.fields !== undefined) argv.push("--fields", opts.fields); if (opts.summary) argv.push("--summary"); @@ -1127,39 +1310,16 @@ async function runHttpPs( return; } - let effectiveFilter = opts.filter; - if (opts.active) { - if (effectiveFilter) { - console.error("--active and --filter cannot be combined."); - process.exitCode = 1; - return; - } - effectiveFilter = "agentActivity.state=running"; - } - let parsedFilter: ParsedFilter | null = null; - if (effectiveFilter) { - const result = parseFilter(effectiveFilter); - if ("error" in result) { console.error(result.error); process.exitCode = 1; return; } - parsedFilter = result; - } - const limit = opts.limit !== undefined ? Number(opts.limit) : null; - if (limit !== null && (!Number.isInteger(limit) || limit < 0)) { - console.error(`--limit must be a non-negative integer; got '${opts.limit}'`); + const controls = parsePsControls(opts); + if ("error" in controls) { + console.error(controls.error); process.exitCode = 1; return; } - let fields: string[] | null = null; - if (opts.fields !== undefined) { - const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig"; - const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS; - const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel); - if ("error" in fieldsResult) { console.error(fieldsResult.error); process.exitCode = 1; return; } - fields = fieldsResult; - } + const { parsedFilter, limit, fields, useEnvelope } = controls; const client = deps.clientFactory(host.url); const headers = buildRemoteHeaders(bearerResult.token); - const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true; try { if (opts.nodes) { @@ -1211,15 +1371,38 @@ async function runHttpPs( } } +/** INTERNAL transport result of one fan-out leg (arch adjudication: the + * shared P4 contract is fanout-contract's AggregatedPayload/PerHostStatus; + * this shape survives only as the fan-out's internal carriage, adapted + * below before anything is emitted). */ interface FanOutHostResult { host: string; ok: boolean; - failedStep: import("../cross-host-types.js").FailedStep; + failedStep: import("../cross-host-types.js").FailedStep | "unsupported-transport"; data?: unknown; error?: string; } -async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise { +/** OPR.0.4.4.21 FR-5 — adapter to the intra-P4 shared contract + * (fanout-contract.ts, slice 15 first-lander at 0ecd329b; the closed + * status enum is THE contract, failedStep rides as additive detail). */ +function toPerHostStatus(r: FanOutHostResult): PerHostStatus { + const status: PerHostStatus["status"] = + r.ok ? "ok" + : r.failedStep === "unsupported-transport" ? "unsupported-transport" + : r.failedStep === "permission-gate" ? "auth-failed" + : "unreachable"; + const out: PerHostStatus = { hostId: r.host, status }; + if (r.error) out.error = r.error; + if (!r.ok && r.failedStep !== "unsupported-transport") out.failedStep = r.failedStep; + return out; +} + +async function runFanOutPs( + opts: PsCliOptions, + deps: PsDeps, + shaping: { parsedFilter: ParsedFilter | null; limit: number | null; fields: string[] | null }, +): Promise { const loader = deps.hostRegistryLoader ?? loadHostRegistry; const registry = loader(); if (!registry.ok) { @@ -1239,7 +1422,10 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise { return; } } else { - targetIds = allHosts.filter((h) => h.transport === "http").map((h) => h.id); + // OPR.0.4.4.21 fixback (qa1 F1): target EVERY declared host — non-HTTP + // hosts must appear in hosts[] as unsupported-transport (R15-2), never + // be silently absent. The per-host leg classifies transport. + targetIds = allHosts.map((h) => h.id); } const results: FanOutHostResult[] = await Promise.all( @@ -1247,7 +1433,10 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise { const host = allHosts.find((h) => h.id === id); if (!host) return { host: id, ok: false, failedStep: "remote-daemon-unreachable", error: `unknown host ${id}` }; if (host.transport !== "http") { - return { host: id, ok: false, failedStep: "remote-command-failed", error: `host ${id} uses transport ${host.transport}; HTTP fan-out requires transport: http` }; + // R15-2 (shared contract): an SSH-declared host is a STRUCTURED + // unsupported-transport status — never prose-only, never silently + // thinner output. + return { host: id, ok: false, failedStep: "unsupported-transport", error: `host ${id} uses transport ${host.transport}; HTTP fan-out requires transport: http` }; } const httpHost = host as HttpHostEntry; const bearerResult = resolveRemoteBearer(httpHost); @@ -1255,16 +1444,35 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise { return { host: id, ok: false, failedStep: bearerResult.failedStep, error: bearerResult.error }; } const client = deps.clientFactory(httpHost.url); + const headers = buildRemoteHeaders(bearerResult.token); try { const psQuery = opts.includeArchived ? "?includeArchived=true" : ""; - const res = await client.get(`/api/ps${psQuery}`, { - headers: buildRemoteHeaders(bearerResult.token), - }); + const res = await client.get(`/api/ps${psQuery}`, { headers }); const failedStep = classifyHttpFailedStep(res.status); if (failedStep !== "none") { return { host: id, ok: false, failedStep, error: `HTTP ${res.status}` }; } - return { host: id, ok: true, failedStep: "none", data: res.data }; + if (!opts.nodes) { + return { host: id, ok: true, failedStep: "none", data: res.data }; + } + // OPR.0.4.4.21 FR-5 — the full explicit ladder (--nodes -A, --full + // for complete records): per-host node fan-out, projected unless + // --full (the invariant's last rung; validated as -A-only upstream). + const rigs = Array.isArray(res.data) ? (res.data as PsEntry[]) : []; + const nodes: NodeEntry[] = []; + for (const rig of rigs) { + const nodesRes = await client.get(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`, { headers }); + if (nodesRes.status >= 400) { + return { host: id, ok: false, failedStep: classifyHttpFailedStep(nodesRes.status), error: `HTTP ${nodesRes.status} fetching nodes for rig ${rig.rigName ?? rig.name}` }; + } + const parentRigName = rig.rigName ?? rig.name; + nodes.push(...nodesRes.data.map((n) => ({ + ...n, + rigId: n.rigId ?? rig.rigId, + rigName: n.rigName ?? parentRigName, + }))); + } + return { host: id, ok: true, failedStep: "none", data: nodes }; } catch (err) { return { host: id, ok: false, failedStep: classifyHttpError(err), error: (err as Error).message }; } @@ -1274,7 +1482,51 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise { const hasFailure = results.some((r) => !r.ok); if (opts.json) { - console.log(JSON.stringify({ hosts: results })); + // OPR.0.4.4.21 FR-5 — the intra-P4 shared payload (ONE contract with + // slice 15): items = each host's rows stamped with their origin hostId + // (flat-mergeable; origin never positional), hosts = the per-host + // structured status array. EVERY targeted host appears in hosts[] — + // ok or not (no silent thinning). + const rawItems: Array> = results.flatMap((r) => + r.ok && Array.isArray(r.data) + ? (r.data as Array>).map((row) => ({ ...row, hostId: r.host })) + : [], + ); + const hostStatuses = results.map(toPerHostStatus); + let narrowed = rawItems; + if (opts.rig) narrowed = narrowed.filter((e) => (e.rigName ?? e.name) === opts.rig); + if (opts.nodes && opts.session) narrowed = narrowed.filter((n) => n.canonicalSessionName === opts.session); + + const filtered = shaping.parsedFilter + ? opts.nodes + ? (applyNodeFilter(narrowed as unknown as NodeEntry[], shaping.parsedFilter) as unknown as Array>) + : (applyRigFilter(narrowed as unknown as PsEntry[], shaping.parsedFilter) as unknown as Array>) + : narrowed; + + let items: Array>; + if (opts.summary) { + items = [ + opts.nodes + ? summarizeNodes(filtered as unknown as NodeEntry[]) + : summarizeRigs(filtered as unknown as PsEntry[]), + ]; + } else { + const limited = shaping.limit !== null ? filtered.slice(0, shaping.limit) : filtered; + items = shaping.fields + ? selectFanOutFields(limited, shaping.fields) + : opts.nodes && !opts.full + ? compactNodeProjection(limited as unknown as NodeEntry[]).map((row, i) => ({ + ...row, + hostId: limited[i]?.hostId, + })) + : limited; + } + + const payload: AggregatedPayload> = { + items, + hosts: hostStatuses, + }; + console.log(JSON.stringify(payload)); } else { for (const r of results) { if (r.ok) { diff --git a/packages/cli/src/commands/queue.ts b/packages/cli/src/commands/queue.ts index a573c388a..56af7cc3b 100644 --- a/packages/cli/src/commands/queue.ts +++ b/packages/cli/src/commands/queue.ts @@ -214,6 +214,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { .option("--id ", "Idempotent qitem_id (skip if not provided)") .option("--target-repo ", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])") .option("--summary ", "OPR.0.4.1.18: short human-readable 1-2 sentence summary of the work (feeds the Story node label; the agent-speak --body stays the source of truth). Warned-if-missing; pre-18 qitems exempt.") + .option("--evidence-ref ", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.") .option("--no-nudge", "Suppress the default destination nudge (cold-queue)") .option("--json", "JSON output for agents") .action(async (opts: { @@ -231,6 +232,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { id?: string; targetRepo?: string; summary?: string; + evidenceRef?: string; nudge?: boolean; json?: boolean; }) => { @@ -277,6 +279,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { destinationSession: opts.destination, body: resolvedBody, summary: opts.summary, + evidenceRef: opts.evidenceRef, priority: opts.priority, tier: opts.tier, tags, @@ -331,6 +334,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command { .requiredOption("--state ", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off") .option("--closure-reason ", "Required for state=done") .option("--closure-target ", "Required for handed_off_to, blocked_on, escalation") + .option("--blocked-on ", "For state=blocked: the blocker (a qitem id, or a human seat for the OPR.0.4.4.19 FR-6 park — human parks require summary + evidence_ref on the item)") + .option("--summary ", "OPR.0.4.4.19 FR-6: park-time summary persisted onto the item (human-seat parks only)") + .option("--evidence-ref ", "OPR.0.4.4.19 FR-6: park-time durable-artifact pointer persisted onto the item (human-seat parks only)") .option("--note ", "Transition note for the audit log") .option("--json", "JSON output for agents") .action(async (qitemId: string, opts: { @@ -338,6 +344,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command { state: string; closureReason?: string; closureTarget?: string; + blockedOn?: string; + summary?: string; + evidenceRef?: string; note?: string; json?: boolean; }) => { @@ -350,12 +359,96 @@ export function queueCommand(depsOverride?: QueueDeps): Command { state: opts.state, closureReason: opts.closureReason, closureTarget: opts.closureTarget, + blockedOn: opts.blockedOn, + summary: opts.summary, + evidenceRef: opts.evidenceRef, transitionNote: opts.note, }); printResult(opts.json ?? false, res.data, res.status); }); }); + // OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb, + // both blocker kinds: --on takes a qitem id (today's shipped blocked-on + // usage, nothing new required) or a human-seat session (the leg-1 park — + // summary + evidence_ref enforced by the daemon validator). A THIN client + // of the same update write path — enforcement lives in the daemon domain + // layer, never verb-only. The park is NON-TERMINAL: the owner keeps the + // potato and no closure_reason is involved. + cmd + .command("block ") + .description("Park a qitem as state=blocked on a blocker (qitem id, or a human seat for a needs-human park). Non-terminal: the owner keeps the item; resolution returns to it (rig queue resolve).") + .requiredOption("--on ", "The blocker: a qitem id, or a human-seat session (e.g. human-review@kernel) for the needs-human park") + .option("--actor ", "Acting session (defaults to OPENRIG_SESSION_NAME)") + .option("--summary ", "Plain-language summary of the decision owed (required for human-seat parks unless already on the item)") + .option("--evidence-ref ", "Durable artifact the human judges (required for human-seat parks unless already on the item)") + .option("--note ", "Transition note for the audit log") + .option("--json", "JSON output for agents") + .action(async (qitemId: string, opts: { + on: string; + actor?: string; + summary?: string; + evidenceRef?: string; + note?: string; + json?: boolean; + }) => { + const actor = resolveCurrentSession(opts.actor, "actor"); + if (!actor) return; + const deps = getDeps(); + await withClient(deps, async (client) => { + const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, { + actorSession: actor, + state: "blocked", + blockedOn: opts.on, + summary: opts.summary, + evidenceRef: opts.evidenceRef, + transitionNote: opts.note ?? `parked on ${opts.on}`, + }); + printResult(opts.json ?? false, res.data, res.status); + }); + }); + + // OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the + // ONE write path (POST /api/mission-control/action, verb=resolve). Exists + // so proof walks and the relay session are scriptable before the Packet-2 + // surface ships; the founder's path is the surface/feed card invoking the + // same endpoint. Resolution returns to the PARKED OWNER (blocked → + // in-progress on the SAME item) — never a closure, never a new owner. + cmd + .command("resolve ") + .description("Resolve a leg-1 parked qitem (state=blocked on a human seat): records the decision text durably in queue_transitions, unparks blocked -> in-progress, and nudges the owner. Non-closure.") + .requiredOption("--decision ", "The human's decision text (non-empty; lands in transition_note + the audit row)") + .option("--actor ", "Resolving session (defaults to OPENRIG_SESSION_NAME)") + .option("--bearer ", "Operator bearer token for the mission-control write gate (or set OPENRIG_AUTH_BEARER_TOKEN; loopback daemons without a configured bearer need none)") + .option("--no-notify", "Skip the best-effort owner nudge (the unpark still commits)") + .option("--json", "JSON output for agents") + .action(async (qitemId: string, opts: { + decision: string; + actor?: string; + bearer?: string; + notify?: boolean; + json?: boolean; + }) => { + const actor = resolveCurrentSession(opts.actor, "actor"); + if (!actor) return; + const deps = getDeps(); + const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN; + await withClient(deps, async (client) => { + const res = await client.post( + "/api/mission-control/action", + { + verb: "resolve", + qitemId, + actorSession: actor, + decision: opts.decision, + notify: opts.notify, + }, + bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined, + ); + printResult(opts.json ?? false, res.data, res.status); + }); + }); + cmd .command("handoff ") .description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to") @@ -369,6 +462,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { .option("--gate ", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate: tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.") .option("--target-repo ", "PL-007: typed repo scope for the new qitem") .option("--summary ", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.") + .option("--evidence-ref ", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.") .option("--no-nudge", "Suppress the default nudge to the new destination") .option("--json", "JSON output for agents") .action(async (qitemId: string, opts: { @@ -382,6 +476,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { gate?: string; targetRepo?: string; summary?: string; + evidenceRef?: string; nudge?: boolean; json?: boolean; }) => { @@ -410,6 +505,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { toSession: opts.to, body: opts.body, summary: opts.summary, + evidenceRef: opts.evidenceRef, transitionNote: opts.note, priority: opts.priority, tier: opts.tier, @@ -436,6 +532,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { .option("--gate ", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate: tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.") .option("--target-repo ", "PL-007: typed repo scope for the new qitem") .option("--summary ", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.") + .option("--evidence-ref ", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.") .option("--no-nudge", "Suppress the default nudge to the new destination") .option("--json", "JSON output for agents") .action(async (qitemId: string, opts: { @@ -449,6 +546,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { gate?: string; targetRepo?: string; summary?: string; + evidenceRef?: string; nudge?: boolean; json?: boolean; }) => { @@ -477,6 +575,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command { toSession: opts.to, body: opts.body, summary: opts.summary, + evidenceRef: opts.evidenceRef, transitionNote: opts.note, priority: opts.priority, tier: opts.tier, diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index ca8bb5d82..57325dae8 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -10,6 +10,9 @@ import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { Command } from "commander"; +import { DaemonClient } from "../client.js"; +import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js"; +import { realDeps } from "./daemon.js"; import { CLOSE_REASONS, @@ -49,6 +52,7 @@ import { updateFrontmatter, } from "../lib/scope/scope-fs.js"; import { + renderImplementationPrdTemplate, renderMissionBriefTemplate, renderMissionNotesTemplate, renderMissionProgressTemplate, @@ -255,7 +259,7 @@ function buildSliceShowCommand(): Command { function buildSliceCreateCommand(): Command { return new Command("create") - .description("Create a new slice in a mission") + .description("Create a new slice in a mission. Scaffolds the SDLC convention sections (## Intent / ## Mini-requirements / ## Proof contract) + proof/ + PROOF.md + IMPLEMENTATION-PRD.md for EVERY template kind — the shapes the Living Notes UI projects. Conventions SSOT: docs/reference/sdlc-conventions.md.") .argument("", "Mission name") .argument("", "Short slug (becomes the folder name's suffix)") .option("--template ", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder") @@ -311,6 +315,18 @@ function buildSliceCreateCommand(): Command { created_date: createdDate, }); const proofBody = renderSliceProofTemplate({ id, title }); + // OPR.0.4.4.23 — every template kind also scaffolds the + // IMPLEMENTATION-PRD skeleton (convention sections + elastic-middle + // note): the PRD is what the Living Notes UI's proof-contract join + // reads, so a slice without one doesn't project its DELIVERED pairing. + const prdBody = renderImplementationPrdTemplate({ + id, + slice_number: pad2(nn), + slug, + mission: mission.name, + title, + created_date: createdDate, + }); fs.mkdirSync(sliceAbs, { recursive: true }); fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); const readmePath = path.join(sliceAbs, "README.md"); @@ -327,6 +343,7 @@ function buildSliceCreateCommand(): Command { fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); } fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8"); + fs.writeFileSync(path.join(sliceAbs, "IMPLEMENTATION-PRD.md"), prdBody, "utf8"); const payload = { ok: true, slice: { @@ -883,7 +900,8 @@ function buildAuditCommand(): Command { continue; } - const sliceFm = extractFrontmatterRaw(fs.readFileSync(sliceReadme, "utf-8")); + const sliceReadmeContent = fs.readFileSync(sliceReadme, "utf-8"); + const sliceFm = extractFrontmatterRaw(sliceReadmeContent); const readmeOnlyMarker = sliceFm !== null && /^progress_rail\s*:\s*readme-only/m.test(sliceFm); // committed-without-PROGRESS inputs (CLI-only; inert when git is @@ -919,6 +937,14 @@ function buildAuditCommand(): Command { hasProofPacket: hasProofPacketForSlice(dogfoodEvidenceRoot, entry), sliceTouchedByRecentCommit, progressTouchedByRecentCommit, + // OPR.0.4.4.19 FR-10 backstop inputs. + proofArtifacts: listProofArtifactsForAudit(proofDir), + implementationPrdExists: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md")), + // OPR.0.4.4.23 convention-section advisory inputs. + readmeContent: sliceReadmeContent, + implementationPrdContent: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md")) + ? fs.readFileSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md"), "utf-8") + : null, }); if (!/^\d{2}-/.test(entry)) { @@ -998,6 +1024,23 @@ function defaultDogfoodEvidenceRoot(missionsRoot: string): string { return path.join(path.dirname(missionsRoot), "dogfood-evidence"); } +// OPR.0.4.4.19 FR-10 (C1 backstop input) — list the slice's proof/ markdown +// artifacts with raw frontmatter. Media files are exempt by construction. +// Undefined when the dir is absent/unreadable so the classifier stays inert. +function listProofArtifactsForAudit(proofDir: string): Array<{ path: string; frontmatterRaw: string | null }> | undefined { + if (!fs.existsSync(proofDir)) return undefined; + try { + return fs.readdirSync(proofDir) + .filter((f) => f.toLowerCase().endsWith(".md")) + .map((f) => { + const artifactPath = path.join(proofDir, f); + return { path: artifactPath, frontmatterRaw: extractFrontmatterRaw(fs.readFileSync(artifactPath, "utf-8")) }; + }); + } catch { + return undefined; + } +} + function hasProofPacketForSlice(dogfoodEvidenceRoot: string, sliceName: string): boolean { let entries: fs.Dirent[]; try { @@ -1537,6 +1580,107 @@ function buildMissionRepairCommand(): Command { }); } +// --------------------------------------------------------------------- +// Approve (OPR.0.4.4.19 FR-9) +// --------------------------------------------------------------------- + +// `rig scope slice|mission approve` — a THIN client of the daemon's ONE +// write path (POST /api/scope/approve): frontmatter stamp + append-only +// audit row land together daemon-side (no half-stamp by construction). +// STAGED: --scope spec ("the PRD matches my intent") | delivery (the +// terminal sign-off + future freeze trigger); omitted = delivery. +// DELEGATED: --on-behalf-of records whose decision this is in the audit +// notes; the actor stays the REAL invoking session (honest provenance). +// Two-regime rule (BR-6): approval is freeze/sign-off — NEVER proven-green. +function buildApproveCommand(tier: "slice" | "mission"): Command { + return new Command("approve") + .description( + tier === "slice" + ? "Approve a slice: writes the frontmatter stamp + an append-only audit row (daemon-side, one operation). --scope spec = the PLAN-LOCK (PRD-matches-intent; this artifact set gets built); delivery (default) = the PROOF-LOCK (terminal sign-off). Approval is freeze/sign-off, never proven-green. Conventions SSOT: docs/reference/sdlc-conventions.md." + : "Approve a mission: same staged/delegated semantics as slice approve, at mission tier." + ) + .argument(tier === "slice" ? "" : "", tier === "slice" ? "Slice path (absolute, relative, or NN-slug)" : "Mission name") + .option("--mission ", tier === "slice" ? "Hint mission when slice-path is just NN-slug" : "(unused at mission tier)") + .option("--scope ", "Approval scope: spec | delivery (default delivery)") + .option("--actor ", "Approving session (defaults to OPENRIG_SESSION_NAME)") + .option("--on-behalf-of ", "Record the delegation: whose decision this stamp records (actor stays the real invoking session)") + .option("--json", "Machine-readable output") + .action(async (target: string, opts: { + mission?: string; + scope?: string; + actor?: string; + onBehalfOf?: string; + json?: boolean; + }, command: Command) => { + const out = makeStdout(); + const json = Boolean(opts.json); + try { + if (opts.scope !== undefined && opts.scope !== "spec" && opts.scope !== "delivery") { + throw new ScopeCliError({ + fact: `Unknown --scope value "${opts.scope}".`, + consequence: "Command did not run.", + action: "Pick one of: spec, delivery (omit for delivery).", + }); + } + const actor = opts.actor ?? process.env.OPENRIG_SESSION_NAME; + if (!actor) { + throw new ScopeCliError({ + fact: "No acting session: --actor not passed and OPENRIG_SESSION_NAME is unset.", + consequence: "The approval stamp requires honest actor provenance.", + action: "Pass --actor or run from a managed seat.", + }); + } + // Resolve the scope target LOCALLY (rich NN-slug resolution), then + // send the canonical missions-root-relative path to the daemon. + const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace }); + let scopeAbsPath: string; + if (tier === "slice") { + const slice = findSlice(missionsRoot, target, opts.mission ?? null); + scopeAbsPath = slice.absPath; + } else { + const mission = findMission(missionsRoot, target); + scopeAbsPath = mission.absPath; + } + const scopePath = path.relative(missionsRoot, scopeAbsPath).split(path.sep).join("/"); + + const lifecycleDeps = realDeps(); + const status = await getDaemonStatus(lifecycleDeps); + if (status.state !== "running" || status.healthy === false) { + throw new ScopeCliError({ + fact: "Daemon not running.", + consequence: "scope approve writes the stamp + audit row through the daemon (one operation).", + action: "Start it with: rig daemon start", + }); + } + const client = new DaemonClient(getDaemonUrl(status)); + const res = await client.post>("/api/scope/approve", { + scopeTier: tier, + scopePath, + approvalScope: opts.scope, + actorSession: actor, + onBehalfOf: opts.onBehalfOf ?? null, + }); + if (res.status >= 400) { + const err = res.data as { error?: string; message?: string }; + throw new ScopeCliError({ + fact: `Approve failed (${err.error ?? res.status}): ${err.message ?? "unknown error"}.`, + consequence: "No stamp and no audit row were left behind (no half-stamp).", + action: err.error === "already_approved" + ? "The scope already carries this stamp; un-approve/re-approve is out of scope v1." + : "Fix the named issue and re-run.", + }); + } + const data = res.data; + emit(out, { ok: true, ...data }, json, [ + `Approved (${String(data.approvalScope)}) ${tier} ${String(data.scopeId)} — ${String(data.approvedBy)} at ${String(data.approvedAt)}${data.onBehalfOf ? ` on behalf of ${String(data.onBehalfOf)}` : ""}`, + `Audit action: ${String(data.actionId)} (scope_path=${String(data.scopePath)})`, + ]); + } catch (err) { + fail(err, json, out); + } + }); +} + // --------------------------------------------------------------------- // Aggregate // --------------------------------------------------------------------- @@ -1557,6 +1701,7 @@ export function scopeCommand(): Command { slice.addCommand(buildSliceRepairCommand()); slice.addCommand(buildSliceStageCommand()); slice.addCommand(buildSliceVerifiedCommand()); + slice.addCommand(buildApproveCommand("slice")); cmd.addCommand(slice); const mission = new Command("mission").description("Mission-tier commands"); @@ -1567,6 +1712,7 @@ export function scopeCommand(): Command { mission.addCommand(buildMissionRepairCommand()); mission.addCommand(buildMissionStageCommand()); mission.addCommand(buildMissionVerifiedCommand()); + mission.addCommand(buildApproveCommand("mission")); cmd.addCommand(mission); cmd.addCommand(buildAuditCommand()); diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index 3d1008ca0..84a30cba0 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -221,7 +221,7 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's const res = await client.post>("/api/transport/send", { session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs, dangerouslyInteract: opts.dangerouslyInteract, reason: opts.reason, actorSession: senderSession ?? null, - }, { ...waitForIdleRequestOptions(waitForIdleMs), headers: terminalAuthHeaders() }); + }, transportRequestOptions(waitForIdleMs)); if (opts.json) { console.log(JSON.stringify(res.data)); @@ -359,9 +359,7 @@ async function runFanOutSend(params: { body.envelopeSender = senderSession && senderSession.trim().length > 0 ? senderSession : SENDER_FALLBACK; } - const res = await client.post>("/api/transport/broadcast", body, { - headers: terminalAuthHeaders(), - }); + const res = await client.post>("/api/transport/broadcast", body, transportRequestOptions()); if (opts.json) { console.log(JSON.stringify(res.data)); @@ -403,3 +401,14 @@ function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeout if (waitForIdleMs === undefined) return undefined; return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS }; } + +function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record } | undefined { + const waitOptions = waitForIdleRequestOptions(waitForIdleMs); + const headers = terminalAuthHeaders(); + const hasHeaders = Object.keys(headers).length > 0; + if (!waitOptions && !hasHeaders) return undefined; + return { + ...(waitOptions ?? {}), + ...(hasHeaders ? { headers } : {}), + }; +} diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index aedd30462..e92513028 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -1,4 +1,6 @@ import nodePath from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseYamlDoc } from "yaml"; import { Command } from "commander"; import { DaemonClient, DaemonConnectionError } from "../client.js"; import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "../daemon-lifecycle.js"; @@ -28,6 +30,29 @@ function isPathInsideRoot(candidate: string, root: string): boolean { return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative)); } +// OPR.0.4.4.11 (arch return R11-2) — pre-dispatch detection of a topology +// source for the --host rejection. Mirrors the daemon's FR-1 detection +// contract without importing the daemon package: `.rigtopology` extension, +// or a readable path-form YAML document with a top-level `rigs:` LIST. +export function sourceLooksLikeTopology(source: string): boolean { + if (/\.rigtopology$/i.test(source)) return true; + // Name-form sources (no slash, no known extension) keep rig-name + // semantics — never sniffed (same precedence as the daemon router). + if (!source.includes("/") && !source.match(/\.(ya?ml)$/i)) return false; + try { + const p = nodePath.resolve(source); + if (!existsSync(p)) return false; + const parsed: unknown = parseYamlDoc(readFileSync(p, "utf-8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false; + return Array.isArray((parsed as Record)["rigs"]); + } catch { + return false; // unreadable/unparseable — existing flows own the error + } +} + +export const HOST_TOPOLOGY_REJECTION = + "rig up --host cannot take a topology source: per-entry 'host:' in the manifest is the ONLY placement mechanism for topologies (two placement mechanisms must not coexist). Put 'host: ' on the entries you want placed remotely and run 'rig up ' without --host."; + export function upCommand( depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps; @@ -61,6 +86,19 @@ Examples: const deps = getDepsF(); if (opts.host) { + // OPR.0.4.4.11 R11-2: --host + topology source is REJECTED before + // any dispatch — per-entry 'host:' is the only topology placement + // mechanism. The daemon route carries the same rejection on its + // public write path (double-sided enforcement, arch ruling 4). + if (sourceLooksLikeTopology(source)) { + if (opts.json) { + console.log(JSON.stringify({ ok: false, error: HOST_TOPOLOGY_REJECTION, code: "host_flag_topology" })); + } else { + console.error(HOST_TOPOLOGY_REJECTION); + } + process.exitCode = 1; + return; + } const { runRemoteHttpOp } = await import("../remote-host-ops.js"); const body = { sourceRef: source, @@ -163,8 +201,13 @@ Examples: const client = deps.clientFactory(getDaemonUrl(status)); - // Detect rig name vs file path: names don't contain / and don't end in .yaml/.yml/.rigbundle - const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle)$/i); + // Detect rig name vs file path: names don't contain / and don't end in + // .yaml/.yml/.rigbundle/.rigtopology. OPR.0.4.4.11 (guard G-1): the + // topology extension resolves as a PATH (same as yaml/rigbundle) so a + // bare `factory.rigtopology` reaches the daemon's topology routing + // instead of library/existing-rig name resolution. Extensionless + // no-slash sources keep rig_name precedence byte-for-byte. + const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle|rigtopology)$/i); let sourceRef = isRigName ? source : nodePath.resolve(source); // If it looks like a name, check for library spec match @@ -368,6 +411,25 @@ Examples: return; } + // OPR.0.4.4.11 — topology aggregate rendering (full success OR honest + // partial; the daemon returns the same closed {rigRef, host, status, + // error?} entries either way, skipped entries explicitly present). + const topoEntries = res.data["entries"] as Array<{ rigRef: string; host: string; status: string; error?: string }> | undefined; + if (typeof res.data["topology"] === "string" && Array.isArray(topoEntries)) { + const topoOk = res.data["ok"] === true; + console.log( + topoOk + ? `Topology up: all ${topoEntries.length} rigs launched.` + : "Topology up FAILED — honest partial state (started rigs stay up; no rollback):", + ); + for (const e of topoEntries) { + const tag = e.status === "ok" ? " ok " : e.status === "failed" ? "FAIL" : "skip"; + console.log(` [${tag}] ${e.rigRef} @ ${e.host}${e.error ? ` — ${e.error}` : ""}`); + } + if (!topoOk) process.exitCode = 1; + return; + } + if (res.status >= 400) { const code = res.data["code"] as string | undefined; if (code === "cycle_error") { @@ -380,6 +442,9 @@ Examples: console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`); } else if (code === "pre_restore_validation_failed") { printRestoreNotAttempted(res.data as RestoreNotAttemptedData); + } else if (code === "invalid_topology_manifest") { + const errors = (res.data["errors"] as string[]) ?? []; + console.error(`Topology manifest invalid:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: the manifest key set is CLOSED — rigs[]{source, host?} plus optional concurrency.`); } else { const errorText = String(res.data["error"] ?? "unknown error"); console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate `); diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts index 3af2975ce..925db5a2a 100644 --- a/packages/cli/src/commands/workspace.ts +++ b/packages/cli/src/commands/workspace.ts @@ -4,9 +4,9 @@ // - `rig workspace validate` (slice-01) — walks a workspace root, // parses each .md file's YAML frontmatter, emits a structured gap // report. Advisory only — never modifies. curate-steward consumes. -// - `rig workspace doctor` (slice-21 FR-5) — runs the 7-check -// workspace-readiness diagnostic against the daemon's resolved -// workspace (or a --workspace override). Reports state + fix-hints. +// - `rig workspace doctor` (slice-21 FR-5; +1 check OPR.0.4.4.23) — runs +// the 8-check workspace-readiness diagnostic against the daemon's +// resolved workspace (or a --workspace override). Reports state + fix-hints. import { Command } from "commander"; import { DaemonClient } from "../client.js"; @@ -76,7 +76,7 @@ function emit3PartError(json: boolean, fact: string, consequence: string, action export function workspaceCommand(depsOverride?: WorkspaceDeps): Command { const cmd = new Command("workspace").description( - "PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.", + "PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 8-check workspace-readiness diagnostic.", ); const getDeps = (): WorkspaceDeps => @@ -148,7 +148,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command { cmd .command("doctor") .description( - "Run the 7-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, and MISSION_NOTES. Read-only.", + "Run the 8-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, MISSION_NOTES, and SDLC convention sections. Read-only.", ) .option( "--workspace ", @@ -243,7 +243,7 @@ interface DoctorReport { const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray }> = [ { category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] }, - { category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence"] }, + { category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence", "sdlc_convention_sections"] }, { category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] }, ]; diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts index 2019dc1b3..c56ad8d36 100644 --- a/packages/cli/src/config-store.ts +++ b/packages/cli/src/config-store.ts @@ -375,6 +375,41 @@ function isValidKey(key: string): key is ValidKey { return (VALID_KEYS as readonly string[]).includes(key); } +// ── OPR.0.4.4.15 (G15-P1) — TWIN of the daemon settings-store's ONE +// registered dynamic key class: `feed.subscriptions..enabled` +// (boolean; v1 per-host key set CLOSED to {enabled}; no env mapping). +// Kept in lockstep with packages/daemon/src/domain/user-settings/ +// settings-store.ts — the parity test pins both twins (host-registry twin +// discipline). Reserved segments cover key-level snake_case AND FILE-level +// camelCase toggle names so no host id shadows a flat toggle at either +// layer. Every other unknown key keeps the reject-loud behavior. +const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/; +export const FEED_HOST_RESERVED_SEGMENTS = new Set([ + "action_required", + "actionRequired", + "approvals", + "shipped", + "progress", + "audit_log", + "auditLog", + "enabled", +]); + +export function parseFeedHostSubscriptionKey(key: string): { hostId: string } | null { + const m = key.match(FEED_HOST_KEY_RE); + if (!m) return null; + const hostId = m[1]!; + if (FEED_HOST_RESERVED_SEGMENTS.has(hostId)) return null; + return { hostId }; +} + +function coerceFeedHostSubscriptionValue(key: string, raw: string): boolean { + const v = raw.trim().toLowerCase(); + if (v === "true") return true; + if (v === "false") return false; + throw new Error(`Invalid value for ${key}: expected "true" or "false", got "${raw}"`); +} + function getNestedValue(obj: Record, parts: string[]): unknown { let current: unknown = obj; for (const part of parts) { @@ -657,6 +692,14 @@ export class ConfigStore { * default). */ resolveWithSource(key: string): ResolvedSetting { + // OPR.0.4.4.15: dynamic class resolves file-or-default (no env in v1). + const feedHost = parseFeedHostSubscriptionKey(key); + if (feedHost) { + const fcDyn = this.readConfigFile(); + const fileVal = getNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"]); + if (typeof fileVal === "boolean") return { value: fileVal, source: "file", defaultValue: false }; + return { value: false, source: "default", defaultValue: false }; + } if (!isValidKey(key)) { throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); } @@ -722,6 +765,17 @@ export class ConfigStore { } set(key: string, value: string): void { + // OPR.0.4.4.15: the registered dynamic class is accepted; every other + // unknown key keeps the reject-loud behavior below byte-for-byte. + const feedHost = parseFeedHostSubscriptionKey(key); + if (feedHost) { + const coercedDyn = coerceFeedHostSubscriptionValue(key, value); + const fcDyn = this.readConfigFile(); + setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn); + mkdirSync(dirname(this.configPath), { recursive: true }); + writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8"); + return; + } if (!isValidKey(key)) { throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); } @@ -745,6 +799,16 @@ export class ConfigStore { try { unlinkSync(this.configPath); } catch { /* missing is fine */ } return; } + // OPR.0.4.4.15: dynamic-class reset removes the whole host node. + const feedHost = parseFeedHostSubscriptionKey(key); + if (feedHost) { + if (!existsSync(this.configPath)) return; + const fcDyn = this.readConfigFile(); + const subsParent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record | undefined; + if (subsParent && feedHost.hostId in subsParent) delete subsParent[feedHost.hostId]; + writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8"); + return; + } if (!isValidKey(key)) { throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); } diff --git a/packages/cli/src/cross-host-executor.ts b/packages/cli/src/cross-host-executor.ts index 9aae10e57..df911f4ce 100644 --- a/packages/cli/src/cross-host-executor.ts +++ b/packages/cli/src/cross-host-executor.ts @@ -167,7 +167,10 @@ const PERMISSION_GATE_PATTERNS = [ /no mutual signature algorithm/i, ]; -function looksLikePermissionGate(stderr: string): boolean { +// OPR.0.4.4.18: exported so `rig file` classifies rsync stderr with the SAME +// signature (single-source classification per arch ruling — never a second +// pattern copy). Behavior unchanged. +export function looksLikePermissionGate(stderr: string): boolean { return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr)); } diff --git a/packages/cli/src/daemon-lifecycle.ts b/packages/cli/src/daemon-lifecycle.ts index 1808c8492..eeb348fa5 100644 --- a/packages/cli/src/daemon-lifecycle.ts +++ b/packages/cli/src/daemon-lifecycle.ts @@ -143,7 +143,10 @@ export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log"); const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 7433; const DEFAULT_DB = "openrig.sqlite"; -const HEALTHZ_RETRIES = 20; +// Fresh Linux hosts can take longer than a warmed dev machine to load native +// modules, run startup reconciliation, and bind healthz. Keep the retry loop +// bounded, but do not kill a healthy daemon during first-boot slack. +const HEALTHZ_RETRIES = 80; const HEALTHZ_DELAY_MS = 250; const HEALTHZ_PROBE_TIMEOUT_MS = 250; // OPR.0.4.2.1 — status-probe bounded settle: a single /healthz fetch loses to the post-restart diff --git a/packages/cli/src/host-registry.ts b/packages/cli/src/host-registry.ts index f8c864cc3..2789f6172 100644 --- a/packages/cli/src/host-registry.ts +++ b/packages/cli/src/host-registry.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { parse as parseYaml } from "yaml"; +import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { getDefaultOpenRigPath } from "./openrig-compat.js"; import type { FailedStep } from "./cross-host-types.js"; @@ -226,3 +227,49 @@ export function resolveHost(registry: HostRegistry, id: string): HostResolution error: `unknown host id '${id}'.${idsHint}`, }; } + +// --------------------------------------------------------------------------- +// OPR.0.4.4.13 FR-1 — the registry WRITE path (rig host add). +// +// ONE validation source: the candidate registry (existing entries + the new +// raw entry) is validated by the SAME validateHostRegistry the loader uses — +// add-time errors are load-time errors, verbatim (incl. duplicate ids, +// transport-appropriate fields, exactly-one-bearer). The standard path never +// hand-edits YAML; note: add REWRITES the file canonically (hand-authored +// comments are not preserved — hand-editing remains the path for exotica). +// --------------------------------------------------------------------------- + +export type AddHostResult = + | { ok: true; path: string; entry: HostEntry } + | { ok: false; error: string }; + +export function addHostEntry(rawEntry: Record, path: string = defaultHostRegistryPath()): AddHostResult { + // Load what exists; a MISSING file is a valid starting point for `add` + // (the verb exists so operators never hand-create the YAML), but a present- + // but-invalid file is a loud error — never silently clobber operator state. + let existing: HostEntry[] = []; + if (existsSync(path)) { + const loaded = loadHostRegistry(path); + if (!loaded.ok) { + return { ok: false, error: `refusing to modify an invalid registry: ${loaded.error}` }; + } + existing = loaded.registry.hosts; + } + + const candidate = { hosts: [...existing, rawEntry] }; + const validated = validateHostRegistry(candidate, path); + if (!validated.ok) { + return { ok: false, error: validated.error }; + } + const entry = validated.registry.hosts[validated.registry.hosts.length - 1]!; + + try { + mkdirSync(dirname(path), { recursive: true }); + const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`); + writeFileSync(tmp, stringifyYaml({ hosts: validated.registry.hosts }), { mode: 0o600 }); + renameSync(tmp, path); + } catch (err) { + return { ok: false, error: `failed to write host registry at ${path}: ${(err as Error).message}` }; + } + return { ok: true, path, entry }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 186c14a68..5c3327f6e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,11 +18,13 @@ import { bindCommand } from "./commands/bind.js"; import { adoptCommand, type AdoptDeps } from "./commands/adopt.js"; import { bundleCommand } from "./commands/bundle.js"; import { scopeCommand } from "./commands/scope.js"; +import { proofCommand } from "./commands/proof.js"; import { upCommand } from "./commands/up.js"; import { downCommand } from "./commands/down.js"; import { archiveCommand } from "./commands/archive.js"; import { unarchiveCommand } from "./commands/unarchive.js"; import { psCommand } from "./commands/ps.js"; +import { hostCommand } from "./commands/host.js"; import { mcpCommand } from "./commands/mcp.js"; import { agentCommand, type AgentDeps } from "./commands/agent.js"; import { rigCommand, type RigDeps } from "./commands/rig.js"; @@ -38,6 +40,7 @@ import { startCommand, type StartDeps } from "./commands/start.js"; import { captureCommand } from "./commands/capture.js"; import { broadcastCommand } from "./commands/broadcast.js"; import { configCommand } from "./commands/config.js"; +import { fileCommand } from "./commands/file.js"; import { preflightCommand } from "./commands/preflight.js"; import { authCommand } from "./commands/auth.js"; import { doctorCommand } from "./commands/doctor.js"; @@ -168,6 +171,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command { // OPR.0.3.3.19 - rig archive affordance (soft, reversible; NOT a delete). program.addCommand(archiveCommand(depsOverride?.archiveDeps)); program.addCommand(unarchiveCommand(depsOverride?.unarchiveDeps)); + program.addCommand(hostCommand()); program.addCommand(psCommand(depsOverride?.psDeps)); program.addCommand(mcpCommand(depsOverride?.mcpDeps)); program.addCommand(agentCommand(depsOverride?.agentDeps)); @@ -194,6 +198,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command { program.addCommand(rigPolicyCommand(depsOverride?.rigPolicyDeps)); program.addCommand(whoamiCommand(depsOverride?.whoamiDeps)); program.addCommand(configCommand(depsOverride?.configPath)); + program.addCommand(fileCommand()); program.addCommand(preflightCommand()); program.addCommand(authCommand()); program.addCommand(doctorCommand()); @@ -219,6 +224,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command { program.addCommand(startupProofCommand(depsOverride?.startupProofDeps)); // release-0.3.2 slice 12 — rig scope CLI primitive. program.addCommand(scopeCommand()); + // OPR.0.4.4.19 FR-8 — rig proof: the C1 proof-drop write path. + program.addCommand(proofCommand()); return program; } diff --git a/packages/cli/src/lib/file-transfer.ts b/packages/cli/src/lib/file-transfer.ts new file mode 100644 index 000000000..afe98ce60 --- /dev/null +++ b/packages/cli/src/lib/file-transfer.ts @@ -0,0 +1,358 @@ +// OPR.0.4.4.18 — `rig file copy` core: path grammar, the FR-4 security wall, +// the closed rsync argv builder, and result classification. +// +// SECURITY CELL (plan §3 + §7a/§7a-2, guard-cleared over three rounds): +// five independent layers, every rejection BEFORE any spawn — +// 1. GRAMMAR (fail-closed): a path is remote iff it carries an explicit +// `:` qualifier; no inference from cwd/env/session (FR-2). +// Local-prefix escapes (`/ ./ ../ ~`); an id-shaped colon prefix MUST +// resolve or fail loudly (N18-1); leading-dash operands are refused +// outright with the ./ escape taught (G18-P1 belt). +// 2. NORMALIZATION: local paths are ~-expanded + path.resolve'd and the +// RESOLVED form is what is checked and transported; remote paths +// reject ANY raw '..' SEGMENT BEFORE normalization (G18-C1: normalize +// COLLAPSES '..' in absolute paths, so a post-normalize check is dead +// code), then '.'/'//' are collapsed for the shipped form. +// 3. THE REMOTE CHARSET WALL (G18-P3 — supersedes a --protect-args pin: +// this platform's rsync is OPENRSYNC, which rejects -s; never assume +// GNU parity on macOS): remote paths must be ABSOLUTE (arch Q5) and +// match ^[A-Za-z0-9._/-]+$ — every allowed character is POSIX-shell- +// inert, so the ssh-invoked remote shell has NOTHING to interpret: +// no word-split (space excluded), no glob (*?[] excluded), no +// expansion (~ $ excluded), no metacharacters. Rejections TEACH +// (offending char named + workaround; arch note, N18-2-revisitable). +// 4. THE `--` OPERAND PIN (G18-P1 suspenders): the builder ALWAYS places +// `--` before the two path operands — a flag-shaped path can never +// parse as an option even if a future caller bypasses the refusal. +// 5. NO SHELL locally: spawn("rsync", argv) with argv arrays; the -e ssh +// string is built ONLY from registry fields (user shape-checked). +// Deletion-class flags are UNREACHABLE (property-tested, incl. the +// `-s`/--protect-args ABSENCE uniformity pin). +// +// DENY WALL (FR-4, arch-endorsed SHORT CLOSED NAMED list — extension = a +// ruling, never a growing blocklist): local side denies resolved paths under +// ~/.openrig (live rig state — crash-safety class), ~/.ssh, ~/.codex, +// ~/.claude (credential/shared-singleton classes); remote side denies any +// path CONTAINING one of those dot-directory segments (conservative-over- +// broad: /srv/backup/.ssh/x is still a credentials dir; false positives are +// loud + revisitable, false negatives are the corruption class). + +import os from "node:os"; +import path from "node:path"; +import { spawn as nodeSpawn } from "node:child_process"; +import { loadHostRegistry, resolveHost, type SshHostEntry } from "./../host-registry.js"; +import { looksLikePermissionGate } from "./../cross-host-executor.js"; +import { getDefaultOpenRigPath, getOpenRigHome } from "./../openrig-compat.js"; + +// ── grammar ──────────────────────────────────────────────────────────────── + +export type ParsedFileArg = + | { kind: "local"; path: string } + | { kind: "remote"; hostId: string; path: string }; + +export type FileArgParse = { ok: true; arg: ParsedFileArg } | { ok: false; error: string }; + +const HOST_ID_SHAPE = /^[A-Za-z0-9_-]+$/; +const SSH_USER_SHAPE = /^[A-Za-z0-9._-]+$/; +export const REMOTE_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/; + +/** The FR-4 short closed named deny list (dot-directory segments). */ +export const DENIED_SEGMENTS = [".openrig", ".ssh", ".codex", ".claude"] as const; + +export function parseFilePathArg(raw: string): FileArgParse { + if (raw === "") return { ok: false, error: "empty path operand" }; + if (raw.startsWith("-")) { + // G18-P1 belt: a flag-shaped operand is refused outright; the ./ escape + // is the honest way to address such a file. + return { + ok: false, + error: `path operand '${raw}' begins with '-' and could be mistaken for an option. If this is a real local file, address it with the ./ prefix (./${raw}).`, + }; + } + // Local-prefix escapes: these are ALWAYS local, colon or not (N18-1). + if (raw.startsWith("/") || raw.startsWith("./") || raw.startsWith("../") || raw.startsWith("~")) { + return { ok: true, arg: { kind: "local", path: raw } }; + } + const colon = raw.indexOf(":"); + if (colon >= 0) { + const prefix = raw.slice(0, colon); + const rest = raw.slice(colon + 1); + if (HOST_ID_SHAPE.test(prefix)) { + // Id-shaped prefix: this IS a host qualifier — it must resolve or the + // command fails loudly (fail-closed; never a silent local fallback). + if (rest === "") return { ok: false, error: `remote path missing after '${prefix}:' — expected ${prefix}:` }; + return { ok: true, arg: { kind: "remote", hostId: prefix, path: rest } }; + } + // Non-id-shaped prefix (contains '/', empty, etc.): scp's lived + // behavior — the whole operand is a local path. + return { ok: true, arg: { kind: "local", path: raw } }; + } + return { ok: true, arg: { kind: "local", path: raw } }; +} + +// ── the deny wall + normalization ────────────────────────────────────────── + +function expandLocalTilde(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +export type PathCheck = { ok: true; normalizedPath: string } | { ok: false; error: string }; + +/** Local side: ~-expand, resolve (traversal collapses here), then deny-check + * the RESOLVED form. The resolved form is what rsync receives. */ +export function checkLocalPath(raw: string): PathCheck { + const resolved = path.resolve(expandLocalTilde(raw)); + const home = os.homedir(); + const activeOpenRigHome = path.resolve(getOpenRigHome()); + const activeHostsRegistry = path.resolve(getDefaultOpenRigPath("hosts.yaml")); + if (resolved === activeHostsRegistry) { + return { + ok: false, + error: `refused: '${raw}' resolves to the active hosts registry (${activeHostsRegistry}) — the registry itself is not a copy source/target in v0. This is the FR-4 default-deny wall (live OpenRig state; extension requires a ruling).`, + }; + } + for (const segment of DENIED_SEGMENTS) { + const deniedRoot = path.join(home, segment); + if (resolved === deniedRoot || resolved.startsWith(deniedRoot + path.sep)) { + return { + ok: false, + error: `refused: '${raw}' resolves into ${deniedRoot} — ${segment === ".openrig" ? "live OpenRig state (incl. the hosts registry) is not a copy source/target in v0 (crash-safety)" : "credential/agent-home directories are not copy sources/targets"}. This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`, + }; + } + } + if ( + activeOpenRigHome !== path.join(home, ".openrig") && + (resolved === activeOpenRigHome || resolved.startsWith(activeOpenRigHome + path.sep)) + ) { + return { + ok: false, + error: `refused: '${raw}' resolves into the active OPENRIG_HOME (${activeOpenRigHome}) — live OpenRig state is not a copy source/target in v0 (crash-safety). This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`, + }; + } + return { ok: true, normalizedPath: resolved }; +} + +/** Remote side: posix-normalize, then the wall — absolute-only (arch Q5), + * no surviving traversal, the shell-inert charset (G18-P3), and the + * denied-segment list. Errors TEACH (offending char + workaround). */ +export function checkRemotePath(raw: string, hostId: string): PathCheck { + const charsetViolation = [...raw].find((ch) => !REMOTE_PATH_CHARSET.test(ch)); + if (charsetViolation !== undefined) { + const shown = charsetViolation === " " ? "a space" : `'${charsetViolation}'`; + return { + ok: false, + error: `refused: remote path for '${hostId}' contains ${shown}, which is outside the v0 remote-path character set [A-Za-z0-9._/-]. v0 keeps remote paths shell-inert by construction${charsetViolation === " " ? " — use a space-free staging path and rename on the far side" : ""}. (v0-conservative; widening is a ruling.)`, + }; + } + if (!raw.startsWith("/")) { + return { ok: false, error: `refused: remote paths are ABSOLUTE-only in v0 (got '${raw}' for host '${hostId}') — remote ~/relative resolution would smuggle in remote-side semantics. Use the full path.` }; + } + // G18-C1 (guard code-review): the RAW path is checked for '..' BEFORE + // normalization — posix.normalize fully COLLAPSES '..' in absolute paths + // (/srv/../../etc/passwd → /etc/passwd), so a post-normalize "surviving + // .." check is dead code and the climb escapes silently. In an + // absolute-only grammar a '..' segment is never necessary; ANY '..' + // rejects outright (no benign/escaping distinction — remote symlinks + // make that unreasonable to judge from here). + if (raw.split("/").includes("..")) { + return { ok: false, error: `refused: remote path '${raw}' contains a '..' traversal segment (FR-4). Remote paths are absolute-only — write the final path without '..'.` }; + } + const normalized = path.posix.normalize(raw); + for (const part of normalized.split("/")) { + if ((DENIED_SEGMENTS as readonly string[]).includes(part)) { + return { + ok: false, + error: `refused: remote path '${raw}' contains the denied directory segment '${part}' (credential/rig-state class; conservative-over-broad by design — FR-4's short closed list).`, + }; + } + } + return { ok: true, normalizedPath: normalized }; +} + +// ── planning ──────────────────────────────────────────────────────────────── + +export interface CopySide { + kind: "local" | "remote"; + /** Normalized path (resolved local / posix-normalized remote). */ + path: string; + /** Present on remote sides. */ + host?: SshHostEntry; +} + +export interface CopyPlan { + src: CopySide; + dst: CopySide; + dryRun: boolean; +} + +export type PlanResult = { ok: true; plan: CopyPlan } | { ok: false; error: string; code: string }; + +export interface PlanDeps { + registryLoader?: typeof loadHostRegistry; +} + +function resolveRemoteSide(hostId: string, rawPath: string, deps: PlanDeps): { ok: true; side: CopySide } | { ok: false; error: string; code: string } { + const loader = deps.registryLoader ?? loadHostRegistry; + const reg = loader(); + if (!reg.ok) return { ok: false, error: reg.error, code: "registry_error" }; + const resolved = resolveHost(reg.registry, hostId); + if (!resolved.ok) return { ok: false, error: resolved.error, code: "unknown_host" }; + if (resolved.host.transport !== "ssh") { + return { + ok: false, + error: `host '${hostId}' uses transport '${resolved.host.transport}' — v0 file movement is ssh/rsync only. Register an ssh entry for this host or move the file another way.`, + code: "unsupported_transport", + }; + } + if (resolved.host.user !== undefined && !SSH_USER_SHAPE.test(resolved.host.user)) { + return { ok: false, error: `registry entry '${hostId}' has a user field outside [A-Za-z0-9._-] — refusing to place it on an ssh command line`, code: "invalid_registry_user" }; + } + const pathCheck = checkRemotePath(rawPath, hostId); + if (!pathCheck.ok) return { ok: false, error: pathCheck.error, code: "denied_path" }; + return { ok: true, side: { kind: "remote", path: pathCheck.normalizedPath, host: resolved.host } }; +} + +/** Validate the whole invocation — EVERY rejection here happens before any + * spawn (the fail-closed set; spawn-spy-asserted in tests). */ +export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: boolean } & PlanDeps = {}): PlanResult { + const srcParse = parseFilePathArg(rawSrc); + if (!srcParse.ok) return { ok: false, error: srcParse.error, code: "bad_operand" }; + const dstParse = parseFilePathArg(rawDst); + if (!dstParse.ok) return { ok: false, error: dstParse.error, code: "bad_operand" }; + + if (srcParse.arg.kind === "remote" && dstParse.arg.kind === "remote") { + return { ok: false, error: "remote-to-remote is not in v0; pull then push (two explicit transfers — never a silent relay through this host)", code: "remote_to_remote" }; + } + + const sides: CopySide[] = []; + for (const parsed of [srcParse.arg, dstParse.arg]) { + if (parsed.kind === "remote") { + const side = resolveRemoteSide(parsed.hostId, parsed.path, opts); + if (!side.ok) return { ok: false, error: side.error, code: side.code }; + sides.push(side.side); + } else { + const check = checkLocalPath(parsed.path); + if (!check.ok) return { ok: false, error: check.error, code: "denied_path" }; + sides.push({ kind: "local", path: check.normalizedPath }); + } + } + return { ok: true, plan: { src: sides[0]!, dst: sides[1]!, dryRun: opts.dryRun === true } }; +} + +// ── the CLOSED argv builder ───────────────────────────────────────────────── + +function sideOperand(side: CopySide): string { + if (side.kind === "local") return side.path; + return `${side.host!.target}:${side.path}`; +} + +/** The ONLY place rsync arguments are assembled. Properties (test-asserted): + * no deletion-class flag ever present; NO -s/--protect-args (uniform argv + * across GNU rsync and openrsync — the G18-P3 absence pin); `--` ALWAYS + * precedes the two path operands; option positions are fixed. */ +export function buildRsyncArgv(plan: CopyPlan): string[] { + const argv = ["--archive", "--itemize-changes", "--stats"]; + if (plan.dryRun) argv.push("--dry-run"); + const remote = plan.src.kind === "remote" ? plan.src : plan.dst.kind === "remote" ? plan.dst : null; + if (remote) { + const sshParts = ["ssh", "-o", "ConnectTimeout=10"]; + if (remote.host!.user) sshParts.push("-l", remote.host!.user); + argv.push("-e", sshParts.join(" ")); + } + argv.push("--", sideOperand(plan.src), sideOperand(plan.dst)); + return argv; +} + +// ── execution + classification ────────────────────────────────────────────── + +export type FileCopyFailedStep = "none" | "rsync-missing" | "permission-gate" | "ssh-unreachable" | "remote-command-failed"; + +export interface FileCopyResult { + ok: boolean; + failedStep: FileCopyFailedStep; + exitCode: number | null; + stdout: string; + stderr: string; + /** Parsed from rsync --stats when available. */ + bytesTransferred?: number; + filesTransferred?: number; + hint?: string; +} + +type SpawnFn = typeof nodeSpawn; + +const CONNECTION_FAILURE_PATTERNS = [ + /connection refused/i, + /connection timed out/i, + /connection unexpectedly closed/i, + /could not resolve hostname/i, + /no route to host/i, + /operation timed out/i, +]; + +function parseStats(stdout: string): { bytesTransferred?: number; filesTransferred?: number } { + const bytes = stdout.match(/Total transferred file size:\s*([\d,.]+)\s*bytes/i); + const files = stdout.match(/Number of (?:regular )?files transferred:\s*([\d,.]+)/i); + const num = (m: RegExpMatchArray | null) => (m ? Number(m[1]!.replace(/[,.](?=\d{3})/g, "").replace(/,/g, "")) : undefined); + return { bytesTransferred: num(bytes), filesTransferred: num(files) }; +} + +/** rsync's classes are the executor taxonomy MINUS remote-daemon-unreachable + * (rsync needs no daemon — arch ruling 2). */ +export function classifyRsyncResult(exitCode: number | null, stdout: string, stderr: string): FileCopyResult { + if (exitCode === 0) { + return { ok: true, failedStep: "none", exitCode, stdout, stderr, ...parseStats(stdout) }; + } + if (looksLikePermissionGate(stderr)) { + return { + ok: false, + failedStep: "permission-gate", + exitCode, + stdout, + stderr, + hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.", + }; + } + if (exitCode === 255 || CONNECTION_FAILURE_PATTERNS.some((re) => re.test(stderr))) { + return { ok: false, failedStep: "ssh-unreachable", exitCode, stdout, stderr }; + } + return { ok: false, failedStep: "remote-command-failed", exitCode, stdout, stderr }; +} + +export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}): Promise { + const argv = buildRsyncArgv(plan); + const spawn = deps.spawn ?? nodeSpawn; + const child = spawn("rsync", argv, { stdio: ["ignore", "pipe", "pipe"] }); + + let stdout = ""; + let stderr = ""; + let spawnFailed: NodeJS.ErrnoException | null = null; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + + const exitCode: number | null = await new Promise((resolve) => { + child.on("error", (err: NodeJS.ErrnoException) => { + spawnFailed = err; + resolve(null); + }); + child.on("close", (code: number | null) => resolve(code)); + }); + + if (spawnFailed !== null && (spawnFailed as NodeJS.ErrnoException).code === "ENOENT") { + return { + ok: false, + failedStep: "rsync-missing", + exitCode: null, + stdout, + stderr, + hint: "rsync is not installed locally. macOS: `brew install rsync` (or use the system openrsync ≥ the one shipped with your OS); Linux: install the rsync package.", + }; + } + return classifyRsyncResult(exitCode, stdout, stderr); +} diff --git a/packages/cli/src/lib/hosts/fanout-contract.ts b/packages/cli/src/lib/hosts/fanout-contract.ts new file mode 100644 index 000000000..c2399ba0a --- /dev/null +++ b/packages/cli/src/lib/hosts/fanout-contract.ts @@ -0,0 +1,50 @@ +// OPR.0.4.4.15 — THE shared intra-P4 fan-out payload contract. +// +// ONE contract for the whole P4 packet (arch adjudication 2026-07-05, +// cross-PRD interface cell): slice 15's aggregated For-You attention feed +// AND slice 21's `rig ps --all-hosts` rollup both speak AggregatedPayload — +// items plus a per-host structured status array. Slice 15 defines this +// module (first lander, per the whichever-builds-first rule named in both +// PRDs); slice 21 IMPORTS it. Any change here is a CROSS-PRD contract +// change requiring re-review of BOTH slices — never driver discretion. + +/** The local host's id literal in every P3/P4 payload (arch pin: defined + * ONCE here, imported everywhere, never retyped). Matches slice 11's + * topology aggregate literal. */ +export const LOCAL_HOST_ID = "local"; + +/** CLOSED enum (arch pin A). `unsupported-transport` is R15-2's explicit + * class (an SSH-declared host is never a silently thinner payload); + * `auth-failed` is distinct from `unreachable` because the operator fix + * differs (rotate/set the bearer vs check the host). Extending this set = + * a cross-PRD contract change (slices 15 AND 21 re-review). */ +export type PerHostStatusKind = "ok" | "unreachable" | "unsupported-transport" | "auth-failed"; + +export interface PerHostStatus { + hostId: string; + status: PerHostStatusKind; + /** Honest failure detail, rendered muted next to the host chip/row. */ + error?: string; + /** ADDITIVE OPTIONAL detail (arch adjudication): the shipped CLI + * FailedStep vocabulary when a transport-level step classified the + * failure. Never load-bearing — `status` is the contract. */ + failedStep?: string; +} + +/** items + per-host status: EVERY subscribed host appears in `hosts` on + * EVERY payload — ok, unreachable, auth-failed, or unsupported-transport. + * Absence is a contract violation (omission-proof; never all-or-nothing, + * never silent thinning). */ +export interface AggregatedPayload { + items: T[]; + hosts: PerHostStatus[]; +} + +/** Contract-level completeness predicate (arch pin B: asserted as near to + * the contract as tests allow): true iff every expected host id appears + * exactly once in the payload's hosts array. */ +export function hostsCovered(payload: AggregatedPayload, expectedHostIds: string[]): boolean { + const seen = new Map(); + for (const h of payload.hosts) seen.set(h.hostId, (seen.get(h.hostId) ?? 0) + 1); + return expectedHostIds.every((id) => seen.get(id) === 1) && payload.hosts.length === expectedHostIds.length; +} diff --git a/packages/cli/src/lib/scope-templates/backlog-deprecation.md b/packages/cli/src/lib/scope-templates/backlog-deprecation.md index 3447f7d15..4f8d18de2 100644 --- a/packages/cli/src/lib/scope-templates/backlog-deprecation.md +++ b/packages/cli/src/lib/scope-templates/backlog-deprecation.md @@ -10,6 +10,18 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} +## Intent + +[The recorded intent — what is being deprecated and why. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [The deprecation path as observable outcomes. For a small deprecation this may BE the whole plan.] + +## Proof contract + +- [ ] [The migration landed / the removal is clean — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).] + ## Target [What is being deprecated] @@ -25,3 +37,7 @@ created: {{created_date}} ## Removal [When/how the deprecated thing gets deleted] + +--- + +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/backlog-tech-debt.md b/packages/cli/src/lib/scope-templates/backlog-tech-debt.md index 7102957d1..0c55371d5 100644 --- a/packages/cli/src/lib/scope-templates/backlog-tech-debt.md +++ b/packages/cli/src/lib/scope-templates/backlog-tech-debt.md @@ -10,6 +10,18 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} +## Intent + +[The recorded intent — the debt to pay down and why now. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [The paydown as observable outcomes. For small debt this may BE the whole plan.] + +## Proof contract + +- [ ] [The debt is paid and pinned (tests/measures) — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).] + ## Current state [How the code/system looks today] @@ -21,3 +33,7 @@ created: {{created_date}} ## Proposal [How to pay it down] + +--- + +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/bug-fix.md b/packages/cli/src/lib/scope-templates/bug-fix.md index 2c4eb7559..67add71be 100644 --- a/packages/cli/src/lib/scope-templates/bug-fix.md +++ b/packages/cli/src/lib/scope-templates/bug-fix.md @@ -10,6 +10,18 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} +## Intent + +[The recorded intent — the defect to fix and why it matters. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [The fix as an observable outcome. For a bug fix this may BE the whole plan.] + +## Proof contract + +- [ ] [The repro no longer reproduces / the regression test pins it — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).] + ## Repro [Steps to reproduce] @@ -29,3 +41,7 @@ created: {{created_date}} ## Fix proposal [Optional: thinking on the fix] + +--- + +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/implementation-prd.md b/packages/cli/src/lib/scope-templates/implementation-prd.md new file mode 100644 index 000000000..8df597a5b --- /dev/null +++ b/packages/cli/src/lib/scope-templates/implementation-prd.md @@ -0,0 +1,33 @@ +--- +id: {{id}} +slice: {{slice_number}}-{{slug}} +mission: {{mission}} +status: intent +stage: wip +created: {{created_date}} +--- + + + +# Slice {{slice_number}} — {{title}} + +## Intent + +[The recorded intent, verbatim — kept in sync with the slice README.] + +## Mini-requirements + +1. [The concise one-glance requirement tier — this is where approval starts.] + +## Proof contract + +- [ ] [One promised deliverable, written as an observable outcome — captured. This list is the source the DELIVERED section pairs proof against — pair each item via `rig proof add … --evidences --media` (never hand-place evidence without the drop); UI deliverables name their planned mockup (`plannedRef`).] + +## Notes (elastic) + +[Design, seams, risks, sequencing — only as much as the slice needs.] diff --git a/packages/cli/src/lib/scope-templates/mission-placeholder.md b/packages/cli/src/lib/scope-templates/mission-placeholder.md index d0fe4a3b4..f8d3efd7a 100644 --- a/packages/cli/src/lib/scope-templates/mission-placeholder.md +++ b/packages/cli/src/lib/scope-templates/mission-placeholder.md @@ -24,7 +24,7 @@ created: {{created_date}} ## Mission/Slice SOP — how you work this mission & slice -> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. +> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. **Conventions SSOT: `docs/reference/sdlc-conventions.md`** — the slice section names (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format, the two locks (`rig scope slice approve --scope spec|delivery`), and the C1 proof headers all live there. ### The canonical files (the operating surface) diff --git a/packages/cli/src/lib/scope-templates/mission-progress.md b/packages/cli/src/lib/scope-templates/mission-progress.md index 241a6bad7..d469bd9ac 100644 --- a/packages/cli/src/lib/scope-templates/mission-progress.md +++ b/packages/cli/src/lib/scope-templates/mission-progress.md @@ -1,6 +1,6 @@ # Progress — {{missionName}} -> **WHO/WHEN:** the orchestrator owns the current-state summary; every agent logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the `markdown-control-plane` convention. +> **WHO/WHEN:** the orchestrator owns the current-state summary; every agent logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`). ## Milestones diff --git a/packages/cli/src/lib/scope-templates/mission-release.md b/packages/cli/src/lib/scope-templates/mission-release.md index af948a79b..cbf1b61af 100644 --- a/packages/cli/src/lib/scope-templates/mission-release.md +++ b/packages/cli/src/lib/scope-templates/mission-release.md @@ -29,7 +29,7 @@ created: {{created_date}} ## Mission/Slice SOP — how you work this mission & slice -> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. +> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. **Conventions SSOT: `docs/reference/sdlc-conventions.md`** — the slice section names (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format, the two locks (`rig scope slice approve --scope spec|delivery`), and the C1 proof headers all live there. ### The canonical files (the operating surface) diff --git a/packages/cli/src/lib/scope-templates/placeholder.md b/packages/cli/src/lib/scope-templates/placeholder.md index 322fdfca2..dd98f7f8d 100644 --- a/packages/cli/src/lib/scope-templates/placeholder.md +++ b/packages/cli/src/lib/scope-templates/placeholder.md @@ -10,9 +10,17 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} -## Goal +## Intent -[1-2 sentence goal.] +[The recorded intent, verbatim — what was asked for and why. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [The concise one-glance requirement tier — numbered observable outcomes. For a small slice this may BE the whole plan.] + +## Proof contract + +- [ ] [One promised deliverable, written as an observable outcome — captured. Each item pairs with its proof via `rig proof add … --evidences` (media attached with `--media`); UI deliverables name their planned mockup.] ## Source material @@ -34,10 +42,6 @@ Non-visual slices: mark this section N/A. - [Cross-slice / cross-release] -## Acceptance - -- [3-5 bullets] - --- -> **How you work this slice (SOP):** track on PROGRESS.md, prove on PROOF.md (+ `proof/`), carry state in the mission's MISSION_NOTES.md, and hand off via `rig queue`. A slice is **not done until PROOF.md exists** and every acceptance bullet has evidence. The full operating procedure lives in the mission README's Mission/Slice SOP section and the `mission-slice-sop` skill. Verify with `rig scope audit`. +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/proof.md b/packages/cli/src/lib/scope-templates/proof.md index 08ace32e0..71d906fd0 100644 --- a/packages/cli/src/lib/scope-templates/proof.md +++ b/packages/cli/src/lib/scope-templates/proof.md @@ -1,6 +1,8 @@ # PROOF — {{id}} {{title}} -> **WHO/WHEN:** the impl/QA pair that worked the slice, at slice-close — a slice is NOT done until this file exists and every plan success-criterion has evidence (mapped 1:1, artifacts under `proof/`). See the `mission-slice-sop` skill + the `markdown-control-plane` convention. +> **WHO/WHEN:** the impl/QA pair that worked the slice, at slice-close — a slice is NOT done until this file exists and every proof-contract item has evidence (mapped 1:1, artifacts under `proof/`). See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`). +> +> **HOW (the drop verb, not hand-placement):** put media files under `proof/`, then ATTACH them with `rig proof add {{id}} --artifact-type qa --verdict PASS --candidate-sha --money-evidence "" --evidences "1" --media "screenshot-01.png"` — the drop writes the C1 header the Living Notes DELIVERED pairing joins on. Hand-placing files without a drop leaves the deliverable unpaired and `unverified`. Closed by: Date: Verdict: @@ -10,6 +12,8 @@ Closed by: Date: Verdict: ## Artifacts (media in proof/) +Dropped via `rig proof add … --evidences … --media …` (one drop per verdict; media attached, never only hand-listed): + - proof/screenshot-01.png — - proof/capture-behavior.gif — - proof/command-output.txt — diff --git a/packages/cli/src/lib/scope-templates/release-feature.md b/packages/cli/src/lib/scope-templates/release-feature.md index ca9b3817f..f33de02b4 100644 --- a/packages/cli/src/lib/scope-templates/release-feature.md +++ b/packages/cli/src/lib/scope-templates/release-feature.md @@ -10,18 +10,26 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} -## Goal +## Intent -[What this feature ships and why now] +[The recorded intent, verbatim — what this feature ships and why now. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [The concise one-glance requirement tier — numbered observable outcomes.] + +## Proof contract + +- [ ] [One promised deliverable, written as an observable outcome — captured. Each item pairs with its proof via `rig proof add … --evidences` (media attached with `--media`); UI deliverables name their planned mockup.] ## Scope [In / out for v0] -## Acceptance - -- [3-5 bullets] - ## Risks [Known unknowns] + +--- + +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/research.md b/packages/cli/src/lib/scope-templates/research.md index b386259e1..710ca6349 100644 --- a/packages/cli/src/lib/scope-templates/research.md +++ b/packages/cli/src/lib/scope-templates/research.md @@ -10,6 +10,18 @@ created: {{created_date}} # Slice {{slice_number}} — {{title}} +## Intent + +[The recorded intent — the thing we are trying to learn and why. The Living Notes UI projects this text as the INTENT section.] + +## Mini-requirements + +1. [What a sufficient answer looks like. For research this may BE the whole plan.] + +## Proof contract + +- [ ] [The findings artifact answering the question — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).] + ## Question [The thing we are trying to learn] @@ -21,3 +33,7 @@ created: {{created_date}} ## Findings [Captured as work progresses] + +--- + +> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`. diff --git a/packages/cli/src/lib/scope-templates/slice-progress.md b/packages/cli/src/lib/scope-templates/slice-progress.md index 573f3f238..4a187f606 100644 --- a/packages/cli/src/lib/scope-templates/slice-progress.md +++ b/packages/cli/src/lib/scope-templates/slice-progress.md @@ -1,6 +1,6 @@ # Progress — {{sliceName}} -> **WHO/WHEN:** every agent working this slice logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the `markdown-control-plane` convention. +> **WHO/WHEN:** every agent working this slice logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`). ## Acceptance diff --git a/packages/cli/src/lib/scope/scope-audit.ts b/packages/cli/src/lib/scope/scope-audit.ts index 409e88720..215c98058 100644 --- a/packages/cli/src/lib/scope/scope-audit.ts +++ b/packages/cli/src/lib/scope/scope-audit.ts @@ -13,7 +13,18 @@ export type FindingKind = | "malformed_mission_brief" | "missing_mission_notes" | "missing_proof" - | "progress_not_updated_on_commit"; + | "progress_not_updated_on_commit" + // OPR.0.4.4.19 FR-10 — the belt-and-suspenders BACKSTOPS (never the + // primary enforcement; the primary is the drop path / write path): + | "proof_artifact_c1_invalid" + | "missing_impl_prd" + // OPR.0.4.4.23 — SDLC convention-section advisories (fail-open by + // construction: low/info severities never flip the audit exit code; + // conventions SSOT: docs/reference/sdlc-conventions.md): + | "missing_intent_section" + | "mini_requirements_missing_or_malformed" + | "proof_contract_missing_or_malformed" + | "ui_slice_missing_mockup"; export interface AuditFinding { kind: FindingKind; @@ -52,6 +63,21 @@ export interface ScopeAuditInput { // is unavailable the CLI leaves them undefined: no false-green, no false-positive. sliceTouchedByRecentCommit?: boolean; progressTouchedByRecentCommit?: boolean; + // OPR.0.4.4.19 FR-10 (C1 backstop) — the slice's proof/ dir markdown + // artifacts with their raw frontmatter, caller-listed. Undefined = the + // caller has no proof-dir context; the check is inert (no false findings). + // Media files (video/screenshot) are exempt by construction — callers list + // .md artifacts only. + proofArtifacts?: Array<{ path: string; frontmatterRaw: string | null }>; + // OPR.0.4.4.19 FR-10 (C7 backstop) — whether IMPLEMENTATION-PRD.md exists + // at the slice root. Undefined = inert (caller has no fs context). + implementationPrdExists?: boolean; + // OPR.0.4.4.23 — convention-section advisory inputs: full file contents, + // caller-read. Undefined = the caller has no content context and every + // section check is inert (no false findings). null = the file does not + // exist (the proof-contract check falls back to the README on a null PRD). + readmeContent?: string | null; + implementationPrdContent?: string | null; } export interface ScopeAuditResult { @@ -60,7 +86,11 @@ export interface ScopeAuditResult { frontmatterError: string | null; } -const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"]; +// OPR.0.4.4.20 FR-8: exported so the review brief-spine writer conforms to +// the SAME pinned exact-order schema this audit enforces (parity by +// construction — the generated output can never trip malformed_mission_brief +// without this file changing too). +export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"]; function childPath(parent: string, child: string): string { return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`; @@ -105,6 +135,72 @@ function statusRequiresProof(status: string | null | undefined): boolean { || normalized.includes("promoted"); } +// OPR.0.4.4.19 FR-10 (C7) — the statusRequiresProof pattern applied one +// phase earlier: a slice whose status implies SPECCED/BUILDING (or later) +// must carry IMPLEMENTATION-PRD.md at its root ("no build without a +// specifying doc"). Shaping / pre-spec statuses are NOT violations. +function statusRequiresSpec(status: string | null | undefined): boolean { + if (!status) return false; + const normalized = status.toLowerCase().trim(); + return normalized.includes("build") + || normalized.includes("implement") + || normalized.includes("in-progress") + || normalized.includes("review") + || normalized.includes("qa") + || statusRequiresProof(normalized); +} + +// OPR.0.4.4.19 FR-10 (C1) — the ratified closed sets (BR-4; source of truth +// for the drop path lives in the CLI proof command; this mirrored file +// carries its own copy because both scope-audit copies must stay +// self-contained + byte-identical. Extending the sets is a pm-lead +// convention change, made in BOTH places). +const C1_REQUIRED_FIELDS = ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const; +const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const; +const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const; + +/** Validate one proof artifact's raw frontmatter against the C1 contract. + * Returns null when valid; else the human-readable problem list. */ +function c1ArtifactProblems(frontmatterRaw: string | null): string[] | null { + if (frontmatterRaw === null) { + return [`no frontmatter header at all (required C1 fields: ${C1_REQUIRED_FIELDS.join(", ")})`]; + } + let parsed: unknown = null; + try { + parsed = YAML.parse(frontmatterRaw); + } catch (err) { + return [`frontmatter fails to parse: ${err instanceof Error ? err.message : String(err)}`]; + } + const fm = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + const problems: string[] = []; + const missing = C1_REQUIRED_FIELDS.filter((f) => typeof fm[f] !== "string" || (fm[f] as string).trim().length === 0); + if (missing.length > 0) problems.push(`missing field(s): ${missing.join(", ")}`); + if (typeof fm.artifact_type === "string" && !(C1_ARTIFACT_TYPES as readonly string[]).includes(fm.artifact_type)) { + problems.push(`artifact_type '${fm.artifact_type}' not in closed set (${C1_ARTIFACT_TYPES.join(" | ")})`); + } + if (typeof fm.verdict === "string" && !(C1_VERDICTS as readonly string[]).includes(fm.verdict)) { + problems.push(`verdict '${fm.verdict}' not in closed set (${C1_VERDICTS.join(" | ")})`); + } + return problems.length > 0 ? problems : null; +} + +// OPR.0.4.4.23 — markdown H2-section helpers for the convention-section +// advisories. Headings are literals owned by this file ("Intent", +// "Proof contract", "Intent visual") — no user input reaches the regex. +function hasH2(content: string, heading: string): boolean { + return new RegExp(`^##\\s+${heading}\\s*$`, "m").test(content); +} + +function h2Body(content: string, heading: string): string | null { + const match = new RegExp(`^##\\s+${heading}\\s*$`, "m").exec(content); + if (!match) return null; + const rest = content.slice(match.index + match[0].length); + const next = rest.search(/^##\s+/m); + return next === -1 ? rest : rest.slice(0, next); +} + // A mission is ACTIVE unless its status names a terminal / archived state. // The SOP wants missing_mission_notes to fire for an ACTIVE mission only — a // shipped/archived mission no longer needs a live continuity file. No status @@ -264,6 +360,115 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult { }); } + // OPR.0.4.4.19 FR-10 (C1 backstop) — flag proof/ artifacts missing the + // C1 header or carrying out-of-set values. The backstop catches what + // bypassed the drop path (raw file writes are never gated at write + // time; this is where they surface). + for (const artifact of input.proofArtifacts ?? []) { + const problems = c1ArtifactProblems(artifact.frontmatterRaw); + if (problems) { + findings.push({ + kind: "proof_artifact_c1_invalid", + severity: "medium", + path: artifact.path, + message: `Proof artifact violates the C1 header contract: ${problems.join("; ")}.`, + remediation: `Re-drop via: rig proof add --artifact-type <${C1_ARTIFACT_TYPES.join("|")}> --verdict <${C1_VERDICTS.join("|")}> --candidate-sha --money-evidence "" — or add the missing frontmatter fields in place.`, + }); + } + } + + // OPR.0.4.4.19 FR-10 (C7 backstop) — a specced/building-or-later slice + // must carry IMPLEMENTATION-PRD.md (the pinned expected filename) at its + // root. Status-gated: shaping is not a violation. Inert when the caller + // provided no fs context (implementationPrdExists undefined). + if (input.implementationPrdExists === false && statusRequiresSpec(status)) { + findings.push({ + kind: "missing_impl_prd", + severity: "medium", + path: childPath(input.path, "IMPLEMENTATION-PRD.md"), + message: `Slice status "${status}" implies specced/building but there is no IMPLEMENTATION-PRD.md at the slice root (C7: no build without a specifying doc).`, + remediation: "Author (or relocate) the slice's IMPLEMENTATION-PRD.md at the slice root — the pinned C7 filename — before building continues.", + }); + } + + // OPR.0.4.4.23 — SDLC convention-section advisories (SSOT: + // docs/reference/sdlc-conventions.md). Structurally fail-open: the + // audit command flips its exit code on HIGH findings only, and these + // are low/info by construction — they record and advise, never gate. + // Inert when the caller provided no content context (undefined inputs). + if (typeof input.readmeContent === "string" && !hasH2(input.readmeContent, "Intent")) { + findings.push({ + kind: "missing_intent_section", + severity: "low", + path: childPath(input.path, "README.md"), + message: "Slice README has no `## Intent` section, so the Living Notes UI cannot project its INTENT section.", + remediation: "Add `## Intent` carrying the recorded intent verbatim (conventions SSOT: docs/reference/sdlc-conventions.md; `rig scope slice create` scaffolds it).", + }); + } + + const contractSource = typeof input.implementationPrdContent === "string" + ? { content: input.implementationPrdContent, path: childPath(input.path, "IMPLEMENTATION-PRD.md") } + : typeof input.readmeContent === "string" + ? { content: input.readmeContent, path: childPath(input.path, "README.md") } + : null; + if (contractSource) { + // OPR.0.4.4.23 rev1-r2 B1 (PRD L34 guard F-3): well-formed + // `## Mini-requirements` — the PLAN leg of the Living Notes + // projection. Same source-selection policy as the proof contract + // (PRD preferred, README fallback). Well-formed = the heading plus + // at least one numbered list item; a heading over prose-only is + // malformed (no usable requirements projection). + const miniBody = h2Body(contractSource.content, "Mini-requirements"); + const hasNumberedItem = miniBody !== null && /^\s*\d+\.\s+\S/m.test(miniBody); + if (!hasNumberedItem) { + findings.push({ + kind: "mini_requirements_missing_or_malformed", + severity: "low", + path: contractSource.path, + message: miniBody === null + ? "No `## Mini-requirements` section — the Living Notes PLAN section has no concise requirements tier to project." + : "`## Mini-requirements` carries no numbered items (`1. …`) — the one-glance requirement tier is where approval starts.", + remediation: "Add `## Mini-requirements` with a numbered list of observable outcomes (conventions SSOT: docs/reference/sdlc-conventions.md; for a small slice this may BE the whole PRD).", + }); + } + + const contractBody = h2Body(contractSource.content, "Proof contract"); + const hasCheckboxItem = contractBody !== null && /^\s*-\s*\[[ xX]\]/m.test(contractBody); + if (!hasCheckboxItem) { + findings.push({ + kind: "proof_contract_missing_or_malformed", + severity: "low", + path: contractSource.path, + message: contractBody === null + ? "No `## Proof contract` section — the DELIVERED pairing has no promised-deliverables source to join proof against." + : "`## Proof contract` carries no checkbox deliverables (`- [ ] …`) — the DELIVERED pairing joins proof against those items.", + remediation: "Add `## Proof contract` with one checkbox line per promised deliverable, written as an observable outcome (conventions SSOT: docs/reference/sdlc-conventions.md).", + }); + } + + if (typeof input.readmeContent === "string") { + const visualBody = h2Body(input.readmeContent, "Intent visual"); + const isUiSlice = visualBody !== null && !/\bN\/A\b/i.test(visualBody); + // rev1-r2 B2: a mockup is PRESENT only via a real markdown + // image/media ref in `## Intent visual` or an explicit plannedRef + // token in the proof contract. Generic prose containing the word + // "mockup" (the scaffold placeholder says "name their planned + // mockup") is NOT a reference and must not suppress the advisory. + const hasMockupRef = /!\[/.test(visualBody ?? "") + || /plannedRef/i.test(contractBody ?? "") + || /!\[/.test(contractBody ?? ""); + if (isUiSlice && !hasMockupRef) { + findings.push({ + kind: "ui_slice_missing_mockup", + severity: "info", + path: childPath(input.path, "README.md"), + message: "Slice declares an Intent visual (UI slice) but no mockup reference is present — a UI slice with no mockup in its locked set is an incomplete plan.", + remediation: "Attach the planned mockup: an image ref in `## Intent visual` or a plannedRef on the proof-contract deliverable (conventions SSOT: docs/reference/sdlc-conventions.md §3).", + }); + } + } + } + // committed-without-PROGRESS. Fires only on POSITIVE evidence: the most // recent commit (HEAD) touched this slice AND definitively did NOT touch its // PROGRESS.md. Inputs are git-derived and CLI-only; when git context is diff --git a/packages/cli/src/lib/scope/templates.ts b/packages/cli/src/lib/scope/templates.ts index 4806a828e..afcdf6078 100644 --- a/packages/cli/src/lib/scope/templates.ts +++ b/packages/cli/src/lib/scope/templates.ts @@ -72,6 +72,16 @@ export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): return applyPlaceholders(raw, opts); } +// OPR.0.4.4.23 — every scaffolded slice also gets an IMPLEMENTATION-PRD.md +// skeleton (the convention sections + the elastic-middle header note), so a +// fresh install authors what the Living Notes UI projects. One skeleton for +// all SliceTemplateKind values; proportionality lives in the note, not in +// kind-specific PRD shapes. Conventions SSOT: docs/reference/sdlc-conventions.md. +export function renderImplementationPrdTemplate(opts: RenderOpts): string { + const raw = fs.readFileSync(resolveTemplate("implementation-prd.md"), "utf8"); + return applyPlaceholders(raw, opts); +} + export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string { const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md"; const raw = fs.readFileSync(resolveTemplate(filename), "utf8"); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 3404824a4..0608732d3 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { BUILD_INFO, type BuildInfo } from "./build-info.js"; type PackageJson = { version?: string; @@ -10,4 +11,13 @@ function readPackageVersion(): string { return parsed.version ?? "0.0.0"; } -export const CLI_VERSION = readPackageVersion(); +/** OPR.0.4.4.11 FR-7 — a STAMPED build prints ` ()` (plus + * a dirty marker when dirty); an unstamped dev run prints the semver alone + * exactly as before (negative AC: no fake identity). */ +export function formatCliVersion(semver: string, info: BuildInfo): string { + if (!info.commit) return semver; + const short = info.commit.slice(0, 8); + return info.dirty ? `${semver} (${short}, dirty)` : `${semver} (${short})`; +} + +export const CLI_VERSION = formatCliVersion(readPackageVersion(), BUILD_INFO); diff --git a/packages/cli/test/client.test.ts b/packages/cli/test/client.test.ts index a6baad601..8f0dd319a 100644 --- a/packages/cli/test/client.test.ts +++ b/packages/cli/test/client.test.ts @@ -180,7 +180,7 @@ describe("DaemonClient", () => { cwd: resolve(import.meta.dirname, ".."), }); - expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/); }); // Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody } diff --git a/packages/cli/test/cross-host-commands.test.ts b/packages/cli/test/cross-host-commands.test.ts index 8ff815913..5c539966f 100644 --- a/packages/cli/test/cross-host-commands.test.ts +++ b/packages/cli/test/cross-host-commands.test.ts @@ -327,11 +327,11 @@ describe("ps --host (cross-host short-circuit)", () => { }, })); await cmd.parseAsync([ - "--host", "vm-a", "--nodes", "--full", "--limit", "20", - "--fields", "rigName,status", "--summary", "--filter", "status=running", "--json", + "--host", "vm-a", "--nodes", "-A", "--full", "--limit", "20", + "--fields", "rigName,sessionStatus", "--summary", "--filter", "status=running", "--json", ], { from: "user" }); expect(captureCalls.argv).toEqual([ - "rig", "ps", "--nodes", "--full", "--limit", "20", "--fields", "rigName,status", + "rig", "ps", "--nodes", "--full", "--all-rigs", "--limit", "20", "--fields", "rigName,sessionStatus", "--summary", "--filter", "status=running", "--json", ]); }); diff --git a/packages/cli/test/fanout-contract-parity.test.ts b/packages/cli/test/fanout-contract-parity.test.ts new file mode 100644 index 000000000..b7ca03e07 --- /dev/null +++ b/packages/cli/test/fanout-contract-parity.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { LOCAL_HOST_ID, hostsCovered, type AggregatedPayload } from "../src/lib/hosts/fanout-contract.js"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); + +// OPR.0.4.4.21 × OPR.0.4.4.15 — THE intra-P4 shared fan-out contract. +// Slice 15 defines the module daemon-side (first lander, 0ecd329b); the CLI +// cannot import across the package boundary (deliberately daemon-free), so +// it carries a BYTE-IDENTICAL copy enforced here — the shipped scope-audit +// parity pattern. Any intentional contract change lands in BOTH copies and +// is a cross-PRD re-review (slices 15 + 21) per the arch adjudication. +describe("fanout-contract CLI/daemon parity (CI-FAILING)", () => { + it("fanout-contract.ts is byte-equivalent across CLI and daemon", () => { + const cliContent = fs.readFileSync(path.join(REPO_ROOT, "packages/cli/src/lib/hosts/fanout-contract.ts"), "utf-8"); + const daemonContent = fs.readFileSync(path.join(REPO_ROOT, "packages/daemon/src/domain/hosts/fanout-contract.ts"), "utf-8"); + expect(cliContent).toBe(daemonContent); + }); + + it("exports the pinned contract members", () => { + expect(LOCAL_HOST_ID).toBe("local"); + const payload: AggregatedPayload<{ x: number }> = { + items: [{ x: 1 }], + hosts: [{ hostId: "h1", status: "ok" }, { hostId: "h2", status: "unsupported-transport", error: "ssh" }], + }; + expect(hostsCovered(payload, ["h1", "h2"])).toBe(true); + expect(hostsCovered(payload, ["h1"])).toBe(false); // extra host = violation + expect(hostsCovered({ items: [], hosts: [] }, ["h1"])).toBe(false); // omission = violation + }); +}); diff --git a/packages/cli/test/file-command.test.ts b/packages/cli/test/file-command.test.ts new file mode 100644 index 000000000..03958fa7c --- /dev/null +++ b/packages/cli/test/file-command.test.ts @@ -0,0 +1,125 @@ +// OPR.0.4.4.18 — `rig file copy` CLI wiring: rejection surfaces render the +// core's structured errors verbatim (fail-closed, spawn NEVER invoked on any +// rejection — the spawn-spy negative), dry-run reports the planned transfer, +// JSON mode is agent-consumable and exposes the closed builder's argv. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fileCommand } from "../src/commands/file.js"; +import type { FileCopyResult } from "../src/lib/file-transfer.js"; +import type { SshHostEntry } from "../src/host-registry.js"; + +const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" }; +const registryLoader = (() => ({ ok: true as const, registry: { hosts: [VPS] } })) as never; + +let logs: string[]; +let errs: string[]; + +beforeEach(() => { + logs = []; + errs = []; + vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { + logs.push(a.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { + errs.push(a.join(" ")); + }); + process.exitCode = 0; +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = 0; +}); + +function okResult(partial: Partial = {}): FileCopyResult { + return { ok: true, failedStep: "none", exitCode: 0, stdout: "", stderr: "", ...partial }; +} + +async function runCopy(args: string[], run?: (plan: unknown) => Promise) { + const runs: unknown[] = []; + const cmd = fileCommand({ + registryLoader, + run: (async (plan: unknown) => { + runs.push(plan); + return run ? run(plan) : okResult({ bytesTransferred: 128, filesTransferred: 1 }); + }) as never, + }); + await cmd.parseAsync(["copy", ...args], { from: "user" }); + return { runs }; +} + +describe("rig file copy — rejection surfaces (spawn NEVER invoked)", () => { + it("remote:remote rejects with the pull-then-push text; exit 1; no run", async () => { + const { runs } = await runCopy(["vps-a:/a.md", "vps-a:/b.md"]); + expect(errs.join("\n")).toContain("remote-to-remote is not in v0; pull then push"); + expect(process.exitCode).toBe(1); + expect(runs).toHaveLength(0); + }); + + it("unknown host (the N18-1 colon-file case) fails loudly; no run", async () => { + const { runs } = await runCopy(["notes:v2.md", "./out.md"]); + expect(errs.join("\n")).toContain("unknown host id 'notes'"); + expect(runs).toHaveLength(0); + expect(process.exitCode).toBe(1); + }); + + it("denied location + charset violations reject before any transfer; JSON mode carries {ok:false, code}", async () => { + await runCopy(["--json", "~/.ssh/id_ed25519", "vps-a:/tmp/k"]); + const payload = JSON.parse(logs[0]!) as Record; + expect(payload["ok"]).toBe(false); + expect(payload["code"]).toBe("denied_path"); + expect(process.exitCode).toBe(1); + + logs = []; + const { runs } = await runCopy(["--json", "./a.md", "vps-a:/srv/a b.md"]); + const payload2 = JSON.parse(logs[0]!) as Record; + expect(payload2["code"]).toBe("denied_path"); + expect(String(payload2["error"])).toContain("a space"); + expect(runs).toHaveLength(0); + }); + + it("leading-dash operand refused, teaching the ./ escape; no run", async () => { + const { runs } = await runCopy(["--", "--delete-after", "vps-a:/srv/x"]); + expect(errs.join("\n")).toContain("./--delete-after"); + expect(runs).toHaveLength(0); + }); +}); + +describe("rig file copy — success + dry-run rendering", () => { + it("success prints src → dst + bytes", async () => { + await runCopy(["./a.md", "vps-a:/srv/briefs/a.md"]); + expect(logs.join("\n")).toMatch(/Copied .*a\.md → vps-a:\/srv\/briefs\/a\.md \(128 bytes\)/); + expect(process.exitCode).toBe(0); + }); + + it("dry-run prints the planned transfer and moves nothing (native rsync --dry-run rides the plan)", async () => { + const { runs } = await runCopy( + ["--dry-run", "./a.md", "vps-a:/srv/a.md"], + async () => okResult({ stdout: ">f+++++++++ a.md\nNumber of regular files transferred: 1\nTotal transferred file size: 128 bytes\n", bytesTransferred: 128, filesTransferred: 1 }), + ); + expect((runs[0] as { dryRun: boolean }).dryRun).toBe(true); + const out = logs.join("\n"); + expect(out).toContain("DRY RUN — nothing moved"); + expect(out).toContain("→ vps-a:/srv/a.md"); + expect(out).toContain("files: 1, bytes: 128"); + }); + + it("JSON success exposes the closed builder's argv (transparency; '--' pin visible)", async () => { + await runCopy(["--json", "./a.md", "vps-a:/srv/a.md"]); + const payload = JSON.parse(logs[0]!) as { ok: boolean; rsyncArgv: string[]; src: string; dst: string }; + expect(payload.ok).toBe(true); + expect(payload.dst).toBe("vps-a:/srv/a.md"); + expect(payload.rsyncArgv.indexOf("--")).toBe(payload.rsyncArgv.length - 3); + }); + + it("transfer failure renders [failedStep] + stderr + hint; exit 1", async () => { + await runCopy(["./a.md", "vps-a:/srv/a.md"], async () => + ({ ok: false, failedStep: "permission-gate", exitCode: 255, stdout: "", stderr: "Permission denied (publickey).", hint: "See the keychain field note." }) as FileCopyResult, + ); + const err = errs.join("\n"); + expect(err).toContain("[permission-gate]"); + expect(err).toContain("Permission denied"); + expect(err).toContain("Hint:"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/test/file-transfer.test.ts b/packages/cli/test/file-transfer.test.ts new file mode 100644 index 000000000..17383e8c0 --- /dev/null +++ b/packages/cli/test/file-transfer.test.ts @@ -0,0 +1,302 @@ +// OPR.0.4.4.18 — the security cell, test-asserted (plan §3/§7a/§7a-2, +// guard-cleared over three rounds). The property tests ARE the contract: +// deletion-class flags unreachable, -s/--protect-args ABSENT (the G18-P3 +// uniformity pin), '--' always pinned before operands (G18-P1), the remote +// charset shell-inert, every rejection before any spawn. + +import { describe, it, expect } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { + parseFilePathArg, + checkLocalPath, + checkRemotePath, + planFileCopy, + buildRsyncArgv, + classifyRsyncResult, + runFileCopy, + DENIED_SEGMENTS, + type CopyPlan, + type CopySide, +} from "../src/lib/file-transfer.js"; +import type { SshHostEntry } from "../src/host-registry.js"; + +const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" }; +const REGISTRY_OK = () => ({ ok: true as const, registry: { hosts: [VPS, { id: "http-1", transport: "http" as const, url: "http://h:7433", bearer_env: "T" }] } }); + +function local(p: string): CopySide { + return { kind: "local", path: p }; +} +function remote(p: string): CopySide { + return { kind: "remote", path: p, host: VPS }; +} + +// ── grammar (FR-1/FR-2/N18-1/G18-P1) ──────────────────────────────────────── + +describe("parseFilePathArg — the explicit-or-local grammar", () => { + it("local-prefix escapes are ALWAYS local, colon or not", () => { + for (const p of ["/abs/file.md", "./notes:v2.md", "../up.md", "~/docs/x.md", "~"]) { + expect(parseFilePathArg(p)).toEqual({ ok: true, arg: { kind: "local", path: p } }); + } + }); + + it("id-shaped colon prefix parses as a HOST qualifier (resolve-or-fail-loud happens at planning)", () => { + expect(parseFilePathArg("vps-a:/srv/x.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "vps-a", path: "/srv/x.md" } }); + expect(parseFilePathArg("mac_mini2:/tmp/y")).toEqual({ ok: true, arg: { kind: "remote", hostId: "mac_mini2", path: "/tmp/y" } }); + }); + + it("N18-1: a bare colon-named file parses as an (unknown) host — fail-closed at planning, never silent-local", () => { + expect(parseFilePathArg("notes:v2.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "notes", path: "v2.md" } }); + }); + + it("non-id-shaped colon prefixes are local (scp's lived behavior)", () => { + expect(parseFilePathArg("dir/sub:file.md")).toEqual({ ok: true, arg: { kind: "local", path: "dir/sub:file.md" } }); + }); + + it("G18-P1: leading-dash operands are REFUSED with the ./ escape taught", () => { + const res = parseFilePathArg("--delete-after"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("./--delete-after"); + }); + + it("empty remote path after the qualifier rejects", () => { + const res = parseFilePathArg("vps-a:"); + expect(res.ok).toBe(false); + }); +}); + +// ── the deny wall (FR-4) ──────────────────────────────────────────────────── + +describe("checkLocalPath — resolve-then-recheck deny wall", () => { + it("denies resolved paths under each short-closed-list root, incl. via traversal and ~", () => { + const home = os.homedir(); + for (const seg of DENIED_SEGMENTS) { + const direct = checkLocalPath(path.join(home, seg, "x")); + expect(direct.ok).toBe(false); + const viaTilde = checkLocalPath(`~/${seg}/hosts.yaml`); + expect(viaTilde.ok).toBe(false); + // Traversal resolving INTO a denied root is caught on the RESOLVED form. + const viaTraversal = checkLocalPath(path.join(home, "safe", "..", seg, "x")); + expect(viaTraversal.ok).toBe(false); + } + }); + + it("denies the active hosts registry and custom OPENRIG_HOME state", () => { + const saved = process.env["OPENRIG_HOME"]; + const customHome = path.join(os.tmpdir(), `openrig-s18-active-${Date.now()}`); + try { + process.env["OPENRIG_HOME"] = customHome; + const registry = checkLocalPath(path.join(customHome, "hosts.yaml")); + expect(registry.ok).toBe(false); + if (!registry.ok) expect(registry.error).toContain("active hosts registry"); + + const state = checkLocalPath(path.join(customHome, "workspace", "artifact.md")); + expect(state.ok).toBe(false); + if (!state.ok) expect(state.error).toContain("active OPENRIG_HOME"); + } finally { + if (saved === undefined) delete process.env["OPENRIG_HOME"]; + else process.env["OPENRIG_HOME"] = saved; + } + }); + + it("allows ordinary paths and returns the RESOLVED form (what rsync receives)", () => { + const res = checkLocalPath("./some/dir/../file.md"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(path.isAbsolute(res.normalizedPath)).toBe(true); + expect(res.normalizedPath.includes("..")).toBe(false); + } + }); +}); + +describe("checkRemotePath — the shell-inert charset wall (G18-P3) + absolute-only + traversal + segments", () => { + it("rejects every shell-interpretable character class, TEACHING the offending char", () => { + const cases: Array<[string, string]> = [ + ["/srv/a b.md", "a space"], + ["/srv/$HOME/x", "'$'"], + ["/srv/`id`.md", "'`'"], + ["/srv/x;rm", "';'"], + ["/srv/x|y", "'|'"], + ["/srv/x&y", "'&'"], + ["/srv/'q'.md", `"'"`.slice(1, 2) === "'" ? "'''" : "quote"], + ["/srv/x>y", "'>'"], + ["/srv/(x)", "'('"], + ["/srv/{x}", "'{'"], + ["/srv/x\\y", "'\\'"], + ["/srv/x\ny", "\n"], + ["/srv/*.md", "'*'"], + ["/srv/x?.md", "'?'"], + ["~/x.md", "'~'"], + ]; + for (const [p] of cases) { + const res = checkRemotePath(p, "vps-a"); + expect(res.ok, `should reject: ${JSON.stringify(p)}`).toBe(false); + } + // The teaching shape, spot-checked on the space case (arch note 1). + const space = checkRemotePath("/srv/a b.md", "vps-a"); + if (!space.ok) { + expect(space.error).toContain("a space"); + expect(space.error).toContain("space-free staging path"); + } + }); + + it("rejects relative remote paths (absolute-only, arch Q5)", () => { + const res = checkRemotePath("srv/x.md", "vps-a"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("ABSOLUTE-only"); + }); + + it("G18-C1: ANY raw '..' segment rejects BEFORE normalization (normalize COLLAPSES '..' in absolute paths — a post-normalize check is dead code)", () => { + expect(checkRemotePath("/srv/../../etc/passwd", "vps-a").ok).toBe(false); // the root-climb guard caught escaping + expect(checkRemotePath("/srv/sub/../x.md", "vps-a").ok).toBe(false); // 'benign' collapsible '..' rejects too — never necessary in an absolute path + const res = checkRemotePath("/srv/..hidden/x.md", "vps-a"); // '..' as a NAME PREFIX is not a traversal segment + expect(res.ok).toBe(true); + // Normalization still collapses '.' and '//' — the normalized form is what ships. + const collapsed = checkRemotePath("/srv//sub/./x.md", "vps-a"); + expect(collapsed.ok).toBe(true); + if (collapsed.ok) expect(collapsed.normalizedPath).toBe("/srv/sub/x.md"); + }); + + it("rejects denied dot-directory segments ANYWHERE in the path (conservative-over-broad)", () => { + for (const seg of DENIED_SEGMENTS) { + expect(checkRemotePath(`/home/u/${seg}/f`, "vps-a").ok).toBe(false); + expect(checkRemotePath(`/srv/backup/${seg}/f`, "vps-a").ok).toBe(false); + } + // Also via traversal that normalizes INTO a denied segment. + expect(checkRemotePath("/srv/x/../.ssh/id_ed25519", "vps-a").ok).toBe(false); + }); +}); + +// ── planning: the fail-closed set (all BEFORE any spawn) ──────────────────── + +describe("planFileCopy — fail-closed set", () => { + const deps = { registryLoader: REGISTRY_OK as never }; + + it("remote:remote rejects with the pull-then-push message", () => { + const res = planFileCopy("vps-a:/a.md", "vps-a:/b.md", deps); + expect(res).toMatchObject({ ok: false, code: "remote_to_remote" }); + }); + + it("unknown host fails loudly (the N18-1 colon-file case lands here)", () => { + const res = planFileCopy("notes:v2.md", "./out.md", deps); + expect(res).toMatchObject({ ok: false, code: "unknown_host" }); + if (!res.ok) expect(res.error).toContain("unknown host id 'notes'"); + }); + + it("http-transport host rejects (ssh/rsync only in v0)", () => { + const res = planFileCopy("http-1:/x.md", "./out.md", deps); + expect(res).toMatchObject({ ok: false, code: "unsupported_transport" }); + }); + + it("two bare paths = a local-only plan (explicit-or-local; no inference)", () => { + const res = planFileCopy("./a.md", "b.md", deps); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.plan.src.kind).toBe("local"); + expect(res.plan.dst.kind).toBe("local"); + } + }); + + it("denied locations reject at planning on either side", () => { + expect(planFileCopy("~/.ssh/id_ed25519", "vps-a:/tmp/key", deps)).toMatchObject({ ok: false, code: "denied_path" }); + expect(planFileCopy("./ok.md", "vps-a:/home/u/.openrig/db.sqlite", deps)).toMatchObject({ ok: false, code: "denied_path" }); + }); +}); + +// ── the CLOSED argv builder: property tests ───────────────────────────────── + +const ADVERSARIAL_LOCALS = ["/tmp/--delete-after", "/tmp/--remove-source-files", "/tmp/-e", "/tmp/--rsh=x", "/tmp/-- evil"]; + +function allPlans(): CopyPlan[] { + const plans: CopyPlan[] = []; + for (const dryRun of [false, true]) { + plans.push({ src: local("/tmp/a.md"), dst: remote("/srv/a.md"), dryRun }); + plans.push({ src: remote("/srv/a.md"), dst: local("/tmp/a.md"), dryRun }); + plans.push({ src: local("/tmp/a.md"), dst: local("/tmp/b.md"), dryRun }); + for (const adv of ADVERSARIAL_LOCALS) { + plans.push({ src: local(adv), dst: remote("/srv/x"), dryRun }); + plans.push({ src: local(adv), dst: local("/tmp/y"), dryRun }); + } + } + return plans; +} + +describe("buildRsyncArgv — closed-builder properties (every permutation)", () => { + it("NO deletion-class flag, NO -s/--protect-args (G18-P3 absence pin), '--' ALWAYS pinned before the two operands, flag-shaped operands only in post-'--' positions (G18-P1)", () => { + for (const plan of allPlans()) { + const argv = buildRsyncArgv(plan); + const sep = argv.indexOf("--"); + expect(sep, JSON.stringify(argv)).toBeGreaterThan(-1); + expect(sep).toBe(argv.length - 3); // exactly two operands after the pin + const options = argv.slice(0, sep); + for (const opt of options) { + expect(opt.startsWith("--delete")).toBe(false); + expect(opt.startsWith("--remove")).toBe(false); + expect(opt.startsWith("--force")).toBe(false); + expect(opt).not.toBe("-s"); + expect(opt).not.toBe("--protect-args"); + } + // Adversarial flag-shaped paths appear ONLY as operands (index > sep). + for (const adv of ADVERSARIAL_LOCALS) { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === adv) expect(i).toBeGreaterThan(sep); + } + } + // Dry-run is rsync's NATIVE flag, present iff requested. + expect(options.includes("--dry-run")).toBe(plan.dryRun); + } + }); + + it("the -e ssh command is built ONLY from registry fields; local-local plans carry no -e at all", () => { + const withRemote = buildRsyncArgv({ src: local("/tmp/a"), dst: remote("/srv/b"), dryRun: false }); + const e = withRemote[withRemote.indexOf("-e") + 1]!; + expect(e).toBe("ssh -o ConnectTimeout=10 -l openrig"); + const localOnly = buildRsyncArgv({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: false }); + expect(localOnly.includes("-e")).toBe(false); + }); + + it("remote operands render as target:path from the registry target", () => { + const argv = buildRsyncArgv({ src: local("/tmp/a.md"), dst: remote("/srv/briefs/a.md"), dryRun: false }); + expect(argv[argv.length - 1]).toBe("vps-a.tail.net:/srv/briefs/a.md"); + }); +}); + +// ── classification (executor taxonomy minus the daemon class) ─────────────── + +describe("classifyRsyncResult / runFileCopy", () => { + it("exit 0 → ok with --stats parsed", () => { + const res = classifyRsyncResult(0, "Number of regular files transferred: 1\nTotal transferred file size: 2,048 bytes\n", ""); + expect(res).toMatchObject({ ok: true, failedStep: "none", bytesTransferred: 2048, filesTransferred: 1 }); + }); + + it("permission-gate stderr signature → permission-gate (single-source via the exported executor matcher)", () => { + expect(classifyRsyncResult(255, "", "user@host: Permission denied (publickey).").failedStep).toBe("permission-gate"); + }); + + it("exit 255 / connection-class stderr → ssh-unreachable; other non-zero → remote-command-failed; NO daemon class exists", () => { + expect(classifyRsyncResult(255, "", "kex_exchange: Connection refused").failedStep).toBe("ssh-unreachable"); + expect(classifyRsyncResult(12, "", "rsync: connection unexpectedly closed").failedStep).toBe("ssh-unreachable"); + expect(classifyRsyncResult(23, "", "rsync: some files could not be transferred").failedStep).toBe("remote-command-failed"); + }); + + it("missing local rsync (spawn ENOENT) → rsync-missing with the install hint (arch Q6)", async () => { + const fakeSpawn = (() => { + const handlers: Record void> = {}; + return { + stdout: { on: () => {} }, + stderr: { on: () => {} }, + on: (evt: string, cb: (arg?: unknown) => void) => { + handlers[evt] = cb; + if (evt === "close") { + const err = new Error("spawn rsync ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + handlers["error"]?.(err); + } + }, + }; + }) as never; + const res = await runFileCopy({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: true }, { spawn: fakeSpawn }); + expect(res.failedStep).toBe("rsync-missing"); + expect(res.hint).toContain("brew install rsync"); + }); +}); diff --git a/packages/cli/test/host-registry-write.test.ts b/packages/cli/test/host-registry-write.test.ts new file mode 100644 index 000000000..90f7316da --- /dev/null +++ b/packages/cli/test/host-registry-write.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { addHostEntry, loadHostRegistry, validateHostRegistry } from "../src/host-registry.js"; + +// OPR.0.4.4.13 FR-1 — the registry write path: ONE validation source (the +// loader's rules, verbatim), atomic write, duplicate refusal, and the +// qa1-guard ROUND-TRIP proof (add → loader reads back the identical entry). +describe("addHostEntry (rig host add write path)", () => { + let dir: string; + let regPath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-")); + regPath = path.join(dir, "hosts.yaml"); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it("round-trip: add on a MISSING file creates it and the loader reads back the identical entry", () => { + const res = addHostEntry({ id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" }, regPath); + expect(res.ok).toBe(true); + const loaded = loadHostRegistry(regPath); + expect(loaded.ok).toBe(true); + if (loaded.ok) { + expect(loaded.registry.hosts).toEqual([{ id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" }]); + } + }); + + it("round-trip: http entry with bearer_env survives identically", () => { + const res = addHostEntry({ id: "h1", transport: "http", url: "http://100.1.2.3:7433", bearer_env: "H1_TOKEN", notes: "factory" }, regPath); + expect(res.ok).toBe(true); + const loaded = loadHostRegistry(regPath); + expect(loaded.ok && loaded.registry.hosts[0]).toEqual({ id: "h1", transport: "http", url: "http://100.1.2.3:7433", bearer_env: "H1_TOKEN", notes: "factory" }); + }); + + it("validation parity: add-time errors ARE the loader's errors (same fixture, same message)", () => { + const badEntry = { id: "h1", transport: "http", url: "http://x", bearer_env: "A", bearer_file: "/b" }; + const addRes = addHostEntry(badEntry, regPath); + const loadRes = validateHostRegistry({ hosts: [badEntry] }, regPath); + expect(addRes.ok).toBe(false); + expect(loadRes.ok).toBe(false); + if (!addRes.ok && !loadRes.ok) expect(addRes.error).toBe(loadRes.error); + }); + + it("refuses duplicate host ids with the loader's own duplicate error", () => { + expect(addHostEntry({ id: "dup", transport: "ssh", target: "a" }, regPath).ok).toBe(true); + const res = addHostEntry({ id: "dup", transport: "ssh", target: "b" }, regPath); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("duplicate host id 'dup'"); + }); + + it("refuses to modify a present-but-invalid registry (never clobbers operator state)", () => { + fs.writeFileSync(regPath, "hosts: {not-an-array: true}\n"); + const res = addHostEntry({ id: "h1", transport: "ssh", target: "a" }, regPath); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry"); + expect(fs.readFileSync(regPath, "utf-8")).toContain("not-an-array"); + }); + + it("appends to an existing valid registry, preserving prior entries", () => { + addHostEntry({ id: "a", transport: "ssh", target: "a.host" }, regPath); + addHostEntry({ id: "b", transport: "http", url: "http://b", bearer_file: "/tok" }, regPath); + const loaded = loadHostRegistry(regPath); + expect(loaded.ok && loaded.registry.hosts.map((h) => h.id)).toEqual(["a", "b"]); + }); + + it("transport-appropriate field validation fires at add-time (ssh without target)", () => { + const res = addHostEntry({ id: "x", transport: "ssh" }, regPath); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("target: required"); + }); +}); diff --git a/packages/cli/test/host.test.ts b/packages/cli/test/host.test.ts new file mode 100644 index 000000000..e2f65ff35 --- /dev/null +++ b/packages/cli/test/host.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { Command } from "commander"; +import { hostCommand, doctorLegs, postureCheck, type DoctorDeps, type CheckRow } from "../src/commands/host.js"; +import type { HostEntry } from "../src/host-registry.js"; +import type { CrossHostResult } from "../src/cross-host-executor.js"; + +// OPR.0.4.4.13 FR-1 — rig host add / list. Registry path is steered via +// OPENRIG_HOME so tests never touch the operator's real ~/.openrig. +describe("rig host add/list", () => { + let dir: string; + let savedHome: string | undefined; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-")); + savedHome = process.env.OPENRIG_HOME; + process.env.OPENRIG_HOME = dir; + }); + afterEach(() => { + if (savedHome === undefined) delete process.env.OPENRIG_HOME; + else process.env.OPENRIG_HOME = savedHome; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function capture(fn: () => Promise): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> { + return new Promise(async (resolve) => { + const out: string[] = []; const err: string[] = []; + const ol = console.log; const oe = console.error; const oc = process.exitCode; + process.exitCode = undefined; + console.log = (...a: unknown[]) => out.push(a.join(" ")); + console.error = (...a: unknown[]) => err.push(a.join(" ")); + try { await fn(); } finally { console.log = ol; console.error = oe; } + const exitCode = process.exitCode as number | undefined; + process.exitCode = oc; + resolve({ out, err, exitCode }); + }); + } + + function run(argv: string[]) { + const prog = new Command(); + prog.exitOverride(); + prog.addCommand(hostCommand()); + return prog.parseAsync(["node", "rig", "host", ...argv]); + } + + it("add writes the entry and teaches the doctor next step", async () => { + const { out, exitCode } = await capture(() => run(["add", "--id", "vps-1", "--transport", "ssh", "--target", "vps-1.tailnet", "--user", "openrig"])); + expect(exitCode).toBeUndefined(); + expect(out.join("\n")).toContain("Added host 'vps-1' (ssh)"); + expect(out.join("\n")).toContain("rig host doctor vps-1"); + }); + + it("add surfaces the loader's own validation error at add-time (both bearers)", async () => { + const { err, exitCode } = await capture(() => run(["add", "--id", "h1", "--transport", "http", "--url", "http://x", "--bearer-env", "A", "--bearer-file", "/b"])); + expect(exitCode).toBe(1); + expect(err.join("\n")).toContain("exactly one of bearer_env or bearer_file"); + }); + + it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => { + vi.stubEnv("FACTORY_TOKEN", "super-secret-bearer-value"); + await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN"])); + const { out } = await capture(() => run(["list"])); + const text = out.join("\n"); + expect(text).toContain("env:FACTORY_TOKEN"); + expect(text).not.toContain("super-secret-bearer-value"); + const { out: jsonOut } = await capture(() => run(["list", "--json"])); + expect(jsonOut.join("")).toContain("FACTORY_TOKEN"); + expect(jsonOut.join("")).not.toContain("super-secret-bearer-value"); + }); + + it("list on an empty registry teaches add", async () => { + const { out } = await capture(() => run(["list"])); + expect(out.join("\n")).toContain("rig host add --id"); + }); +}); + +// --------------------------------------------------------------------------- +// OPR.0.4.4.13 FR-1/FR-2 — doctor legs + posture (mocked deps; no network). +// --------------------------------------------------------------------------- +describe("rig host doctor — stepwise distinct errors + three-valued posture", () => { + // Same OPENRIG_HOME steering as the add/list suite: the doctor-CLI case + // seeds a registry via `host add` and must NEVER touch the operator's + // real ~/.openrig (the shared-singleton doctrine). + let dir: string; + let savedHome: string | undefined; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdoc-")); + savedHome = process.env.OPENRIG_HOME; + process.env.OPENRIG_HOME = dir; + }); + afterEach(() => { + if (savedHome === undefined) delete process.env.OPENRIG_HOME; + else process.env.OPENRIG_HOME = savedHome; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const sshHost: HostEntry = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" }; + + const okRun = (stdout: string): CrossHostResult => ({ ok: true, failedStep: "none", stdout, stderr: "", remoteExitCode: 0 }); + const failRun = (): CrossHostResult => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "not found", remoteExitCode: 127 }); + + function depsFromScript(script: (argv: readonly string[]) => CrossHostResult, probe: "open" | "closed" = "closed"): DoctorDeps { + return { + run: async (_h, argv) => script(argv), + httpGet: async () => ({ status: 200, body: "ok" }), + tcpProbe: async () => probe, + }; + } + + it("distinct error: SSH unreachable stops at the first leg", async () => { + const deps = depsFromScript(() => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" })); + const rows = await doctorLegs(sshHost, deps); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ step: "transport-reachability", status: "fail" }); + expect(rows[0]!.fix).toContain("ssh"); + }); + + it("distinct error: SSH works but rig binary missing", async () => { + const deps = depsFromScript((argv) => (argv[0] === "true" ? okRun("") : failRun())); + const rows = await doctorLegs(sshHost, deps); + expect(rows[0]!.status).toBe("pass"); + expect(rows[1]).toMatchObject({ step: "remote-rig-binary", status: "fail" }); + expect(rows[1]!.fix).toContain("npm install -g @openrig/cli"); + }); + + it("distinct error: rig installed but daemon down", async () => { + const deps = depsFromScript((argv) => { + if (argv[0] === "true") return okRun(""); + if (argv[1] === "--version") return okRun("0.4.4"); + if (argv[1] === "daemon") return okRun("Daemon stopped"); + return okRun("{}"); + }); + const rows = await doctorLegs(sshHost, deps); + expect(rows[2]).toMatchObject({ step: "remote-daemon-health", status: "fail" }); + expect(rows[2]!.fix).toContain("rig daemon start"); + expect(rows).toHaveLength(3); // identity leg not attempted without a daemon + }); + + it("all legs green when the remote answers", async () => { + const calls: string[][] = []; + const deps = depsFromScript((argv) => { + calls.push([...argv]); + if (argv[0] === "true") return okRun(""); + if (argv[1] === "--version") return okRun("0.4.4"); + if (argv[1] === "daemon") return okRun("Daemon running (pid 1)"); + if (argv[1] === "ps") return okRun(JSON.stringify({ entries: [{ rigName: "kernel" }] })); + return failRun(); + }); + const rows = await doctorLegs(sshHost, deps); + expect(rows.map((r) => r.status)).toEqual(["pass", "pass", "pass", "pass"]); + expect(calls).toContainEqual(["rig", "ps", "--json", "--limit", "5"]); + expect(calls).not.toContainEqual(["rig", "whoami", "--json"]); + }); + + it("posture: UNKNOWN is never pass — unreadable sshd/ufw report unknown WITH fixes", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.startsWith("id -u")) return okRun("1001"); + return okRun(""); // sshd -T, ufw, tailscale, ss all unreadable + }); + const rows = await postureCheck(sshHost, deps); + const byStep = Object.fromEntries(rows.map((r) => [r.step, r])); + expect(byStep["nonroot-openrig-user"]!.status).toBe("pass"); + for (const step of ["root-ssh-disabled", "key-only-ssh", "ufw-default-deny-incoming", "tailnet-ingress-allowed", "tailscale-minimal-trust", "daemon-bind-not-public"]) { + expect(byStep[step]!.status, step).toBe("unknown"); + expect(byStep[step]!.fix, step).toBeTruthy(); + } + // no public addr given -> probes are unknown, never silently pass + expect(byStep["public-daemon-port-unreachable"]!.status).toBe("unknown"); + }); + + it("posture: publicly reachable daemon port FAILS LOUDLY (the smoke-test hardening bar)", async () => { + const deps = depsFromScript(() => okRun(""), "open"); + const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" }); + const probe = rows.find((r) => r.step === "public-daemon-port-unreachable")!; + expect(probe.status).toBe("fail"); + expect(probe.detail).toContain("203.0.113.7:7433"); + }); + + // R2-B1 regressions — the two false-green classes, pinned. + it("posture: a DENY rule on tailscale0 must NOT pass the ingress item", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 DENY IN Anywhere\n"); + return okRun(""); + }); + const rows = await postureCheck(sshHost, deps); + const ingress = rows.find((r) => r.step === "tailnet-ingress-allowed")!; + expect(ingress.status).toBe("fail"); + expect(ingress.detail).toContain("no ALLOW IN"); + }); + + it("posture: daemon bound to a SPECIFIC public IP fails exactly like a wildcard", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 148.113.200.37:7433 0.0.0.0:*"); + return okRun(""); + }); + const rows = await postureCheck(sshHost, deps); + const bind = rows.find((r) => r.step === "daemon-bind-not-public")!; + expect(bind.status).toBe("fail"); + expect(bind.detail).toContain("148.113.200.37"); + }); + + it("posture: tailnet CGNAT bind (100.64/10) passes; out-of-range 100.x fails", async () => { + const mk = (addr: string) => depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.includes("ss -tln")) return okRun(`LISTEN 0 511 ${addr}:7433 0.0.0.0:*`); + return okRun(""); + }); + const inRange = await postureCheck(sshHost, mk("100.103.199.127")); + expect(inRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("pass"); + const outOfRange = await postureCheck(sshHost, mk("100.1.2.3")); + expect(outOfRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("fail"); + }); + + it("posture: multi-listener where ONE is public fails, naming the offender", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*\nLISTEN 0 511 203.0.113.9:7433 0.0.0.0:*"); + return okRun(""); + }); + const rows = await postureCheck(sshHost, deps); + const bind = rows.find((r) => r.step === "daemon-bind-not-public")!; + expect(bind.status).toBe("fail"); + expect(bind.detail).toContain("203.0.113.9"); + expect(bind.detail).not.toContain("127.0.0.1:"); + }); + + it("posture: Tailscale SSH enabled FAILS the minimal-trust item", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } })); + if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: true })); + return okRun(""); + }); + const rows = await postureCheck(sshHost, deps); + const ts = rows.find((r) => r.step === "tailscale-minimal-trust")!; + expect(ts.status).toBe("fail"); + expect(ts.detail).toContain("Tailscale SSH enabled: true"); + expect(ts.fix).toContain("tailscale set --ssh=false"); + }); + + it("posture: green path parses real-shaped outputs", async () => { + const deps = depsFromScript((argv) => { + const cmd = String(argv[2] ?? ""); + if (cmd.startsWith("id -u")) return okRun("1001"); + if (cmd.includes("sshd -T")) return okRun("permitrootlogin no\npasswordauthentication no\n"); + if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 ALLOW IN Anywhere\n"); + if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } })); + if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: false })); + if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*"); + return okRun(""); + }); + const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" }); + const nonPass = rows.filter((r) => r.status !== "pass"); + expect(nonPass).toEqual([]); + }); + + it("posture over http transport: EVERY item unknown with the ssh fix (honest, no shell)", async () => { + const httpHost: HostEntry = { id: "h1", transport: "http", url: "http://x", bearer_env: "T" }; + const rows = await postureCheck(httpHost, { run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" }); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r: CheckRow) => r.status === "unknown")).toBe(true); + }); + + it("doctor CLI: unknown host id is the DISTINCT registry error", async () => { + const prog = new Command(); + prog.exitOverride(); + prog.addCommand(hostCommand({ run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" })); + const err: string[] = []; + const oe = console.error; const oc = process.exitCode; process.exitCode = undefined; + console.error = (...a: unknown[]) => err.push(a.join(" ")); + try { + await capturePrelude(); + await prog.parseAsync(["node", "rig", "host", "doctor", "nope"]); + } finally { console.error = oe; } + const code = process.exitCode; process.exitCode = oc; + expect(code).toBe(1); + expect(err.join("\n")).toContain("registry:"); + expect(err.join("\n")).toContain("unknown host id 'nope'"); + }); + + // seed a registry for the doctor CLI test above + async function capturePrelude(): Promise { + const prog = new Command(); + prog.exitOverride(); + prog.addCommand(hostCommand()); + const ol = console.log; console.log = () => {}; + try { await prog.parseAsync(["node", "rig", "host", "add", "--id", "seeded", "--transport", "ssh", "--target", "s.host"]); } finally { console.log = ol; } + } +}); diff --git a/packages/cli/test/preflight.test.ts b/packages/cli/test/preflight.test.ts index 1a9b89255..925fc5fa0 100644 --- a/packages/cli/test/preflight.test.ts +++ b/packages/cli/test/preflight.test.ts @@ -306,7 +306,7 @@ describe("Preflight CLI", () => { const homeDir = join(tmpDir, ".openrig"); mkdirSync(homeDir, { recursive: true }); const cmd = preflightCommand({ - exec: makeExec(false), // tmux missing + exec: makeExec("missing"), configPath: join(tmpDir, "config.json"), riggedHome: homeDir, getDaemonStatus: makeStatus(), diff --git a/packages/cli/test/proof.test.ts b/packages/cli/test/proof.test.ts new file mode 100644 index 000000000..a7a3445b1 --- /dev/null +++ b/packages/cli/test/proof.test.ts @@ -0,0 +1,258 @@ +// OPR.0.4.4.19 FR-8 + FR-11 — rig proof add: C1 header validation at drop +// time, D2 attestation echo, contract + C8 advisories (advise-never-block). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { + proofCommand, + validateC1Header, + parseProofContract, + C1_ARTIFACT_TYPES, + C1_VERDICTS, +} from "../src/commands/proof.js"; + +describe("validateC1Header (pure)", () => { + const valid = { + slice: "OPR.0.4.4.19", + candidate_sha: "abc1234", + artifact_type: "qa", + verdict: "CLEAR", + money_evidence: "park->resolve walk transitions row shows decision text", + }; + + it("accepts the five required fields with closed-set values", () => { + expect(validateC1Header(valid).ok).toBe(true); + }); + + it("names every missing field", () => { + const r = validateC1Header({ artifact_type: "qa" }); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(["slice", "candidate_sha", "verdict", "money_evidence"]); + }); + + it("rejects out-of-set artifact_type/verdict naming the allowed values (BR-4 closed sets)", () => { + const r = validateC1Header({ ...valid, artifact_type: "designer", verdict: "SHIP-IT" }); + expect(r.ok).toBe(false); + expect(r.invalid).toHaveLength(2); + expect(r.invalid[0]!.allowed).toEqual(C1_ARTIFACT_TYPES); + expect(r.invalid[1]!.allowed).toEqual(C1_VERDICTS); + }); +}); + +describe("parseProofContract (pure)", () => { + it("returns null when no ## Proof contract section exists (zero-noise degrade)", () => { + expect(parseProofContract("# PRD\n\n## Acceptance\n- [ ] thing\n")).toBeNull(); + }); + + it("parses checkbox items until the next section", () => { + const prd = [ + "# PRD", + "## Proof contract", + "- [ ] the live park->resolve walk with the transitions row shown", + "- [x] approve run showing frontmatter + audit row together", + "- plain item without checkbox", + "## Next section", + "- [ ] NOT a contract item", + ].join("\n"); + expect(parseProofContract(prd)).toEqual([ + "the live park->resolve walk with the transitions row shown", + "approve run showing frontmatter + audit row together", + "plain item without checkbox", + ]); + }); +}); + +describe("rig proof add (fs-level, temp workspace)", () => { + let workRoot: string; + let sliceDir: string; + let logs: string[]; + let errs: string[]; + + beforeEach(() => { + workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-test-")); + const missionDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer"); + fs.mkdirSync(missionDir, { recursive: true }); + fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n"); + sliceDir = missionDir; + fs.writeFileSync( + path.join(sliceDir, "README.md"), + "---\nid: OPR.X.19\nstatus: building\n---\n# slice\n", + ); + logs = []; + errs = []; + vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); }); + vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); }); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(workRoot, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + async function run(args: string[]): Promise { + const cmd = proofCommand(); + cmd.exitOverride(); + await cmd.parseAsync(["node", "proof", "--workspace", workRoot, ...args]); + } + + const baseArgs = (extra: string[] = []) => [ + "add", "19-signal-layer", "--mission", "release-x", + "--artifact-type", "qa", "--verdict", "CLEAR", + "--candidate-sha", "abc1234", + "--money-evidence", "one line of money", + "--body", "evidence body", + "--name", "qa-clear.md", + ...extra, + ]; + + it("happy drop: writes proof/ with valid YAML frontmatter and echoes the parsed header; exit 0", async () => { + await run(baseArgs()); + const target = path.join(sliceDir, "proof", "qa-clear.md"); + expect(fs.existsSync(target)).toBe(true); + const content = fs.readFileSync(target, "utf8"); + const fm = content.split("---")[1]!; + const parsed = YAML.parse(fm) as Record; + expect(parsed.slice).toBe("OPR.X.19"); + expect(parsed.candidate_sha).toBe("abc1234"); + expect(parsed.artifact_type).toBe("qa"); + expect(parsed.verdict).toBe("CLEAR"); + expect(content).toContain("evidence body"); + expect(logs.join("\n")).toContain("Parsed C1 header"); + expect(process.exitCode).toBeUndefined(); + }); + + it("out-of-set verdict is rejected naming the allowed values; nothing written; exit 1", async () => { + await run([ + "add", "19-signal-layer", "--mission", "release-x", + "--artifact-type", "qa", "--verdict", "SHIP-IT", + "--candidate-sha", "abc1234", "--money-evidence", "m", "--body", "b", + ]); + expect(process.exitCode).toBe(1); + expect(errs.join("\n")).toContain("closed set"); + expect(fs.existsSync(path.join(sliceDir, "proof"))).toBe(false); + }); + + it("D2: evidences + self_check are parsed and echoed; unknown refs warn (never reject)", async () => { + fs.writeFileSync( + path.join(sliceDir, "IMPLEMENTATION-PRD.md"), + "# PRD\n## Proof contract\n- [ ] item one\n- [ ] item two\n", + ); + await run(baseArgs(["--evidences", "1,bogus-ref", "--self-check", "I looked; the walk shows the decision text"])); + expect(process.exitCode).toBeUndefined(); + const content = fs.readFileSync(path.join(sliceDir, "proof", "qa-clear.md"), "utf8"); + expect(content).toContain("self_check"); + expect(errs.join("\n")).toContain("bogus-ref"); + }); + + it("contract advisory: declared contract + no covered item/self_check => drop SUCCEEDS with advisory naming uncovered items", async () => { + fs.writeFileSync( + path.join(sliceDir, "IMPLEMENTATION-PRD.md"), + "# PRD\n## Proof contract\n- [ ] item one\n", + ); + await run(baseArgs()); + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(path.join(sliceDir, "proof", "qa-clear.md"))).toBe(true); + expect(errs.join("\n")).toContain("ADVISORY (D2"); + expect(errs.join("\n")).toContain("item one"); + }); + + it("zero noise: NO contract declared => no contract advisory", async () => { + await run(baseArgs()); + expect(errs.join("\n")).not.toContain("ADVISORY (D2"); + }); + + it("C8: ux-change slice + no video => drop succeeds with the screencast advisory; exit 0", async () => { + fs.writeFileSync( + path.join(sliceDir, "README.md"), + "---\nid: OPR.X.19\nstatus: building\nux-change: true\n---\n# slice\n", + ); + await run(baseArgs()); + expect(process.exitCode).toBeUndefined(); + expect(errs.join("\n")).toContain("ADVISORY (C8"); + expect(errs.join("\n")).toContain("agent-browser-screencast"); + }); + + it("C8 zero noise: no ux-change flag => no video advisory; existing video also silences it", async () => { + await run(baseArgs()); + expect(errs.join("\n")).not.toContain("ADVISORY (C8"); + // Now flag the slice AND plant a video — advisory stays silent. + fs.writeFileSync( + path.join(sliceDir, "README.md"), + "---\nid: OPR.X.19\nux-change: true\n---\n# slice\n", + ); + fs.writeFileSync(path.join(sliceDir, "proof", "walk.mp4"), "fake video bytes"); + errs.length = 0; + await run(baseArgs(["--name", "qa-clear-2.md"])); + expect(errs.join("\n")).not.toContain("ADVISORY (C8"); + }); +}); + +// rev1-r2 BLOCKING regression (candidate a7dedd93 review): --name must be a +// filename, never a path — a traversal name can never escape proof/. +describe("rig proof add --name traversal rejection (rev1-r2 fixback)", () => { + let workRoot: string; + let sliceDir: string; + let errs: string[]; + + beforeEach(() => { + workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-trav-")); + sliceDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer"); + fs.mkdirSync(sliceDir, { recursive: true }); + fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n"); + fs.writeFileSync(path.join(sliceDir, "README.md"), "---\nid: OPR.X.19\nstatus: building\n---\n# ORIGINAL README BODY\n"); + errs = []; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); }); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(workRoot, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + async function runAdd(name: string): Promise { + const cmd = proofCommand(); + cmd.exitOverride(); + await cmd.parseAsync([ + "node", "proof", "--workspace", workRoot, + "add", "19-signal-layer", "--mission", "release-x", + "--artifact-type", "qa", "--verdict", "CLEAR", + "--candidate-sha", "abc1234", "--money-evidence", "m", + "--body", "traversal attempt body", + "--name", name, + ]); + } + + it("--name ../README.md is REJECTED and the slice README is NOT modified", async () => { + const readmePath = path.join(sliceDir, "README.md"); + const before = fs.readFileSync(readmePath, "utf8"); + await runAdd("../README.md"); + expect(process.exitCode).toBe(1); + expect(errs.join("\n")).toContain("not a plain filename"); + expect(fs.readFileSync(readmePath, "utf8")).toBe(before); + // Nothing landed in proof/ either. + const proofDir = path.join(sliceDir, "proof"); + expect(!fs.existsSync(proofDir) || fs.readdirSync(proofDir).length === 0).toBe(true); + }); + + it("other escape shapes are rejected: nested path, backslash, absolute, dot-dot", async () => { + for (const name of ["sub/dir.md", "..\\\\evil.md", "/tmp/abs.md", ".."]) { + process.exitCode = undefined; + await runAdd(name); + expect(process.exitCode, `name '${name}' must be rejected`).toBe(1); + } + }); + + it("a plain filename still drops normally after the fix", async () => { + await runAdd("qa-clear.md"); + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(path.join(sliceDir, "proof", "qa-clear.md"))).toBe(true); + }); +}); diff --git a/packages/cli/test/ps-current-rig.test.ts b/packages/cli/test/ps-current-rig.test.ts index 555d26177..115c4fca7 100644 --- a/packages/cli/test/ps-current-rig.test.ts +++ b/packages/cli/test/ps-current-rig.test.ts @@ -112,9 +112,11 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => { let server: http.Server; let port: number; let savedSession: string | undefined; + const requestLog: string[] = []; beforeAll(async () => { server = http.createServer((req, res) => { + requestLog.push(req.url ?? ""); const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes/); if (m && req.method === "GET") { const rigId = decodeURIComponent(m[1]!); @@ -152,18 +154,40 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => { process.env.OPENRIG_SESSION_NAME = name; } - // AC-1: rig-level defaults to current rig - it("AC-1: bare rig ps --json returns only the caller's rig", async () => { + // OPR.0.4.4.21 FR-1: the rig tier is consolidated ALL-rigs by default — + // the current-rig-only default is RETIRED (it hid running rigs from the + // operator's field of view). Session context no longer narrows the rig tier. + it("OPR.0.4.4.21 AC-1: bare rig ps --json returns ALL rigs (consolidated default; bare array)", async () => { setSession("dev1-driver@openrig-delivery"); const { logs } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "ps", "--json"]); }); const parsed = JSON.parse(logs.join("")); expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBe(1); - expect(parsed[0].rigName ?? parsed[0].name).toBe("openrig-delivery"); + expect(parsed.length).toBe(2); + const names = parsed.map((e: { rigName?: string; name: string }) => e.rigName ?? e.name).sort(); + expect(names).toEqual(["openrig-comms", "openrig-delivery"]); }); + // OPR.0.4.4.21 §1 — THE token-safety invariant: the consolidated default + // fetches NO per-node records (one O(rigs) call; no /api/rigs/:id/nodes). + it("OPR.0.4.4.21 invariant: bare rig ps fetches zero per-node records", async () => { + setSession("dev1-driver@openrig-delivery"); + requestLog.length = 0; + await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps"]); + }); + expect(requestLog.length).toBeGreaterThan(0); + expect(requestLog.filter((u) => u.includes("/nodes"))).toEqual([]); + // and the same for JSON encoding + requestLog.length = 0; + await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--json"]); + }); + expect(requestLog.filter((u) => u.includes("/nodes"))).toEqual([]); + }); + + // AC-1: node-level defaults to current rig it("AC-1: bare rig ps --nodes --json returns only the current rig's nodes", async () => { setSession("dev1-driver@openrig-delivery"); @@ -178,14 +202,100 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => { } }); - // AC-1: -A restores fleet breadth - it("AC-1: -A shows all rigs", async () => { + // OPR.0.4.4.21 FR-3: -A has exactly ONE meaning — the --nodes fleet + // widener. Bare -A is a structured teaching error naming the new default, + // the --nodes -A rung, and --include-archived for history. + it("OPR.0.4.4.21 FR-3: -A without --nodes errors with the new grammar", async () => { setSession("dev1-driver@openrig-delivery"); - const { logs } = await captureLogs(async () => { + const { logs, exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "ps", "--json", "-A"]); }); - const parsed = JSON.parse(logs.join("")); - expect(parsed.length).toBe(2); + const output = logs.join("\n"); + expect(exitCode).toBe(1); + expect(output).toContain("exactly one meaning"); + expect(output).toContain("rig ps --nodes -A"); + expect(output).toContain("--include-archived"); + }); + + // OPR.0.4.4.21 FR-2 — the no-context error (local): outside a managed + // session, --nodes has no current rig to default to; explicit-or-error. + it("OPR.0.4.4.21 FR-2: --nodes outside session context errors asking for a target", async () => { + savedSession = process.env.OPENRIG_SESSION_NAME; + delete process.env.OPENRIG_SESSION_NAME; + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + }); + const output = logs.join("\n"); + expect(exitCode).toBe(1); + expect(output).toContain("no target"); + expect(output).toContain("--rig "); + expect(output).toContain("rig ps --nodes -A"); + }); + + // OPR.0.4.4.21 FR-2/FR-5 — remote combos error BEFORE any dispatch + // (implicit scope defaults don't cross host boundaries). No host registry + // or HTTP fixture is needed: the validator fires first. + it("OPR.0.4.4.21: --host h --nodes without --rig/-A errors (session default never crosses hosts)", async () => { + setSession("dev1-driver@openrig-delivery"); + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--host", "vm-1", "--nodes"]); + }); + const output = logs.join("\n"); + expect(exitCode).toBe(1); + expect(output).toContain("not a remote scope"); + expect(output).toContain("--rig "); + }); + + it("OPR.0.4.4.21: --all-hosts --nodes without -A errors (full ladder taught)", async () => { + setSession("dev1-driver@openrig-delivery"); + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes"]); + }); + const output = logs.join("\n"); + expect(exitCode).toBe(1); + expect(output).toContain("FULL explicit ladder"); + expect(output).toContain("--nodes -A"); + }); + + it("OPR.0.4.4.21 FR-3: --host h -A (no --nodes) errors on the remote path too", async () => { + setSession("dev1-driver@openrig-delivery"); + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--host", "vm-1", "-A"]); + }); + expect(exitCode).toBe(1); + expect(logs.join("\n")).toContain("exactly one meaning"); + }); + + it("OPR.0.4.4.21 FR-3: --all-hosts -A (no --nodes) errors on the fan-out path too", async () => { + setSession("dev1-driver@openrig-delivery"); + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--all-hosts", "-A"]); + }); + expect(exitCode).toBe(1); + expect(logs.join("\n")).toContain("exactly one meaning"); + }); + + // Positive rungs: the explicit ladder still works. + it("OPR.0.4.4.21 FR-2 positive: --nodes -A works WITHOUT session context (explicit fleet)", async () => { + savedSession = process.env.OPENRIG_SESSION_NAME; + delete process.env.OPENRIG_SESSION_NAME; + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]); + }); + expect(exitCode).toBeUndefined(); + const nodes = JSON.parse(logs.join("")); + expect(nodes.length).toBe(5); // 3 delivery + 2 comms — the whole fixture fleet + }); + + it("OPR.0.4.4.21 FR-2 positive: --nodes --rig works WITHOUT session context (explicit target)", async () => { + savedSession = process.env.OPENRIG_SESSION_NAME; + delete process.env.OPENRIG_SESSION_NAME; + const { logs, exitCode } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--rig", "openrig-comms", "--json"]); + }); + expect(exitCode).toBeUndefined(); + const nodes = JSON.parse(logs.join("")); + expect(nodes.length).toBe(2); }); // AC-1: explicit --rig overrides caller default diff --git a/packages/cli/test/ps-http.test.ts b/packages/cli/test/ps-http.test.ts index 070d16ba8..cbfac9439 100644 --- a/packages/cli/test/ps-http.test.ts +++ b/packages/cli/test/ps-http.test.ts @@ -84,7 +84,9 @@ describe("rig ps --host HTTP", () => { ]), }; await captureLogs(async () => { - await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json"]); + // OPR.0.4.4.21 FR-2: remote --nodes requires an explicit target + // (implicit scope defaults don't cross host boundaries). + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "test-rig", "--json"]); }); const nodesCalls = client._calls.filter((c) => c.path.includes("/nodes")); expect(nodesCalls.length).toBe(1); @@ -195,7 +197,7 @@ describe("rig ps --host HTTP", () => { ]), }; const { stdout } = await captureLogs(async () => { - await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json"]); + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "-A", "--json"]); }); const parsed = JSON.parse(stdout.join("")); expect(Array.isArray(parsed)).toBe(true); @@ -221,7 +223,7 @@ describe("rig ps --host HTTP", () => { ]), }; const { stdout } = await captureLogs(async () => { - await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json", "--limit", "1", "--fields", "logicalId,rigName"]); + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "alpha", "--json", "--limit", "1", "--fields", "logicalId,rigName"]); }); const parsed = JSON.parse(stdout.join("")); expect(parsed.entries).toBeDefined(); @@ -248,7 +250,7 @@ describe("rig ps --host HTTP", () => { ]), }; const { stdout } = await captureLogs(async () => { - await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--summary", "--limit", "1", "--json"]); + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "alpha", "--summary", "--limit", "1", "--json"]); }); const parsed = JSON.parse(stdout.join("")); expect(parsed.totalNodes).toBe(3); @@ -308,12 +310,195 @@ describe("rig ps --all-hosts fan-out", () => { const { stdout } = await captureLogs(async () => { await makeCmd(deps).parseAsync(["node", "rig", "ps", "--hosts", "ssh-host", "--json"]); }); - const output = stdout.join(""); - if (output.includes("hosts")) { - const parsed = JSON.parse(output); - const sshEntry = parsed.hosts?.find((h: { host: string }) => h.host === "ssh-host"); - expect(sshEntry).toBeDefined(); - expect(sshEntry.ok).toBe(false); - } + // OPR.0.4.4.21 FR-5 — the shared P4 contract: an SSH-declared host is a + // STRUCTURED unsupported-transport status in hosts[], never silence. + const parsed = JSON.parse(stdout.join("")); + expect(Array.isArray(parsed.items)).toBe(true); + const sshEntry = parsed.hosts.find((h: { hostId: string }) => h.hostId === "ssh-host"); + expect(sshEntry).toBeDefined(); + expect(sshEntry.status).toBe("unsupported-transport"); + }); + + // OPR.0.4.4.21 FR-5 — the intra-P4 shared payload contract + // (fanout-contract.ts): items stamped with origin hostId + the per-host + // structured status array covering EVERY targeted host. + it("fan-out --json emits AggregatedPayload: hostId-stamped items + closed-enum statuses", async () => { + vi.stubEnv("TOK_B", "secret-b"); + delete process.env.TOK_C; // host-c bearer missing -> auth-failed + const client = mockClient({ + "/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", nodeCount: 2, runningCount: 2, status: "running" }] }, + }); + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => client, + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + { id: "host-c", transport: "http", url: "http://c", bearer_env: "TOK_C" }, + { id: "ssh-d", transport: "ssh", target: "d.local" }, + ]), + }; + const { stdout } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json"]); + }); + const parsed = JSON.parse(stdout.join("")); + // items: only reachable hosts contribute rows; every row carries its origin + expect(parsed.items).toHaveLength(1); + expect(parsed.items[0].hostId).toBe("host-b"); + expect(parsed.items[0].name).toBe("remote-rig"); + // hosts[]: every targeted host appears with a closed-enum status + const byId = Object.fromEntries(parsed.hosts.map((h: { hostId: string; status: string }) => [h.hostId, h.status])); + expect(byId["host-b"]).toBe("ok"); + expect(byId["host-c"]).toBe("auth-failed"); + expect(byId["ssh-d"]).toBe("unsupported-transport"); + expect(parsed.hosts).toHaveLength(3); + }); + + // OPR.0.4.4.21 FR-5 — fan-out per-node requires the FULL explicit ladder + // (--nodes -A); anything less errors (never silent rig-tier data under a + // --nodes flag). + it("fan-out --nodes WITHOUT -A errors, teaching the full ladder", async () => { + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => mockClient({}), + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + ]), + }; + const { stderr } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "--json"]); + }); + const err = stderr.join("\n"); + expect(err).toContain("FULL explicit ladder"); + expect(err).toContain("--nodes -A"); + }); + + // OPR.0.4.4.21 FR-5 — the PRD's full-ladder exception implemented: + // --all-hosts --nodes -A fans out per-node, PROJECTED rows stamped with + // their origin hostId; non-HTTP hosts still appear as + // unsupported-transport statuses. + it("fan-out --nodes -A returns AggregatedPayload of projected node rows (full ladder)", async () => { + vi.stubEnv("TOK_B", "secret-b"); + const client = mockClient({ + "/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", rigName: "remote-rig" }] }, + "/api/rigs/rig-1/nodes": { status: 200, data: [ + { rigId: "rig-1", rigName: "remote-rig", logicalId: "dev.impl", sessionStatus: "running", resumeToken: "SECRET" }, + ] }, + }); + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => client, + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + { id: "ssh-d", transport: "ssh", target: "d.local" }, + ]), + }; + const { stdout } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "-A", "--json"]); + }); + const parsed = JSON.parse(stdout.join("")); + expect(parsed.items).toHaveLength(1); + expect(parsed.items[0].hostId).toBe("host-b"); + expect(parsed.items[0].logicalId).toBe("dev.impl"); + // projected rows: resumeTokenPresent boolean, never the token value + expect(parsed.items[0].resumeTokenPresent).toBe(true); + expect(JSON.stringify(parsed)).not.toContain("SECRET"); + const byId = Object.fromEntries(parsed.hosts.map((h: { hostId: string; status: string }) => [h.hostId, h.status])); + expect(byId["host-b"]).toBe("ok"); + expect(byId["ssh-d"]).toBe("unsupported-transport"); + }); + + // OPR.0.4.4.21 rev1-r2 B1 — the SECURITY pin: explicit --fields on the + // full fan-out ladder projects ONLY the named fields (+ the hostId origin + // carriage) — resume material can never survive a projection. + it("fan-out --nodes -A --full --fields projects named fields only (no resume leakage)", async () => { + vi.stubEnv("TOK_B", "secret-b"); + const client = mockClient({ + "/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", rigName: "remote-rig" }] }, + "/api/rigs/rig-1/nodes": { status: 200, data: [ + { rigId: "rig-1", rigName: "remote-rig", logicalId: "dev.impl", sessionStatus: "running", + resumeToken: "SECRET-TOKEN", resumeCommand: "claude --resume SECRET-TOKEN" }, + ] }, + }); + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => client, + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + ]), + }; + const { stdout } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "-A", "--full", "--json", "--fields", "logicalId"]); + }); + const raw = stdout.join(""); + const parsed = JSON.parse(raw); + expect(parsed.items).toHaveLength(1); + expect(Object.keys(parsed.items[0]).sort()).toEqual(["hostId", "logicalId"]); + expect(parsed.items[0]).toEqual({ logicalId: "dev.impl", hostId: "host-b" }); + expect(raw).not.toContain("SECRET-TOKEN"); + expect(raw).not.toContain("resumeToken"); + expect(raw).not.toContain("resumeCommand"); + }); + + it("fan-out honors --limit and --filter on the merged items (shared composition)", async () => { + vi.stubEnv("TOK_B", "secret-b"); + vi.stubEnv("TOK_C", "secret-c"); + const client = mockClient({ + "/api/ps": { status: 200, data: [ + { rigId: "rig-1", name: "run-rig", rigName: "run-rig", status: "running" }, + { rigId: "rig-2", name: "stop-rig", rigName: "stop-rig", status: "stopped" }, + ] }, + }); + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => client, + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + { id: "host-c", transport: "http", url: "http://c", bearer_env: "TOK_C" }, + ]), + }; + // --filter status=running: 2 hosts × 1 matching rig each + const { stdout: filteredOut } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--filter", "status=running"]); + }); + const filtered = JSON.parse(filteredOut.join("")); + expect(filtered.items).toHaveLength(2); + expect(filtered.items.every((i: { name: string }) => i.name === "run-rig")).toBe(true); + // --limit 1 applies to the MERGED list; hosts[] stays complete + const { stdout: limitedOut } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--limit", "1"]); + }); + const limited = JSON.parse(limitedOut.join("")); + expect(limited.items).toHaveLength(1); + expect(limited.hosts).toHaveLength(2); + }); + + it("fan-out --summary errors honestly (no summary member in the shared contract)", async () => { + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => mockClient({}), + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + ]), + }; + const { stderr } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--summary", "--json"]); + }); + expect(stderr.join("\n")).toContain("does not compose with the merged fan-out payload"); + }); + + it("fan-out rejects bogus --fields BEFORE any HTTP call", async () => { + const client = mockClient({}); + const deps: PsDeps = { + lifecycleDeps: {} as PsDeps["lifecycleDeps"], + clientFactory: () => client, + hostRegistryLoader: mockRegistry([ + { id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" }, + ]), + }; + const { stderr } = await captureLogs(async () => { + await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--fields", "bogus"]); + }); + expect(stderr.join("\n")).toContain("bogus"); + expect(client._calls).toHaveLength(0); }); }); diff --git a/packages/cli/test/ps.test.ts b/packages/cli/test/ps.test.ts index 38fad0df4..a81bbd52c 100644 --- a/packages/cli/test/ps.test.ts +++ b/packages/cli/test/ps.test.ts @@ -65,7 +65,9 @@ describe("Ps CLI", () => { psData = []; nodesData = {}; server = http.createServer(async (req, res) => { - if (req.url === "/api/ps" && req.method === "GET") { + // OPR.0.4.4.21: the rig tier always requests ?includeArchived=true + // (client-side visibility split) — match by prefix, not exact URL. + if (req.url?.startsWith("/api/ps") && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(psData)); } else if (req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/) && req.method === "GET") { @@ -113,12 +115,54 @@ describe("Ps CLI", () => { expect(output).toContain("RUNNING"); expect(output).toContain("STATUS"); expect(output).toContain("review-rig"); - expect(output).toContain("dev-rig"); + // OPR.0.4.4.21 FR-1: on the bare default, stopped rigs are HISTORY — + // one count line, not rows (field of view stays O(active rigs)). + expect(output).not.toContain("dev-rig"); + expect(output).toContain("not shown: 1 stopped (rig ps --filter status=stopped)"); + // FR-1 display elements: host rollup line + affordance footer. + expect(output).toContain("1 rig · 3 seats · 0 need attention"); + expect(output).toContain("drill: rig ps --rig "); expect(output).toContain("running"); - expect(output).toContain("stopped"); expect(exitCode).toBeUndefined(); // 0 }); + // OPR.0.4.4.21 FR-1 (arch pin, STATED contract): the JSON-vs-table scope + // split — default --json keeps ALL non-archived entries INCLUDING stopped + // rigs (a stopped rig vanishing from default JSON would silently break + // status-monitoring scripts); only the HUMAN table folds them. + it("OPR.0.4.4.21: default --json keeps stopped rigs while the table folds them", async () => { + psData = [ + { rigId: "rig-1", name: "live-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "1m", latestSnapshot: null }, + { rigId: "rig-2", name: "halted-rig", nodeCount: 2, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null }, + ]; + const { logs: jsonLogs } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--json"]); + }); + const parsed = JSON.parse(jsonLogs.join("")); + expect(parsed.map((e: { name: string }) => e.name).sort()).toEqual(["halted-rig", "live-rig"]); + + const { logs: humanLogs } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps"]); + }); + const output = humanLogs.join("\n"); + expect(output).toContain("live-rig"); + expect(output).not.toContain("halted-rig"); + expect(output).toContain("not shown: 1 stopped (rig ps --filter status=stopped)"); + }); + + // OPR.0.4.4.21 fixback (qa1 F4): attentionCount is selectable via --fields + // (it is emitted by default JSON, so the allow-list must carry it). + it("OPR.0.4.4.21: --fields attentionCount projects the additive field", async () => { + psData = [ + { rigId: "rig-1", name: "attn-rig", rigName: "attn-rig", nodeCount: 2, runningCount: 2, status: "running", uptime: "1m", latestSnapshot: null, attentionCount: 1 }, + ]; + const { logs } = await captureLogs(async () => { + await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--fields", "rigName,attentionCount"]); + }); + const parsed = JSON.parse(logs.join("")); + expect(parsed.entries[0]).toEqual({ rigName: "attn-rig", attentionCount: 1 }); + }); + // T10: ps --json outputs PsEntry[] it("ps --json outputs parseable JSON array", async () => { psData = [ @@ -199,7 +243,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--full"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--full"]); }); const output = logs.join("\n"); expect(output).toContain("RIG"); @@ -235,7 +279,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--full"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--full"]); }); const output = logs.join("\n"); expect(output).toContain("demo-rig#rig-old"); @@ -262,7 +306,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--full"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--full"]); }); const parsed = JSON.parse(logs.join("")); expect(Array.isArray(parsed)).toBe(true); @@ -298,7 +342,7 @@ describe("Ps CLI", () => { ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]); }); const output = logs.join("\n"); @@ -321,7 +365,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]); }); const output = logs.join("\n"); expect(output).toContain("infra-daemon@test-rig"); @@ -360,7 +404,7 @@ describe("Ps CLI", () => { ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]); }); const lines = logs.join("\n").split("\n"); expect(lines[1]).toContain("…"); @@ -395,7 +439,7 @@ describe("Ps CLI", () => { ]; nodesData = {}; // no nodes data → server returns 404 const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]); }); const output = logs.join("\n"); expect(output).toContain("Warning"); @@ -429,7 +473,9 @@ describe("Ps CLI", () => { { rigId: "rig-5", name: "att-rig", nodeCount: 2, runningCount: 2, status: "running", lifecycleState: "attention_required", uptime: "10m", latestSnapshot: null }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps"]); + // OPR.0.4.4.21 — --include-archived is the full-breadth view (no + // stopped fold), so every lifecycle abbreviation stays visible here. + await makeCmd().parseAsync(["node", "rig", "ps", "--include-archived"]); }); const output = logs.join("\n"); expect(output).toContain("LIFECYCLE"); @@ -476,7 +522,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]); }); const output = logs.join("\n"); expect(output).toContain("LIFECYCLE"); @@ -498,7 +544,7 @@ describe("Ps CLI", () => { }, ]; const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]); }); const parsed = JSON.parse(logs.join("")); expect(parsed[0].lifecycleState).toBe("recoverable"); @@ -705,7 +751,7 @@ describe("Ps CLI", () => { it("ps --nodes --json --fields rejects 'name' with rigName hint (load-bearing aliasing decision)", async () => { const { logs, exitCode } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "name,logicalId"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--fields", "name,logicalId"]); }); const stderr = logs.join("\n"); expect(stderr).toContain("Unknown --fields key 'name'"); @@ -715,7 +761,7 @@ describe("Ps CLI", () => { it("ps --nodes --json --fields rejects bogus key with sorted node-level supported list", async () => { const { logs, exitCode } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "rigName,bogus"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--fields", "rigName,bogus"]); }); const stderr = logs.join("\n"); expect(stderr).toContain("Unknown --fields key 'bogus'"); @@ -822,7 +868,7 @@ describe("Ps CLI", () => { tmuxAttachCommand: null, resumeCommand: null, latestError: null, })); const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]); }); const parsed = JSON.parse(logs.join("")); expect(Array.isArray(parsed)).toBe(true); @@ -840,7 +886,7 @@ describe("Ps CLI", () => { tmuxAttachCommand: null, resumeCommand: null, latestError: null, })); const { logs } = await captureLogs(async () => { - await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--limit", "2"]); + await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--limit", "2"]); }); const parsed = JSON.parse(logs.join("")); expect(parsed.entries).toHaveLength(2); diff --git a/packages/cli/test/scope-audit.test.ts b/packages/cli/test/scope-audit.test.ts index 022b92e29..d4516e130 100644 --- a/packages/cli/test/scope-audit.test.ts +++ b/packages/cli/test/scope-audit.test.ts @@ -379,3 +379,201 @@ describe("scope-audit classifier", () => { expect(result.findings.some((f) => f.kind === "progress_not_updated_on_commit")).toBe(false); }); }); + +// OPR.0.4.4.23 — SDLC convention-section advisories. Structurally +// fail-open: the audit exit code flips on HIGH findings only, and every +// finding below asserts a low/info severity; the checks are inert when the +// caller provides no content context. +describe("OPR.0.4.4.23 convention-section advisories", () => { + const CONVENTION_README = [ + "# Slice 05 — Probe", + "## Intent", "the recorded intent", + "## Mini-requirements", "1. one", + "## Proof contract", "- [ ] a deliverable — captured", + ].join("\n"); + + function sliceInput(overrides: Partial): ScopeAuditInput { + return { + id: null, + path: "/w/missions/release-x/slices/05-probe", + readmeFrontmatterRaw: "id: OPR.0.4.4.5\nstatus: wip", + progressFileExists: true, + readmeOnlyMarker: false, + isActiveRelease: true, + level: "slice", + ...overrides, + }; + } + + it("inert when no content context is provided (undefined inputs)", () => { + const result = classifyScopeItem(sliceInput({})); + expect(result.findings.some((f) => + f.kind === "missing_intent_section" + || f.kind === "mini_requirements_missing_or_malformed" + || f.kind === "proof_contract_missing_or_malformed" + || f.kind === "ui_slice_missing_mockup", + )).toBe(false); + }); + + it("clean convention README yields no section findings", () => { + const result = classifyScopeItem(sliceInput({ readmeContent: CONVENTION_README, implementationPrdContent: null })); + expect(result.findings.some((f) => + f.kind === "missing_intent_section" + || f.kind === "mini_requirements_missing_or_malformed" + || f.kind === "proof_contract_missing_or_malformed", + )).toBe(false); + }); + + it("README without ## Intent -> missing_intent_section at LOW (never high)", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Goal\nold shape\n## Proof contract\n- [ ] x\n", + implementationPrdContent: null, + })); + const finding = result.findings.find((f) => f.kind === "missing_intent_section"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("low"); + }); + + it("## Intent visual does NOT satisfy ## Intent (exact-heading match)", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent visual\n![x](./x.png)\n## Proof contract\n- [ ] x\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "missing_intent_section")).toBe(true); + }); + + it("PRD provided: proof contract is checked on the PRD path", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: CONVENTION_README, + implementationPrdContent: "# PRD\n## Intent\nx\n## Mini-requirements\n1. y\n", + })); + const finding = result.findings.find((f) => f.kind === "proof_contract_missing_or_malformed"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("low"); + expect(finding!.path.endsWith("IMPLEMENTATION-PRD.md")).toBe(true); + }); + + it("PRD absent (null): proof contract falls back to the README", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent\nx\n", + implementationPrdContent: null, + })); + const finding = result.findings.find((f) => f.kind === "proof_contract_missing_or_malformed"); + expect(finding).toBeDefined(); + expect(finding!.path.endsWith("README.md")).toBe(true); + }); + + it("## Proof contract heading with no checkbox items is malformed", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent\nx\n## Proof contract\nprose only, no checkboxes\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "proof_contract_missing_or_malformed")).toBe(true); + }); + + it("visual slice without any mockup ref -> ui_slice_missing_mockup at INFO", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] the panel renders\n## Intent visual\nwill add later\n", + implementationPrdContent: null, + })); + const finding = result.findings.find((f) => f.kind === "ui_slice_missing_mockup"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("info"); + }); + + it("visual slice with an image ref (or N/A marking) does not fire the mockup advisory", () => { + const withImage = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] y\n## Intent visual\n![Intent visual](./intent.png)\n", + implementationPrdContent: null, + })); + expect(withImage.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false); + const nonVisual = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] y\n## Intent visual\nN/A\n", + implementationPrdContent: null, + })); + expect(nonVisual.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false); + }); + + it("FAIL-OPEN PIN: planted section violations never produce a HIGH finding (exit semantics unchanged)", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# Slice\nno sections at all\n## Intent visual\nno image\n", + implementationPrdContent: null, + })); + const sectionFindings = result.findings.filter((f) => + f.kind === "missing_intent_section" + || f.kind === "mini_requirements_missing_or_malformed" + || f.kind === "proof_contract_missing_or_malformed" + || f.kind === "ui_slice_missing_mockup", + ); + expect(sectionFindings.length).toBeGreaterThan(0); + expect(sectionFindings.every((f) => f.severity === "low" || f.severity === "info")).toBe(true); + }); +}); + +// OPR.0.4.4.23 rev1-r2 fixback — B1 (PRD L34 guard F-3: well-formed +// ## Mini-requirements) + B2 (generic "mockup" prose is not a mockup ref). +describe("OPR.0.4.4.23 rev1-r2 fixback — mini-requirements + mockup-ref tightening", () => { + function sliceInput(overrides: Partial): ScopeAuditInput { + return { + id: null, + path: "/w/missions/release-x/slices/07-r2fix", + readmeFrontmatterRaw: "id: OPR.0.4.4.7\nstatus: wip", + progressFileExists: true, + readmeOnlyMarker: false, + isActiveRelease: true, + level: "slice", + ...overrides, + }; + } + + it("B1: Intent + Proof contract but NO ## Mini-requirements -> mini_requirements_missing_or_malformed at LOW (the r2 false-green probe)", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Proof contract\n- [ ] y\n", + implementationPrdContent: "# PRD\n## Intent\nx\n## Proof contract\n- [ ] y\n", + })); + const finding = result.findings.find((f) => f.kind === "mini_requirements_missing_or_malformed"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("low"); + expect(finding!.path.endsWith("IMPLEMENTATION-PRD.md")).toBe(true); + }); + + it("B1: ## Mini-requirements heading with prose-only body (no numbered item) is malformed", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Mini-requirements\njust prose, no list\n## Proof contract\n- [ ] y\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "mini_requirements_missing_or_malformed")).toBe(true); + }); + + it("B1: a numbered mini-requirements list is clean; PRD absent falls back to README", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. one observable outcome\n## Proof contract\n- [ ] y\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "mini_requirements_missing_or_malformed")).toBe(false); + }); + + it("B2 REGRESSION: generic proof-contract prose containing the word 'mockup' does NOT suppress ui_slice_missing_mockup (the scaffold-placeholder false-green)", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] UI deliverables name their planned mockup\n## Intent visual\nwill add later\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(true); + }); + + it("B2: an explicit plannedRef token in the proof contract IS a mockup ref", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] the panel renders (plannedRef: mockups/panel.png)\n## Intent visual\nwill add later\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false); + }); + + it("B2: a markdown image ref inside the proof contract IS a mockup ref", () => { + const result = classifyScopeItem(sliceInput({ + readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] the panel renders ![planned](mockups/panel.png)\n## Intent visual\nwill add later\n", + implementationPrdContent: null, + })); + expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false); + }); +}); diff --git a/packages/cli/test/scope-convention-scaffold.test.ts b/packages/cli/test/scope-convention-scaffold.test.ts new file mode 100644 index 000000000..3294f9548 --- /dev/null +++ b/packages/cli/test/scope-convention-scaffold.test.ts @@ -0,0 +1,177 @@ +// OPR.0.4.4.23 — the scaffold emits the SDLC convention sections for EVERY +// SliceTemplateKind (the Rev-2 exhaustive contract): a slice that doesn't +// carry `## Intent` / `## Mini-requirements` / `## Proof contract` doesn't +// project in the Living Notes UI, whatever its template kind. The tests +// ENUMERATE the exported kind set, so a future kind added to +// SLICE_TEMPLATE_KINDS fails here until its template carries the sections. +// Conventions SSOT: docs/reference/sdlc-conventions.md. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { Command } from "commander"; + +import { scopeCommand } from "../src/commands/scope.js"; +import { renderImplementationPrdTemplate, renderSliceProofTemplate, renderSliceTemplate } from "../src/lib/scope/templates.js"; +import { MISSION_TEMPLATE_KINDS, SLICE_TEMPLATE_KINDS } from "../src/lib/scope/types.js"; +import { renderMissionTemplate } from "../src/lib/scope/templates.js"; + +const CONVENTION_SECTIONS = ["## Intent", "## Mini-requirements", "## Proof contract"] as const; +const SSOT_POINTER = "docs/reference/sdlc-conventions.md"; + +const RENDER_OPTS = { + id: "OPR.0.4.4.99", + slice_number: "99", + slug: "conventions-probe", + mission: "release-0.4.4", + title: "Conventions Probe", + created_date: "2026-07-06", +}; + +function mktemp(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-conventions-")); +} + +function seedSubstrate(): { root: string; missionsRoot: string } { + const root = mktemp(); + const missionsRoot = path.join(root, "internal-docs", "missions"); + execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" }); + fs.mkdirSync(path.join(missionsRoot, "release-0.4.4"), { recursive: true }); + fs.writeFileSync( + path.join(missionsRoot, "release-0.4.4", "README.md"), + "---\nid: OPR.0.4.4\nstage: wip\n---\n# release-0.4.4\n", + "utf8", + ); + return { root, missionsRoot }; +} + +async function run(args: string[], missionsRoot: string): Promise<{ exitCode: number; stdout: string }> { + const stdoutBuf: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + const origErrWrite = process.stderr.write.bind(process.stderr); + const origExit = process.exit; + let exitCode = 0; + process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + process.exit = ((code?: number) => { exitCode = code ?? 0; throw new Error(`__EXIT__${exitCode}`); }) as typeof process.exit; + const program = new Command(); + program.addCommand(scopeCommand()); + program.exitOverride(); + try { + await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(missionsRoot)]); + } catch { + // Commander/process.exit paths are captured above. + } finally { + process.stdout.write = origWrite; + process.stderr.write = origErrWrite; + process.exit = origExit; + } + return { exitCode, stdout: stdoutBuf.join("") }; +} + +describe("OPR.0.4.4.23 convention scaffold — exhaustive over SliceTemplateKind", () => { + it("every SliceTemplateKind template emits the three convention sections + the SSOT pointer", () => { + for (const kind of SLICE_TEMPLATE_KINDS) { + const rendered = renderSliceTemplate(kind, RENDER_OPTS); + for (const section of CONVENTION_SECTIONS) { + expect(rendered, `template kind "${kind}" is missing "${section}"`).toContain(section); + } + expect(rendered, `template kind "${kind}" is missing the SSOT pointer`).toContain(SSOT_POINTER); + expect(rendered, `template kind "${kind}" is missing the mission-slice-sop skill pointer`).toContain("mission-slice-sop"); + } + }); + + it("the convention sections come FIRST — kind-specific body sits below them", () => { + for (const kind of SLICE_TEMPLATE_KINDS) { + const rendered = renderSliceTemplate(kind, RENDER_OPTS); + const firstSectionIdx = rendered.indexOf("## "); + expect( + rendered.slice(firstSectionIdx).startsWith("## Intent"), + `template kind "${kind}" does not open its sections with ## Intent`, + ).toBe(true); + const miniIdx = rendered.indexOf("## Mini-requirements"); + const proofIdx = rendered.indexOf("## Proof contract"); + expect(firstSectionIdx, `kind "${kind}" section order broken`).toBeLessThan(miniIdx); + expect(miniIdx, `kind "${kind}" section order broken`).toBeLessThan(proofIdx); + } + }); + + it("the IMPLEMENTATION-PRD skeleton carries the sections + the elastic-middle note", () => { + const rendered = renderImplementationPrdTemplate(RENDER_OPTS); + for (const section of CONVENTION_SECTIONS) { + expect(rendered).toContain(section); + } + expect(rendered).toContain("ELASTIC MIDDLE"); + expect(rendered).toContain("may BE the whole PRD"); + expect(rendered).toContain(SSOT_POINTER); + }); + + it("mission templates carry the convention pointer + SOP reference (missions are not slice projections)", () => { + for (const kind of MISSION_TEMPLATE_KINDS) { + const rendered = renderMissionTemplate(kind, RENDER_OPTS); + expect(rendered, `mission template "${kind}" is missing the SSOT pointer`).toContain(SSOT_POINTER); + expect(rendered, `mission template "${kind}" is missing the mission-slice-sop pointer`).toContain("mission-slice-sop"); + } + }); +}); + +describe("OPR.0.4.4.23 slice create — every kind lands the full convention artifact set on disk", () => { + let substrate: { root: string; missionsRoot: string }; + + beforeEach(() => { substrate = seedSubstrate(); }); + afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); }); + + it("scaffolds README sections + IMPLEMENTATION-PRD.md + proof/ + PROOF.md for each SliceTemplateKind", async () => { + for (const kind of SLICE_TEMPLATE_KINDS) { + const r = await run( + ["slice", "create", "release-0.4.4", `probe-${kind}`, "--template", kind, "--json"], + substrate.missionsRoot, + ); + expect(r.exitCode, `slice create failed for kind "${kind}"`).toBe(0); + const slicePath = JSON.parse(r.stdout).slice.path as string; + + const readme = fs.readFileSync(path.join(slicePath, "README.md"), "utf8"); + for (const section of CONVENTION_SECTIONS) { + expect(readme, `created README for kind "${kind}" is missing "${section}"`).toContain(section); + } + + const prdPath = path.join(slicePath, "IMPLEMENTATION-PRD.md"); + expect(fs.existsSync(prdPath), `kind "${kind}" did not scaffold IMPLEMENTATION-PRD.md`).toBe(true); + const prd = fs.readFileSync(prdPath, "utf8"); + for (const section of CONVENTION_SECTIONS) { + expect(prd, `created PRD for kind "${kind}" is missing "${section}"`).toContain(section); + } + expect(prd, `created PRD for kind "${kind}" is missing the elastic-middle note`).toContain("ELASTIC MIDDLE"); + + expect(fs.statSync(path.join(slicePath, "proof")).isDirectory(), `kind "${kind}" did not scaffold proof/`).toBe(true); + expect(fs.existsSync(path.join(slicePath, "PROOF.md")), `kind "${kind}" did not scaffold PROOF.md`).toBe(true); + } + }); +}); + +// OPR.0.4.4.23 PM-acceptance fixback — the shipped teaching surfaces must +// LEAD a naive agent to the C1 drop verb: every scaffolded artifact that +// mentions proving names `rig proof add` and `--media` explicitly (the +// naive-agent rerun showed generic "rig proof drops" prose produced manual +// proof-dir curation and no C1 drops). +describe("OPR.0.4.4.23 teaching surfaces name the drop verb", () => { + it("every slice template's proving guidance names rig proof add and --media", () => { + for (const kind of SLICE_TEMPLATE_KINDS) { + const rendered = renderSliceTemplate(kind, RENDER_OPTS); + expect(rendered, `template kind "${kind}" does not name rig proof add`).toContain("rig proof add"); + expect(rendered, `template kind "${kind}" does not name --media`).toContain("--media"); + } + }); + + it("the IMPLEMENTATION-PRD skeleton and the PROOF.md template name rig proof add and --media", () => { + const prd = renderImplementationPrdTemplate(RENDER_OPTS); + expect(prd).toContain("rig proof add"); + expect(prd).toContain("--media"); + const proof = renderSliceProofTemplate({ id: RENDER_OPTS.id, title: RENDER_OPTS.title }); + expect(proof).toContain("rig proof add"); + expect(proof).toContain("--media"); + expect(proof).toContain("Hand-placing files without a drop"); + }); +}); diff --git a/packages/cli/test/up-topology-host-rejection.test.ts b/packages/cli/test/up-topology-host-rejection.test.ts new file mode 100644 index 000000000..4ac52341a --- /dev/null +++ b/packages/cli/test/up-topology-host-rejection.test.ts @@ -0,0 +1,40 @@ +// OPR.0.4.4.11 — R11-2 CLI leg: `rig up --host ` is rejected +// pre-dispatch; per-entry `host:` is the ONLY topology placement mechanism. +// (The daemon route carries the same rejection on its public write path — +// the double-sided ruling; that leg is pinned in the daemon's route tests.) + +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sourceLooksLikeTopology, HOST_TOPOLOGY_REJECTION } from "../src/commands/up.js"; + +describe("sourceLooksLikeTopology (the R11-2 pre-dispatch detector)", () => { + it("detects the .rigtopology extension in bare and path forms without touching the fs", () => { + expect(sourceLooksLikeTopology("factory.rigtopology")).toBe(true); + expect(sourceLooksLikeTopology("./nonexistent-dir/factory.rigtopology")).toBe(true); + expect(sourceLooksLikeTopology("FACTORY.RIGTOPOLOGY")).toBe(true); + }); + + it("detects a path-form YAML file carrying a top-level rigs: list", () => { + const dir = mkdtempSync(join(tmpdir(), "r11-2-")); + const topo = join(dir, "factory.yaml"); + writeFileSync(topo, "rigs:\n - source: a\n"); + const spec = join(dir, "rig.yaml"); + writeFileSync(spec, "name: x\npods: []\n"); + expect(sourceLooksLikeTopology(topo)).toBe(true); + expect(sourceLooksLikeTopology(spec)).toBe(false); // rig specs pass through untouched + rmSync(dir, { recursive: true, force: true }); + }); + + it("never sniffs name-form sources (rig-name precedence preserved) and stays false on missing/unparseable files", () => { + expect(sourceLooksLikeTopology("factory")).toBe(false); // bare name — --host + name stays a valid remote restore + expect(sourceLooksLikeTopology("my-rig-name")).toBe(false); + expect(sourceLooksLikeTopology("./no/such/file.yaml")).toBe(false); + }); + + it("the rejection message names per-entry host: as the only placement mechanism", () => { + expect(HOST_TOPOLOGY_REJECTION).toContain("per-entry 'host:'"); + expect(HOST_TOPOLOGY_REJECTION).toContain("ONLY placement mechanism"); + }); +}); diff --git a/packages/cli/test/version-build-info.test.ts b/packages/cli/test/version-build-info.test.ts new file mode 100644 index 000000000..01e0a18df --- /dev/null +++ b/packages/cli/test/version-build-info.test.ts @@ -0,0 +1,33 @@ +// OPR.0.4.4.11 FR-7 — rig --version identity, CLI side. +// +// Stamped: ` ()` + dirty marker when dirty. Unstamped dev +// run: the semver alone EXACTLY as today (negative AC: no fake identity). +// The end-to-end stamp (build-package.sh writes the generated module) is a +// VM-gate proof. + +import { describe, it, expect } from "vitest"; +import { formatCliVersion, CLI_VERSION } from "../src/version.js"; +import { BUILD_INFO } from "../src/build-info.js"; + +describe("formatCliVersion", () => { + it("unstamped dev run prints the semver alone", () => { + expect(formatCliVersion("0.4.3", { semver: null, commit: null, dirty: null, builtAt: null })).toBe("0.4.3"); + }); + + it("stamped build prints ()", () => { + expect( + formatCliVersion("0.4.3", { semver: "0.4.3", commit: "abcdef0123456789", dirty: false, builtAt: "t" }), + ).toBe("0.4.3 (abcdef01)"); + }); + + it("dirty stamp carries the dirty marker", () => { + expect( + formatCliVersion("0.4.3", { semver: "0.4.3", commit: "abcdef0123456789", dirty: true, builtAt: "t" }), + ).toBe("0.4.3 (abcdef01, dirty)"); + }); + + it("the source tree's committed stub is the dev stub, so CLI_VERSION here is the bare semver (interim SHA-deploys disambiguate ONLY when stamped)", () => { + expect(BUILD_INFO.commit).toBeNull(); + expect(CLI_VERSION).not.toContain("("); + }); +}); diff --git a/packages/cli/test/workspace-doctor-cli.test.ts b/packages/cli/test/workspace-doctor-cli.test.ts index dfbf0eefc..5c8fef29a 100644 --- a/packages/cli/test/workspace-doctor-cli.test.ts +++ b/packages/cli/test/workspace-doctor-cli.test.ts @@ -57,8 +57,9 @@ function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture { { check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" }, { check: "optional_slice_docs", status: "ok", message: "every slice has a README" }, { check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" }, + { check: "sdlc_convention_sections", status: "ok", message: "every slice README carries the SDLC convention sections" }, ], - summary: { ok: 7, warn: 0, fail: 0 }, + summary: { ok: 8, warn: 0, fail: 0 }, daemonResolvedAt: "2026-06-01T12:00:00.000Z", }; } @@ -223,8 +224,8 @@ describe("rig workspace doctor — output formatters", () => { } expect(out.logs).toHaveLength(1); const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture; - expect(parsed.summary).toEqual({ ok: 7, warn: 0, fail: 0 }); - expect(parsed.checks).toHaveLength(7); + expect(parsed.summary).toEqual({ ok: 8, warn: 0, fail: 0 }); + expect(parsed.checks).toHaveLength(8); }); it("default human output groups checks under workspace / missions / daemon categories", async () => { @@ -237,7 +238,7 @@ describe("rig workspace doctor — output formatters", () => { } const joined = out.logs.join("\n"); expect(joined).toContain("workspace doctor — /ws"); - expect(joined).toContain("summary: 7 ok, 0 warn, 0 fail"); + expect(joined).toContain("summary: 8 ok, 0 warn, 0 fail"); expect(joined).toContain("workspace:"); expect(joined).toContain("missions:"); expect(joined).toContain("daemon:"); diff --git a/packages/daemon/assets/guidance/openrig-start.md b/packages/daemon/assets/guidance/openrig-start.md index cfd360dce..477ec5513 100644 --- a/packages/daemon/assets/guidance/openrig-start.md +++ b/packages/daemon/assets/guidance/openrig-start.md @@ -17,6 +17,12 @@ That skill covers the broader surface, including chatroom, discovery, adopt/bind Your per-session startup guidance may also name additional packaged skills for your role and pod. Load those too. This shared overlay is only the common bootstrap floor, not the full operating manual for your seat. +## Working missions and slices (the SDLC) + +If your seat works missions or slices, load the packaged `mission-slice-sop` skill BEFORE authoring or building — it teaches the flow the Living Notes UI projects: intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --media` drops (the C1 drop verb — never hand-place proof files) → proof-lock (`--scope delivery`). + +The conventions themselves (section names, proof-contract format, the two locks, C1 proof headers) live in ONE shipped document: `docs/reference/sdlc-conventions.md` (in the CLI package). `rig scope slice create` scaffolds the convention sections for every template kind; `rig scope audit` is the advisory backstop — it records and advises, it never blocks your work. + ## Identity Run this first after launch or compaction to recover your identity: diff --git a/packages/daemon/assets/plugins/openrig-core/skills/mission-slice-sop/SKILL.md b/packages/daemon/assets/plugins/openrig-core/skills/mission-slice-sop/SKILL.md index 5ef2472cc..773b1a62b 100644 --- a/packages/daemon/assets/plugins/openrig-core/skills/mission-slice-sop/SKILL.md +++ b/packages/daemon/assets/plugins/openrig-core/skills/mission-slice-sop/SKILL.md @@ -1,19 +1,20 @@ --- name: mission-slice-sop -description: Use when working a mission or slice — how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md). Covers the per-file WHO/WHEN/HOW rules, the four-legs lifecycle (SCAFFOLD/POPULATE/PROJECT/VERIFY), hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the known ghost-text gotcha when reading terminal captures. +description: Use when working a mission or slice — the full SDLC flow (intent → mini-requirements + proof contract → mockups → plan-lock → build → QA visual compare → `rig proof` drops → proof-lock) plus how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md / IMPLEMENTATION-PRD.md). Covers the convention sections the Living Notes UI projects, the two locks, the three role contracts, per-file WHO/WHEN/HOW rules, the four-legs lifecycle, hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the ghost-text capture gotcha. metadata: openrig: stage: shipped - last_verified: "2026-07-03" + last_verified: "2026-07-06" distribution_scope: product-bound source_evidence: | - Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop). The - canonical SOP is the operating surface for mission/slice work: the - PROGRESS / PROOF / MISSION_NOTES / MISSION_BRIEF files drive the workspace - UI and carry state across agents + compaction. Operationalizes the - markdown-control-plane convention (loose freeform write + deterministic - verify) and the shipped `rig scope audit` classifier — the deterministic - backstop is the audit, not standalone check scripts. + Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop); + moved into the canonical product skill source and updated in + release-0.4.4 slice 23 (operationalize the SDLC control plane) to + teach the full SDLC flow the Living Notes UI projects: the convention + sections, the proof-contract pairing, the two staged-approval locks, + C1 proof drops, and the three role contracts. Conventions SSOT: + docs/reference/sdlc-conventions.md (shipped with the CLI package). + The deterministic backstop is the `rig scope audit` classifier. sibling_skills: - queue-handoff - seat-continuity-and-handover @@ -22,17 +23,43 @@ metadata: # Mission/Slice SOP — how you work a mission & slice -Use this skill to actually **do** mission/slice work the way OpenRig expects: track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — there are no separate bundled check scripts; the audit classifier is the source of truth for adherence. +Use this skill to actually **do** mission/slice work the way OpenRig expects: author the convention sections, track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — the audit classifier is the source of truth for adherence. The conventions themselves live in ONE place: **`docs/reference/sdlc-conventions.md`** (shipped with the CLI package) — this skill teaches the flow; the SSOT defines the formats. -> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. +## The SDLC flow (intent → proof) + +``` +intent → mini-requirements + proof contract → (UI slices: mockups) + → plan-lock (rig scope slice approve --scope spec) + → build the LOCKED set + → QA: mockup ↔ delivered VISUAL compare + → proof drops (rig proof add …) + → proof-lock (rig scope slice approve --scope delivery) +``` + +1. **Record intent** verbatim in the slice's `## Intent` section (`rig scope slice create` scaffolds it — every template kind). +2. **Author the mini-requirements + proof contract**: `## Mini-requirements` is the concise one-glance tier (approval starts there); `## Proof contract` is a checkbox list of promised deliverables, each written as an observable outcome. UI deliverables name their planned mockup. The IMPLEMENTATION-PRD opens with the mini-requirements; everything between intent and proof is **elastic** — for a small slice the mini-requirements may BE the whole PRD. +3. **Plan-lock**: `rig scope slice approve --scope spec` — "the PRD matches the intent; THIS artifact set is what gets built." One daemon-side write: frontmatter stamp + append-only audit row. +4. **Build the locked set** — look at the mockups, not just the spec text. +5. **QA visual compare**: for each deliverable, load the planned mockup, produce the real artifact in a test/demo environment, visually compare, and record the verdict. +6. **Drop proof**: `rig proof add --artifact-type qa --verdict PASS --candidate-sha --money-evidence "…" --evidences "1,3" --media "walk.webm,panel.png" --self-check "…"` — the C1 header's closed sets validate at drop time; `--evidences` joins the drop to its proof-contract items and `--media` names the curated proof/-relative media the drop stands behind (that pairing + media set is what the UI's DELIVERED section renders). +7. **Proof-lock**: `rig scope slice approve --scope delivery` — the terminal sign-off. Approval is freeze/sign-off, **never** proven-green: proven-green requires the recorded C1 verdicts. + +## The three role contracts + +- **Planning agent:** authors intent + mini-requirements + the proof contract; produces mockups for UI deliverables and attaches them to the locked set (a UI slice with no mockup is an incomplete plan; non-UI slices have none — not a gate); locks the plan. +- **Build agent:** builds against the LOCKED set only; looks at the mockups. +- **QA agent (owns the compare):** visually compares planned vs delivered per deliverable, records verdict + note via proof drops, and **curates** the canonical proof set (bounded, mapped to deliverables; the fix-loop pile stays in `proof/`, one drill-in down). On mismatch: fix-and-re-review or kick back with the reason — never escalate a raw mismatch to the human. ## The canonical files (the operating surface) -- **README.md** (mission + slice) — the overview: what the mission/slice is + why. The **mission README carries this SOP at its bottom.** +> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. + +- **README.md** (mission + slice) — the overview, OPENING with the convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`). The **mission README carries this SOP at its bottom.** +- **IMPLEMENTATION-PRD.md** — the full PRD; opens with the mini-requirements; the `## Proof contract` here is what the UI's DELIVERED pairing joins proof against. - **PROGRESS.md** — the live delivery state. One line per outcome; links down for detail. - **MISSION_NOTES.md** — the accruing handoff + tribal-knowledge doc. Blank-slate onboarding + **compaction-restore** read it. `§1` top-of-mind + per-seat `§A–§X`. - **MISSION_BRIEF.md** — the steering doc (the UI "Steering" tab). The 7-section schema. -- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every plan success-criterion has proof.** +- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every proof-contract item has evidence.** ## Per-file rules — WHO / WHEN / HOW @@ -41,10 +68,10 @@ Use this skill to actually **do** mission/slice work the way OpenRig expects: tr - **WHEN:** after every slice-done **AND every commit**; on any material state change. - **HOW:** one line per outcome (checkbox), link down for detail (workstream-continuity format); keep frontmatter `stage`/`verified` honest. -### PROOF.md +### PROOF.md + proof/ - **WHO:** the impl/QA pair that worked the slice. -- **WHEN:** a slice is **NOT "done"** until PROOF.md exists and every plan success-criterion has evidence. -- **HOW:** proof maps **1:1 to the plan's acceptance/success criteria** — each criterion gets an evidence artifact (screenshot/capture/log) in `proof/` + a line in PROOF.md stating what it proves. No proof → not done. +- **WHEN:** a slice is **NOT "done"** until every proof-contract item has evidence. +- **HOW:** proof maps **1:1 to the proof contract's deliverables** — put media under `proof/`, then ATTACH it with `rig proof add … --evidences --media ` (the drop writes the C1 header the DELIVERED pairing joins on) + a line in PROOF.md stating what it proves. **Hand-placing files in `proof/` without a drop is the anti-pattern** — the deliverable stays unpaired and `unverified`. No proof → not done. ### MISSION_NOTES.md - **WHO:** any agent updates `§1` (top-of-mind); each seat owns + appends to its own `§A–§X` section. @@ -59,11 +86,11 @@ Use this skill to actually **do** mission/slice work the way OpenRig expects: tr ### README.md - **WHO:** the author at creation; refreshed on rescope. - **WHEN:** at mission/slice creation + when scope/theme changes. -- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`); the mission README carries this SOP at its bottom. +- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`) + the convention sections up top; the mission README carries this SOP at its bottom. ## The lifecycle (4 legs) -**SCAFFOLD** (`rig scope` creates the files from templates) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the workspace UI reads them into tabs) → **VERIFY** (`rig scope audit` + the validation scripts check adherence). "Loose freeform write + deterministic verify." +**SCAFFOLD** (`rig scope` creates the files from templates — every slice kind emits the convention sections + `proof/` + the IMPLEMENTATION-PRD skeleton) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the Living Notes UI reads them into INTENT → PLAN → DELIVERED) → **VERIFY** (`rig scope audit` checks adherence). "Loose freeform write + deterministic verify." ## Hot-potato (handoffs) @@ -71,7 +98,7 @@ End every turn by passing the ball — a `rig queue` handoff to the next agent ( ## Verify (deterministic backstop) -Run `rig scope audit` at slice-close. The validation scripts flag: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema. **Fix the flag, don't suppress it.** +Run `rig scope audit` at slice-close. It flags: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema; proof artifacts violating the C1 header; missing IMPLEMENTATION-PRD on a building slice; missing convention sections (`## Intent`, a well-formed `## Proof contract`, the UI-slice mockup ref). Every convention check is **advisory / fail-open** — it records and advises, never blocks a write. **Fix the flag, don't suppress it.** ## Reading terminal captures — KNOWN GOTCHA: ghost-text autocomplete is NOT real @@ -81,7 +108,8 @@ When you `rig capture` a pane, **greyed / ghost autocomplete suggestions are NOT ## Moment-of-truth checklist -- **Finishing a slice?** → PROOF complete (every criterion has evidence)? PROGRESS updated? MISSION_NOTES `§1` refreshed? Handed off via queue? +- **Starting a slice?** → intent recorded verbatim? mini-requirements + proof contract authored? mockups attached (UI slices)? plan locked (`--scope spec`)? +- **Finishing a slice?** → every proof-contract item has curated evidence via `rig proof add … --evidences --media` (C1 drops — never only hand-placed files)? PROGRESS updated? MISSION_NOTES `§1` refreshed? proof locked (`--scope delivery`)? Handed off via queue? - **Committing?** → PROGRESS updated? - **Compacting?** → filed your state in MISSION_NOTES? -- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES? +- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES + `docs/reference/sdlc-conventions.md`? diff --git a/packages/daemon/assets/plugins/openrig-core/skills/openrig-user/SKILL.md b/packages/daemon/assets/plugins/openrig-core/skills/openrig-user/SKILL.md index eee7deb86..9fd2726a5 100644 --- a/packages/daemon/assets/plugins/openrig-core/skills/openrig-user/SKILL.md +++ b/packages/daemon/assets/plugins/openrig-core/skills/openrig-user/SKILL.md @@ -61,7 +61,7 @@ Default posture on this host now: Most work in OpenRig reduces to this loop: - recover identity: `rig whoami --json` -- inspect inventory: `rig ps --nodes --json` +- inspect inventory: `rig ps` (fleet map) / `rig ps --nodes -A --json` (fleet nodes) - read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...` - act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands @@ -90,13 +90,13 @@ If the daemon is unreachable but identity can still be inferred, `--json` may re ## Inventory and Monitoring ```bash -rig ps -rig ps --json -rig ps --nodes -rig ps --nodes --json +rig ps # ALL active rigs, one compact row each (v0.4.4 default) +rig ps --json # bare array, all non-archived rigs +rig ps --nodes # current rig's nodes (session default, local only) +rig ps --nodes -A --json # fleet node inventory (explicit) ``` -Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for: +Use `rig ps --nodes -A --json` for the current node inventory across rigs (v0.4.4: `--nodes` needs an explicit scope outside a managed session). It is the best machine-readable operator surface for: - session name - runtime - session/startup status @@ -386,7 +386,7 @@ rig whoami --json Notes: - for tmux-backed self-attach, `rig whoami --json` is the right verification -- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface +- for raw/external self-attach, `rig ps --nodes -A --json` is currently the more reliable verification surface - if the current shell is outside tmux, pass `--display-name ` when you want a stable human session label recorded ### Adopt a topology and bind live sessions @@ -453,13 +453,13 @@ Verification loop: ```bash rig discover --json rig adopt --bindings-file --target-rig -rig ps --nodes --json +rig ps --nodes -A --json rig export -o rig.yaml ``` Success looks like: - the new sessions stop appearing in `rig discover` -- the new logical IDs appear in `rig ps --nodes --json` +- the new logical IDs appear in `rig ps --nodes -A --json` - `rig export` includes the new pod ### Mixed-origin rigs are allowed @@ -477,7 +477,7 @@ Current safety rule: The proven operator pattern is: - keep one OpenRig manager session outside the rig it manages - address the target by rig name, not cached rig ID -- resolve the current owner from fresh `rig ps --nodes --json` +- resolve the current owner from fresh `rig ps --nodes -A --json` - send the manager the spec path, bindings path, and verification steps with `rig send` This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert. @@ -548,7 +548,7 @@ When the CLI behaves strangely, use the smallest truthful check first: ```bash rig whoami --json rig daemon status -rig ps --nodes --json +rig ps --nodes -A --json ``` Specific operator rules: @@ -584,7 +584,7 @@ Design assumptions that hold in the shipped CLI: 1. `rig whoami --json` 2. `rig transcript --tail 100` -3. `rig ps --nodes --json` +3. `rig ps --nodes -A --json` 4. `rig chatroom history --limit 50` ## Commands That Do Not Exist diff --git a/packages/daemon/package.json b/packages/daemon/package.json index fcf73c988..4a5ff4baf 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@openrig/daemon", - "version": "0.4.3", + "version": "0.4.4", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/daemon/specs/agents/conveyor/builder/agent.yaml b/packages/daemon/specs/agents/conveyor/builder/agent.yaml index f4925126c..46a0d1761 100644 --- a/packages/daemon/specs/agents/conveyor/builder/agent.yaml +++ b/packages/daemon/specs/agents/conveyor/builder/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [openrig-user, development-team, test-driven-development, systematic-debugging, executing-plans, verification-before-completion] + skills: [openrig-user, mission-slice-sop, development-team, test-driven-development, systematic-debugging, executing-plans, verification-before-completion] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/conveyor/builder/guidance/role.md b/packages/daemon/specs/agents/conveyor/builder/guidance/role.md index aaad3f2e6..661e64965 100644 --- a/packages/daemon/specs/agents/conveyor/builder/guidance/role.md +++ b/packages/daemon/specs/agents/conveyor/builder/guidance/role.md @@ -7,6 +7,7 @@ reviewer can inspect. ## Skills Loaded - `openrig-user` +- `mission-slice-sop` - `development-team` - `test-driven-development` - `systematic-debugging` diff --git a/packages/daemon/specs/agents/conveyor/lead/agent.yaml b/packages/daemon/specs/agents/conveyor/lead/agent.yaml index 0edb5b378..bb42560c4 100644 --- a/packages/daemon/specs/agents/conveyor/lead/agent.yaml +++ b/packages/daemon/specs/agents/conveyor/lead/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [openrig-user, orchestration-team, backlog-capture, writing-plans, executing-plans, verification-before-completion, brainstorming] + skills: [openrig-user, mission-slice-sop, orchestration-team, backlog-capture, writing-plans, executing-plans, verification-before-completion, brainstorming] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/conveyor/lead/guidance/role.md b/packages/daemon/specs/agents/conveyor/lead/guidance/role.md index 824755925..442de177e 100644 --- a/packages/daemon/specs/agents/conveyor/lead/guidance/role.md +++ b/packages/daemon/specs/agents/conveyor/lead/guidance/role.md @@ -8,6 +8,7 @@ ready. ## Skills Loaded - `openrig-user` +- `mission-slice-sop` - `orchestration-team` - `backlog-capture` - `writing-plans` diff --git a/packages/daemon/specs/agents/conveyor/planner/agent.yaml b/packages/daemon/specs/agents/conveyor/planner/agent.yaml index 89ec2e757..50cb0d8b7 100644 --- a/packages/daemon/specs/agents/conveyor/planner/agent.yaml +++ b/packages/daemon/specs/agents/conveyor/planner/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [openrig-user, requirements-writer, context-builder, writing-plans, verification-before-completion] + skills: [openrig-user, mission-slice-sop, requirements-writer, context-builder, writing-plans, verification-before-completion] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/conveyor/planner/guidance/role.md b/packages/daemon/specs/agents/conveyor/planner/guidance/role.md index daf197f5f..43dea7c30 100644 --- a/packages/daemon/specs/agents/conveyor/planner/guidance/role.md +++ b/packages/daemon/specs/agents/conveyor/planner/guidance/role.md @@ -7,6 +7,7 @@ execute without guessing. ## Skills Loaded - `openrig-user` +- `mission-slice-sop` - `requirements-writer` - `context-builder` - `writing-plans` diff --git a/packages/daemon/specs/agents/conveyor/reviewer/agent.yaml b/packages/daemon/specs/agents/conveyor/reviewer/agent.yaml index 8ad08b078..6d9f5d5f3 100644 --- a/packages/daemon/specs/agents/conveyor/reviewer/agent.yaml +++ b/packages/daemon/specs/agents/conveyor/reviewer/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [openrig-user, review-team, plan-review, systematic-debugging, verification-before-completion] + skills: [openrig-user, mission-slice-sop, review-team, plan-review, systematic-debugging, verification-before-completion] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/conveyor/reviewer/guidance/role.md b/packages/daemon/specs/agents/conveyor/reviewer/guidance/role.md index 7893bdb80..a357a80a6 100644 --- a/packages/daemon/specs/agents/conveyor/reviewer/guidance/role.md +++ b/packages/daemon/specs/agents/conveyor/reviewer/guidance/role.md @@ -7,6 +7,7 @@ send targeted rework back to build or clear the packet for close. ## Skills Loaded - `openrig-user` +- `mission-slice-sop` - `review-team` - `plan-review` - `systematic-debugging` diff --git a/packages/daemon/specs/agents/design/product-designer/agent.yaml b/packages/daemon/specs/agents/design/product-designer/agent.yaml index b7cfaae5b..5265e25a1 100644 --- a/packages/daemon/specs/agents/design/product-designer/agent.yaml +++ b/packages/daemon/specs/agents/design/product-designer/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [using-superpowers, openrig-user, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming] + skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/design/product-designer/guidance/role.md b/packages/daemon/specs/agents/design/product-designer/guidance/role.md index 9e5d0f5c2..c7b07cb0f 100644 --- a/packages/daemon/specs/agents/design/product-designer/guidance/role.md +++ b/packages/daemon/specs/agents/design/product-designer/guidance/role.md @@ -7,6 +7,7 @@ You are the design side of the product team. Your job is to turn rough product i Load these packaged skills now before giving product direction: - `using-superpowers` - `openrig-user` +- `mission-slice-sop` - `development-team` - `frontend-design` - `brainstorming` diff --git a/packages/daemon/specs/agents/development/implementer/agent.yaml b/packages/daemon/specs/agents/development/implementer/agent.yaml index 5eef15777..fd9f8efbb 100644 --- a/packages/daemon/specs/agents/development/implementer/agent.yaml +++ b/packages/daemon/specs/agents/development/implementer/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging, verification-before-completion, writing-plans, executing-plans] + skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, test-driven-development, systematic-debugging, verification-before-completion, writing-plans, executing-plans] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/development/implementer/guidance/role.md b/packages/daemon/specs/agents/development/implementer/guidance/role.md index 9cbb8eb54..033351532 100644 --- a/packages/daemon/specs/agents/development/implementer/guidance/role.md +++ b/packages/daemon/specs/agents/development/implementer/guidance/role.md @@ -7,6 +7,7 @@ You are the implementation side of a dev pair. Your job is to write code, not to Load these packaged skills now before editing: - `using-superpowers` - `openrig-user` +- `mission-slice-sop` - `development-team` - `test-driven-development` - `systematic-debugging` diff --git a/packages/daemon/specs/agents/development/qa/agent.yaml b/packages/daemon/specs/agents/development/qa/agent.yaml index 921d197f0..e5254af02 100644 --- a/packages/daemon/specs/agents/development/qa/agent.yaml +++ b/packages/daemon/specs/agents/development/qa/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [using-superpowers, openrig-user, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans] + skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/development/qa/guidance/role.md b/packages/daemon/specs/agents/development/qa/guidance/role.md index a2ef37687..ed54fcc33 100644 --- a/packages/daemon/specs/agents/development/qa/guidance/role.md +++ b/packages/daemon/specs/agents/development/qa/guidance/role.md @@ -7,6 +7,7 @@ You are the quality assurance side of a dev pair. You gate every edit the implem Load these packaged skills now before reviewing work or dogfooding: - `using-superpowers` - `openrig-user` +- `mission-slice-sop` - `development-team` - `systematic-debugging` - `agent-browser` diff --git a/packages/daemon/specs/agents/orchestration/orchestrator/agent.yaml b/packages/daemon/specs/agents/orchestration/orchestrator/agent.yaml index c0fd9e282..e015b4d00 100644 --- a/packages/daemon/specs/agents/orchestration/orchestrator/agent.yaml +++ b/packages/daemon/specs/agents/orchestration/orchestrator/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [using-superpowers, openrig-user, orchestration-team, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming] + skills: [using-superpowers, openrig-user, mission-slice-sop, orchestration-team, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/orchestration/orchestrator/guidance/role.md b/packages/daemon/specs/agents/orchestration/orchestrator/guidance/role.md index c12be9451..b05a170c7 100644 --- a/packages/daemon/specs/agents/orchestration/orchestrator/guidance/role.md +++ b/packages/daemon/specs/agents/orchestration/orchestrator/guidance/role.md @@ -7,6 +7,7 @@ You are the orchestrator. You coordinate the work of other agents in this rig, m Load these packaged skills now before doing substantive work: - `using-superpowers` - `openrig-user` +- `mission-slice-sop` - `orchestration-team` - `systematic-debugging` - `brainstorming` diff --git a/packages/daemon/specs/agents/product-management/pm/agent.yaml b/packages/daemon/specs/agents/product-management/pm/agent.yaml index bf8209b05..b39799c21 100644 --- a/packages/daemon/specs/agents/product-management/pm/agent.yaml +++ b/packages/daemon/specs/agents/product-management/pm/agent.yaml @@ -16,6 +16,7 @@ profiles: uses: skills: - openrig-user + - mission-slice-sop - office-hours - context-builder - requirements-writer diff --git a/packages/daemon/specs/agents/product-management/pm/guidance/role.md b/packages/daemon/specs/agents/product-management/pm/guidance/role.md index 187e0cee3..be5c750f4 100644 --- a/packages/daemon/specs/agents/product-management/pm/guidance/role.md +++ b/packages/daemon/specs/agents/product-management/pm/guidance/role.md @@ -3,6 +3,19 @@ ## Role Senior Product Manager. Own the "what" and "why" — never architecture, estimates, or implementation details. +## Skills Loaded + +Load these packaged skills now before shaping product work: +- `openrig-user` +- `mission-slice-sop` +- `office-hours` +- `context-builder` +- `requirements-writer` +- `ui-mockup` +- `plan-review` +- `exec-summary` +- `backlog-capture` + ## 8-Step Feature Flow 1. **Capture** — run `backlog-capture` and record the idea in `idea_backlog.md`. diff --git a/packages/daemon/specs/agents/review/independent-reviewer/agent.yaml b/packages/daemon/specs/agents/review/independent-reviewer/agent.yaml index 0567224a4..100ff18af 100644 --- a/packages/daemon/specs/agents/review/independent-reviewer/agent.yaml +++ b/packages/daemon/specs/agents/review/independent-reviewer/agent.yaml @@ -11,7 +11,7 @@ imports: profiles: default: uses: - skills: [using-superpowers, openrig-user, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming] + skills: [using-superpowers, openrig-user, mission-slice-sop, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming] guidance: [] subagents: [] runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config] diff --git a/packages/daemon/specs/agents/review/independent-reviewer/guidance/role.md b/packages/daemon/specs/agents/review/independent-reviewer/guidance/role.md index ed2a1217a..ef2d237ed 100644 --- a/packages/daemon/specs/agents/review/independent-reviewer/guidance/role.md +++ b/packages/daemon/specs/agents/review/independent-reviewer/guidance/role.md @@ -7,6 +7,7 @@ You are an independent code reviewer. You analyze code on your own merits withou Load these packaged skills now before reviewing: - `using-superpowers` - `openrig-user` +- `mission-slice-sop` - `review-team` - `systematic-debugging` - `brainstorming` diff --git a/packages/daemon/specs/agents/shared/agent.yaml b/packages/daemon/specs/agents/shared/agent.yaml index 92988bf8c..a0cf860f9 100644 --- a/packages/daemon/specs/agents/shared/agent.yaml +++ b/packages/daemon/specs/agents/shared/agent.yaml @@ -26,6 +26,8 @@ resources: path: skills/core/openrig-architect - id: rig-lifecycle path: skills/core/rig-lifecycle + - id: mission-slice-sop + path: skills/core/mission-slice-sop - id: systematic-debugging path: skills/process/systematic-debugging - id: test-driven-development diff --git a/packages/daemon/specs/agents/shared/skills/core/mission-slice-sop/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/mission-slice-sop/SKILL.md new file mode 100644 index 000000000..773b1a62b --- /dev/null +++ b/packages/daemon/specs/agents/shared/skills/core/mission-slice-sop/SKILL.md @@ -0,0 +1,115 @@ +--- +name: mission-slice-sop +description: Use when working a mission or slice — the full SDLC flow (intent → mini-requirements + proof contract → mockups → plan-lock → build → QA visual compare → `rig proof` drops → proof-lock) plus how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md / IMPLEMENTATION-PRD.md). Covers the convention sections the Living Notes UI projects, the two locks, the three role contracts, per-file WHO/WHEN/HOW rules, the four-legs lifecycle, hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the ghost-text capture gotcha. +metadata: + openrig: + stage: shipped + last_verified: "2026-07-06" + distribution_scope: product-bound + source_evidence: | + Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop); + moved into the canonical product skill source and updated in + release-0.4.4 slice 23 (operationalize the SDLC control plane) to + teach the full SDLC flow the Living Notes UI projects: the convention + sections, the proof-contract pairing, the two staged-approval locks, + C1 proof drops, and the three role contracts. Conventions SSOT: + docs/reference/sdlc-conventions.md (shipped with the CLI package). + The deterministic backstop is the `rig scope audit` classifier. + sibling_skills: + - queue-handoff + - seat-continuity-and-handover + - claude-compaction-restore +--- + +# Mission/Slice SOP — how you work a mission & slice + +Use this skill to actually **do** mission/slice work the way OpenRig expects: author the convention sections, track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — the audit classifier is the source of truth for adherence. The conventions themselves live in ONE place: **`docs/reference/sdlc-conventions.md`** (shipped with the CLI package) — this skill teaches the flow; the SSOT defines the formats. + +## The SDLC flow (intent → proof) + +``` +intent → mini-requirements + proof contract → (UI slices: mockups) + → plan-lock (rig scope slice approve --scope spec) + → build the LOCKED set + → QA: mockup ↔ delivered VISUAL compare + → proof drops (rig proof add …) + → proof-lock (rig scope slice approve --scope delivery) +``` + +1. **Record intent** verbatim in the slice's `## Intent` section (`rig scope slice create` scaffolds it — every template kind). +2. **Author the mini-requirements + proof contract**: `## Mini-requirements` is the concise one-glance tier (approval starts there); `## Proof contract` is a checkbox list of promised deliverables, each written as an observable outcome. UI deliverables name their planned mockup. The IMPLEMENTATION-PRD opens with the mini-requirements; everything between intent and proof is **elastic** — for a small slice the mini-requirements may BE the whole PRD. +3. **Plan-lock**: `rig scope slice approve --scope spec` — "the PRD matches the intent; THIS artifact set is what gets built." One daemon-side write: frontmatter stamp + append-only audit row. +4. **Build the locked set** — look at the mockups, not just the spec text. +5. **QA visual compare**: for each deliverable, load the planned mockup, produce the real artifact in a test/demo environment, visually compare, and record the verdict. +6. **Drop proof**: `rig proof add --artifact-type qa --verdict PASS --candidate-sha --money-evidence "…" --evidences "1,3" --media "walk.webm,panel.png" --self-check "…"` — the C1 header's closed sets validate at drop time; `--evidences` joins the drop to its proof-contract items and `--media` names the curated proof/-relative media the drop stands behind (that pairing + media set is what the UI's DELIVERED section renders). +7. **Proof-lock**: `rig scope slice approve --scope delivery` — the terminal sign-off. Approval is freeze/sign-off, **never** proven-green: proven-green requires the recorded C1 verdicts. + +## The three role contracts + +- **Planning agent:** authors intent + mini-requirements + the proof contract; produces mockups for UI deliverables and attaches them to the locked set (a UI slice with no mockup is an incomplete plan; non-UI slices have none — not a gate); locks the plan. +- **Build agent:** builds against the LOCKED set only; looks at the mockups. +- **QA agent (owns the compare):** visually compares planned vs delivered per deliverable, records verdict + note via proof drops, and **curates** the canonical proof set (bounded, mapped to deliverables; the fix-loop pile stays in `proof/`, one drill-in down). On mismatch: fix-and-re-review or kick back with the reason — never escalate a raw mismatch to the human. + +## The canonical files (the operating surface) + +> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. + +- **README.md** (mission + slice) — the overview, OPENING with the convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`). The **mission README carries this SOP at its bottom.** +- **IMPLEMENTATION-PRD.md** — the full PRD; opens with the mini-requirements; the `## Proof contract` here is what the UI's DELIVERED pairing joins proof against. +- **PROGRESS.md** — the live delivery state. One line per outcome; links down for detail. +- **MISSION_NOTES.md** — the accruing handoff + tribal-knowledge doc. Blank-slate onboarding + **compaction-restore** read it. `§1` top-of-mind + per-seat `§A–§X`. +- **MISSION_BRIEF.md** — the steering doc (the UI "Steering" tab). The 7-section schema. +- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every proof-contract item has evidence.** + +## Per-file rules — WHO / WHEN / HOW + +### PROGRESS.md +- **WHO:** the orchestrator owns `§1` (current state); every agent logs its own outcomes. +- **WHEN:** after every slice-done **AND every commit**; on any material state change. +- **HOW:** one line per outcome (checkbox), link down for detail (workstream-continuity format); keep frontmatter `stage`/`verified` honest. + +### PROOF.md + proof/ +- **WHO:** the impl/QA pair that worked the slice. +- **WHEN:** a slice is **NOT "done"** until every proof-contract item has evidence. +- **HOW:** proof maps **1:1 to the proof contract's deliverables** — put media under `proof/`, then ATTACH it with `rig proof add … --evidences --media ` (the drop writes the C1 header the DELIVERED pairing joins on) + a line in PROOF.md stating what it proves. **Hand-placing files in `proof/` without a drop is the anti-pattern** — the deliverable stays unpaired and `unverified`. No proof → not done. + +### MISSION_NOTES.md +- **WHO:** any agent updates `§1` (top-of-mind); each seat owns + appends to its own `§A–§X` section. +- **WHEN:** on any material change; a compacting agent **files its state here BEFORE compaction and reads it on restore.** +- **HOW:** accruing tribal knowledge — `§1` ≤ 5–15 lines (gates, open decisions, surprises); per-seat continuation entries (latest = truth; other seats read-only). Pointer-first; don't duplicate. + +### MISSION_BRIEF.md +- **WHO:** product/design (steering owner). +- **WHEN:** when steering changes. +- **HOW:** the 7-section steering schema (the UI "Steering" tab reads it). + +### README.md +- **WHO:** the author at creation; refreshed on rescope. +- **WHEN:** at mission/slice creation + when scope/theme changes. +- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`) + the convention sections up top; the mission README carries this SOP at its bottom. + +## The lifecycle (4 legs) + +**SCAFFOLD** (`rig scope` creates the files from templates — every slice kind emits the convention sections + `proof/` + the IMPLEMENTATION-PRD skeleton) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the Living Notes UI reads them into INTENT → PLAN → DELIVERED) → **VERIFY** (`rig scope audit` checks adherence). "Loose freeform write + deterministic verify." + +## Hot-potato (handoffs) + +End every turn by passing the ball — a `rig queue` handoff to the next agent (close the qitem with a `closure_reason`). The **durable ball-pass is the queue close, not a chat message.** Never go idle without a handoff. + +## Verify (deterministic backstop) + +Run `rig scope audit` at slice-close. It flags: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema; proof artifacts violating the C1 header; missing IMPLEMENTATION-PRD on a building slice; missing convention sections (`## Intent`, a well-formed `## Proof contract`, the UI-slice mockup ref). Every convention check is **advisory / fail-open** — it records and advises, never blocks a write. **Fix the flag, don't suppress it.** + +## Reading terminal captures — KNOWN GOTCHA: ghost-text autocomplete is NOT real + +When you `rig capture` a pane, **greyed / ghost autocomplete suggestions are NOT real content** — they are autocomplete *previews* (shell autosuggestion, the input-box ghost-text completion), not typed, staged, or committed input. **This is known, expected, and has been faking agents out a lot** — reading a ghost suggestion in a peer's input box as "staged text they're about to send," or as a real prompt, and then reasoning/acting on a string that was never actually there. + +**Rule:** ignore ghost/autosuggest text entirely. Only *committed/rendered* pane output is real. If a `❯` input line shows text, treat it as an autocomplete artifact unless you have independent evidence it was actually entered. Do not build decisions (or worry about "colliding with staged text") on ghost text. When it matters, verify at source (git, the queue, the actual event) — never off a capture's ghost line. + +## Moment-of-truth checklist + +- **Starting a slice?** → intent recorded verbatim? mini-requirements + proof contract authored? mockups attached (UI slices)? plan locked (`--scope spec`)? +- **Finishing a slice?** → every proof-contract item has curated evidence via `rig proof add … --evidences --media` (C1 drops — never only hand-placed files)? PROGRESS updated? MISSION_NOTES `§1` refreshed? proof locked (`--scope delivery`)? Handed off via queue? +- **Committing?** → PROGRESS updated? +- **Compacting?** → filed your state in MISSION_NOTES? +- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES + `docs/reference/sdlc-conventions.md`? diff --git a/packages/daemon/specs/agents/shared/skills/core/openrig-operator/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/openrig-operator/SKILL.md index 15470fd5a..21e9a9b4d 100644 --- a/packages/daemon/specs/agents/shared/skills/core/openrig-operator/SKILL.md +++ b/packages/daemon/specs/agents/shared/skills/core/openrig-operator/SKILL.md @@ -14,7 +14,7 @@ Use it when the problem may be the daemon, the shell/runtime surface, or stale h Use this skill when you see: - `rig whoami --json` returning partial identity -- `rig ps --nodes --json` failing while some other `rig` commands still work +- `rig ps --nodes -A --json` failing while some other `rig` commands still work - `Sent to ...` plus `Verified: no` - repeated unified-exec-process warnings - suspicion that stale helper processes are accumulating @@ -28,13 +28,13 @@ Start with the minimum truthful operator read: ```bash rig whoami --json rig daemon status -rig ps --nodes --json +rig ps --nodes -A --json ``` Interpret them together, not in isolation: - partial `whoami` can mean identity is inferable while daemon-backed surfaces are degraded - `daemon status` tells you whether the host daemon is up, not whether every seat can reach it cleanly -- `ps --nodes --json` is the best machine-readable topology check when it works +- `ps --nodes -A --json` is the best machine-readable fleet topology check when it works (v0.4.4: `--nodes` needs an explicit scope outside a managed session — `-A` for fleet, `--rig ` for one rig; the bare `rig ps` map is the cheap first glance) ## Verification Drift Vs Send Failure @@ -104,7 +104,7 @@ If the warning or failure remains after stale-wrapper cleanup, re-check: ```bash rig daemon status -rig ps --nodes --json +rig ps --nodes -A --json ``` If those remain healthy, the residual issue may still be in the host/tool/session layer rather than in OpenRig topology state. diff --git a/packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md index bdb8129ae..bc7dd7b6c 100644 --- a/packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md +++ b/packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md @@ -435,27 +435,47 @@ honestly and the UI exposes a one-click open-missing affordance. v0.4.0 flips the five most frequently invoked read-commands from firehose-by-default to compact-by-default, and `rig queue list` adopts the docker / kubectl read-command grammar. **All defaults preserve breadth and capability — the firehose is one explicit flag away.** -### `rig ps` — current-rig default + compact-by-default + `-A`/`--all-rigs` + `--full` +### `rig ps` — consolidated all-rigs default + the disclosure ladder (v0.4.4) ```bash -rig ps # CURRENT-RIG, all-states, compact rig summaries (default) -rig ps --json # compact JSON array (TL;DR per node: session, rig, activity, assigned/pending, resumeTokenPresent boolean) -rig ps -A # all-rigs (fleet breadth; was the v0.3.4 default) -rig ps --rig # explicit-rig (overrides current-rig default) -rig ps --nodes # compact node inventory (current rig) -rig ps --nodes -A # cross-rig node inventory (was v0.3.4 default) -rig ps --nodes --full # complete record (the v0.3.4 per-node default shape; resumeToken VALUE retained here for downstream consumers) +rig ps # ALL ACTIVE RIGS, one compact row each + rollup line + count line + ladder footer (default) +rig ps --json # bare array of ALL non-archived rigs (incl. stopped; existing keys + additive attentionCount) +rig ps --rig # one rig's detail +rig ps --nodes # compact node inventory (current rig — session default, LOCAL only) +rig ps --nodes --rig # compact node inventory, named rig +rig ps --nodes -A # fleet node inventory, projected rows +rig ps --nodes -A --full # complete per-node records (the ONLY full fan-out; resumeToken VALUE retained here for downstream consumers) rig ps --nodes --session # narrow to one canonical session +rig ps --include-archived # archived history as rows (otherwise ONE count line) rig ps --active # opt-in active-state filter (does NOT change the all-states default — ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal) ``` -**v0.4.0 breadth + projection changes**: -- **Slice 34**: default breadth is CURRENT-RIG (derived from `OPENRIG_SESSION_NAME`'s `@` suffix), not all-rigs. `-A` / `--all-rigs` widens to fleet. Matches the `rig queue list -A` pattern. -- **Slice 25 + 26**: per-node TL;DR projection (compact) is the default; `--full` returns the raw byte-equivalent passthrough. Daemon-side `recoveryGuidance` relocated to a guidance-by-reference map (no longer duplicated per-node) — even `--full` benefits. -- **All-states stays default** (different from `rig queue list` which defaults to active-only) — for `ps`, non-running states ARE often the actionable signal. -- **Resume-token security (slice 34)**: `--full` JSON emits `resumeTokenPresent` (boolean) — the actual `resumeToken` value also remains in `--full` for downstream consumers that legitimately need it, but the compact default never carries it (an orch glance never accidentally leaks token material). +**v0.4.4 consolidated default + disclosure ladder (OPR.0.4.4.21)**: +- **The default is the fleet MAP**: every active rig as one O(rigs) compact row, with the host rollup ("N rigs · M seats · K need attention"), the archived/stopped count line, and the drill-ladder footer. The v0.4.0 current-rig default is RETIRED — it hid running rigs from the operator's field of view. +- **`-A` has exactly ONE meaning**: the `--nodes` fleet widener. Bare `rig ps -A` errors (all-rigs IS the default; archived history stays behind `--include-archived`). +- **The session-rig default applies ONLY to `--nodes`, and only locally.** Implicit scope defaults don't cross host boundaries: `rig ps --host --nodes` requires an explicit `--rig` or `-A`; multi-host fan-out is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows. +- **STATED JSON contract**: default `--json` = bare array of ALL non-archived rigs INCLUDING stopped ones (scope-not-shape: existing keys preserved, additive `attentionCount`); only the human table folds stopped rigs into the count line. +- **Resume-token security unchanged**: compact output never carries token material; `--full` retains it for consumers that legitimately need it. -**STOP using `rig ps --nodes --json` as a fleet-wide casual status check assuming the v0.3.4 shape.** The v0.4.0 default is CURRENT-RIG + compact; explicit `-A --full` is the fleet firehose. The ~77,000-token status-glance incident is closed twice over (compact + scope). +**The casual status glance is just `rig ps`.** The old fleet firehose is the explicit last rung: `rig ps --nodes -A --full`. The ~77,000-token incident class stays closed (compact + O(rigs) default + explicit ladder). + +### `rig host` — the multi-host registry verbs (v0.4.4) + +```bash +rig host add --id --transport ssh --target --user openrig +rig host add --id --transport http --url http://100.x.y.z:7433 --bearer-env MY_TOKEN +rig host list # pointers only — never secret values +rig host doctor # stepwise: transport -> rig binary -> daemon -> identity +rig host doctor --posture product-factory-vps [--public-addr ] +``` + +Exactly three verbs (capped); the VPS factory bootstrap is script + runbook +(`docs/reference/product-factory-vps-runbook.md`). **Transport posture is +DECIDED and documented** (cli-reference §Cross-host execution): ssh = pane ops +(`send`/`capture`), http = daemon REST (`up`/`down`/`launch`), `ps`/`whoami` +follow the DECLARED transport; fan-out is http-only; no cross-transport +fallback; no http parity for send/capture in 0.4.4. Posture/doctor UNKNOWN is +never pass — each unknown carries the command that verifies it. ### `rig whoami` — compact-by-default + `--full` (`--verbose` alias) @@ -543,6 +563,30 @@ rig scope slice show # derives read-time effect Composes with slice 33's `progress` + scaffolding to make `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` §1 (dot-IDs) + §2 (maturity vocabulary). Agents update `stage` / `verified` / `id` through commands rather than hand-editing markdown and drifting. The `--against` MANDATORY rule on `verified` is the anti-stale keystone: bare timestamps are rejected because a bare timestamp is exactly what lets stale trackers lie while looking fresh. **STOP hand-editing the `stage` / `verified` / `id` fields in scope frontmatter; use the new verbs.** Existing missions / slices with `id:null` ghosts or missing `PROGRESS.md` are repaired idempotently via `reconcile`. +### The SDLC control plane — convention sections, the two locks, `rig proof` (v0.4.4) + +The Living Notes UI is a plain projection of well-formed on-disk markdown. The conventions live in ONE shipped document — **`docs/reference/sdlc-conventions.md`** (in the CLI package) — and the operating procedure is the **`mission-slice-sop`** skill. The command surface: + +```bash +rig scope slice create [--template ] # scaffolds ## Intent / ## Mini-requirements / ## Proof contract + # + proof/ + PROOF.md + IMPLEMENTATION-PRD.md — EVERY template kind + +rig scope slice approve --scope spec # PLAN-LOCK: "the PRD matches my intent; this set gets built" +rig scope slice approve --scope delivery # PROOF-LOCK: the terminal sign-off (default scope; fires the freeze) + +rig proof add --artifact-type qa --verdict PASS \ + --candidate-sha --money-evidence "" \ + --evidences "1,3" --media "walk.webm" \ + --self-check "" # C1 proof drop into proof/; --evidences joins the drop to its + # proof-contract items and --media names the curated proof/-relative + # media it stands behind (what the DELIVERED section pairs + renders) + +rig scope audit # deterministic backstop — flags missing sections / malformed + # proof contract / invalid C1 headers; ADVISORY, never blocks +``` + +The flow: intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock → build the locked set → QA visual compare → `rig proof` drops → proof-lock. Approval is freeze/sign-off, **never** proven-green — proven-green requires recorded C1 verdicts. + ### `rig skill audit` — skill cascade provenance (slice 10) ```bash @@ -584,7 +628,7 @@ Typical operator loop: ```bash rig up secrets-manager --cwd /path/to/project -rig ps --nodes --json +rig ps --nodes --rig secrets-manager --json rig send vault-specialist@secrets-manager "Check Vault health and report back." --verify rig env status secrets-manager rig env logs secrets-manager @@ -634,10 +678,12 @@ JSON form exposes `peersNote` directly for programmatic consumers. rig ps rig ps --json rig ps --nodes -rig ps --nodes --json +rig ps --nodes --rig --json +rig ps --nodes -A --json +rig ps --nodes -A --full --json ``` -Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for: +Use `rig ps --nodes --rig --json` for one rig's node inventory, or `rig ps --nodes -A --json` for fleet-wide projected node inventory. Use `--full` only when a downstream consumer truly needs complete per-node records. The node inventory surface carries: - session name - runtime - session/startup status @@ -964,7 +1010,7 @@ rig whoami --json Notes: - for tmux-backed self-attach, `rig whoami --json` is the right verification -- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface +- for raw/external self-attach, `rig ps --nodes --rig --json` is currently the more reliable verification surface - if the current shell is outside tmux, pass `--display-name ` when you want a stable human session label recorded ### Adopt a topology and bind live sessions @@ -1031,13 +1077,13 @@ Verification loop: ```bash rig discover --json rig adopt --bindings-file --target-rig -rig ps --nodes --json +rig ps --nodes --rig --json rig export -o rig.yaml ``` Success looks like: - the new sessions stop appearing in `rig discover` -- the new logical IDs appear in `rig ps --nodes --json` +- the new logical IDs appear in `rig ps --nodes --rig --json` - `rig export` includes the new pod ### Mixed-origin rigs are allowed @@ -1055,7 +1101,7 @@ Current safety rule: The proven operator pattern is: - keep one OpenRig manager session outside the rig it manages - address the target by rig name, not cached rig ID -- resolve the current owner from fresh `rig ps --nodes --json` +- resolve the current owner from fresh `rig ps --nodes -A --json` - send the manager the spec path, bindings path, and verification steps with `rig send` This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert. @@ -1152,7 +1198,7 @@ When the CLI behaves strangely, use the smallest truthful check first: ```bash rig whoami --json rig daemon status -rig ps --nodes --json +rig ps --nodes ``` Specific operator rules: @@ -1188,7 +1234,7 @@ Design assumptions that hold in the shipped CLI: 1. `rig whoami --json` 2. `rig transcript --tail 100` -3. `rig ps --nodes --json` +3. `rig ps --nodes` 4. `rig chatroom history --limit 50` ## Commands That Do Not Exist diff --git a/packages/daemon/specs/agents/shared/skills/pods/orchestration-team/SKILL.md b/packages/daemon/specs/agents/shared/skills/pods/orchestration-team/SKILL.md index 02a61cb55..96d63b586 100644 --- a/packages/daemon/specs/agents/shared/skills/pods/orchestration-team/SKILL.md +++ b/packages/daemon/specs/agents/shared/skills/pods/orchestration-team/SKILL.md @@ -12,7 +12,7 @@ You are part of the orchestration pod. Your job is to keep the team productive, Before you summarize the rig or assign real work: 1. Load `using-superpowers`, `openrig-user`, `orchestration-team`, `systematic-debugging`, and `verification-before-completion`. Also load `mission-slice-sop` — the operating procedure for the canonical mission/slice files (you own PROGRESS.md `§1`; run `rig scope audit` at slice-close/mission-wrap). 2. Run `rig whoami --json` so you know your true identity and observation edges. -3. Run `rig ps --nodes --json` and wait for the expected starter topology to settle. +3. Run `rig ps --nodes -A --json` (v0.4.4: `--nodes` scopes to the current rig by default; `-A` for the fleet) and wait for the expected starter topology to settle. 4. Check recent chatroom history or direct startup messages so you know who is actually online and what they already reported. 5. Only then announce readiness or assign work. @@ -34,7 +34,7 @@ The orchestration pod is responsible for: **Principle:** monitoring intensity tracks **stakes × how likely you are to need to intervene, bounded to the window where that's true.** Spend tight attention only where it changes what you do, only as long as the risk lasts, then return to default. (Same evidence-not-cadence rule the `watchdog` skill applies to intervention *level*, applied to *intensity*.) **Self-test: "Can I name the stakes AND the condition that ends this close-watch?"** If not, you're hyper-monitoring. **Default (almost always) — token-efficient.** Steady-state your job is the **idle-without-handoff exception**: the queue handles normal handoff; you catch the agent who finished + went idle without closing/handing off. -- **Status lives in the queue, not panes** (`status-not-chat-orchestrator`): `rig ps --nodes --json` + `rig queue` are your status source; do NOT reconstruct fleet-state by capturing panes (pane `rig capture` is high-bandwidth *within your own pod* — that's fine; it is not how you track cross-pod/fleet state). +- **Status lives in the queue, not panes** (`status-not-chat-orchestrator`): `rig ps` (the consolidated map) / `rig ps --nodes -A --json` + `rig queue` are your status source; do NOT reconstruct fleet-state by capturing panes (pane `rig capture` is high-bandwidth *within your own pod* — that's fine; it is not how you track cross-pod/fleet state). - **The watchdog is your clock** (`watchdog`): configure `rig watchdog` to wake you (~3 min); between wakes, idle (zero tokens) — no self-run sleep-loop re-reading panes at steady-state. Prefer one workflow-watchdog + targeted exception handling over many per-seat nag loops. - **On each wake — cheap sweep:** `rig queue` + a *filtered* `rig ps` (see "Read cheap" below) first; ONLY for a seat that looks idle/suspicious, `rig capture ` last few lines (never a full pane, never huge chunks); **active owner → no-op.** - **Read cheap — every status command has a token cost; project to the question.** The queue-first rule is about *where* status lives; this is about *how much you pay to read it*. The token bomb is the broad unfiltered dump, not the pane capture: an unfiltered fleet-wide `rig ps --nodes --json` emits ~77k tokens — for a one-rig or one-qitem question that is almost all waste, and at watchdog cadence it burns the shared account fast. diff --git a/packages/daemon/specs/rigs/focused/pm-team/CULTURE.md b/packages/daemon/specs/rigs/focused/pm-team/CULTURE.md index 7aa9b2101..5a0c194e8 100644 --- a/packages/daemon/specs/rigs/focused/pm-team/CULTURE.md +++ b/packages/daemon/specs/rigs/focused/pm-team/CULTURE.md @@ -15,3 +15,7 @@ Ship features that solve real customer problems with evidence-backed requirement - PM agent is the hub. Research and code agents report to PM. - Coder can observe researcher output for domain context. - When blocked, escalate to PM rather than guessing. + +## Mission/slice tracking (the SDLC) + +Requirements and delivery are tracked as missions and slices — on-disk markdown the Living Notes UI projects. Load the packaged `mission-slice-sop` skill; the conventions live in `docs/reference/sdlc-conventions.md` (shipped with the CLI package). PM's leg of the flow: record intent verbatim, author the mini-requirements (the one-glance tier where approval starts) + the proof contract (promised deliverables as observable outcomes; UI deliverables name their planned mockup), then plan-lock with `rig scope slice approve --scope spec`. Proof-lock (`--scope delivery`) is the terminal sign-off after QA's visual compare and `rig proof add … --media` drops (the C1 drop verb). diff --git a/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/guidance/role.md b/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/guidance/role.md index f4321ab98..b95f6055d 100644 --- a/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/guidance/role.md +++ b/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/guidance/role.md @@ -11,7 +11,7 @@ with them and escalate to them when ops decisions need human approval. rig**; other rigs require explicit operator-initiated restart. When the user says "bring my rigs back online": 1. List rigs that were running pre-reboot using daemon persisted - state (`rig ps --json --all`) and any operator-saved roster. + state (`rig ps --json`) and any operator-saved roster. 2. Confirm with the user which subset to restart. 3. Restart each via `rig up ` (or `rig restore ` if a snapshot exists). diff --git a/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/startup/context.md b/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/startup/context.md index 515b5f5c0..1df4b79d0 100644 --- a/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/startup/context.md +++ b/packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/startup/context.md @@ -34,7 +34,7 @@ tmux sessions are gone. You own the agent-restart workflow: Other rigs (project rigs the user spun up) are NEVER auto-instantiated by the daemon. If the user asks you to bring those back: -1. `rig ps --json --all` shows which rigs are persisted but with +1. `rig ps --json` shows which rigs are persisted but with detached sessions. 2. Confirm with the user which subset to restart. 3. `rig up ` for cold-start; `rig restore --rig diff --git a/packages/daemon/specs/rigs/preview/product-team/CULTURE.md b/packages/daemon/specs/rigs/preview/product-team/CULTURE.md index 115840c5b..625e3f246 100644 --- a/packages/daemon/specs/rigs/preview/product-team/CULTURE.md +++ b/packages/daemon/specs/rigs/preview/product-team/CULTURE.md @@ -135,3 +135,7 @@ Follow patterns agents already know: docker, git, kubectl, npm. ## What this rig is for This is the advanced product-team lane. Use it when the work needs richer coordination than the conveyor starter. The human sets direction; the team plans, implements, reviews, and surfaces gaps honestly. + +## Mission/slice tracking (the SDLC) + +Work is tracked as missions and slices — on-disk markdown the Living Notes UI projects. Load the packaged `mission-slice-sop` skill before authoring or building a slice; the conventions (section names, proof contract, the two locks, C1 proof headers) live in `docs/reference/sdlc-conventions.md` (shipped with the CLI package). The short form: record intent → author mini-requirements + a proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visually compares planned vs delivered → `rig proof add … --evidences --media` drops (the C1 drop verb — never hand-place evidence) → proof-lock (`--scope delivery`). `rig scope audit` is the advisory backstop — fix the flag, don't suppress it. diff --git a/packages/daemon/src/build-info.ts b/packages/daemon/src/build-info.ts new file mode 100644 index 000000000..3ccd566a3 --- /dev/null +++ b/packages/daemon/src/build-info.ts @@ -0,0 +1,30 @@ +// OPR.0.4.4.11 FR-6 — the daemon's build-identity module. +// +// This COMMITTED file is the honest DEV STUB: a source/dev run has no stamp +// and reports its identity fields as absent — never an invented SHA (FR-7 +// negative AC). scripts/build-package.sh computes the stamp ONCE at package +// time (arch ruling 6) and overwrites the COMPILED counterpart +// (dist/build-info.js) in each package with the real values; no runtime +// cross-package import — the CLI carries its own generated twin. + +export interface BuildInfo { + semver: string | null; + commit: string | null; + dirty: boolean | null; + builtAt: string | null; +} + +export const BUILD_INFO: BuildInfo = { + semver: null, + commit: null, + dirty: null, + builtAt: null, +}; + +/** The additive /healthz fields: the four stamp keys when stamped, an EMPTY + * object when dev — existing consumers (including exact-body probes against + * dev daemons) see no change. */ +export function stampFields(info: BuildInfo = BUILD_INFO): Record { + if (!info.commit) return {}; + return { semver: info.semver, commit: info.commit, dirty: info.dirty, builtAt: info.builtAt }; +} diff --git a/packages/daemon/src/db/migrations/048_queue_item_evidence_ref.ts b/packages/daemon/src/db/migrations/048_queue_item_evidence_ref.ts new file mode 100644 index 000000000..dd8535862 --- /dev/null +++ b/packages/daemon/src/db/migrations/048_queue_item_evidence_ref.ts @@ -0,0 +1,23 @@ +import type { Migration } from "../migrate.js"; + +/** + * OPR.0.4.4.19 FR-5 — queue_items.evidence_ref column. + * + * Adds `evidence_ref TEXT` (NULLABLE) to queue_items: a pointer to the + * durable artifact a human judges when the item is human-routed (convention + * C3), authored via `--evidence-ref` on `rig queue create` / `handoff` and + * persistable at park time (`rig queue block`, FR-6). Follows the 044 + * summary-column pattern exactly: additive, nullable, defensive + * detectQueueColumn read so pre-048 fixtures degrade. + * + * NULLABLE by contract: required ONLY for human-routed items (the §5 + * predicate, enforced at the domain write path — FR-4/FR-5), so ordinary + * agent-to-agent rows stay NULL forever (BR-1 zero-friction). Do NOT make + * this NOT NULL or add a default. + */ +export const queueItemEvidenceRefSchema: Migration = { + name: "048_queue_item_evidence_ref.sql", + sql: ` + ALTER TABLE queue_items ADD COLUMN evidence_ref TEXT; + `, +}; diff --git a/packages/daemon/src/domain/feed/attention-aggregator.ts b/packages/daemon/src/domain/feed/attention-aggregator.ts new file mode 100644 index 000000000..3c44738a4 --- /dev/null +++ b/packages/daemon/src/domain/feed/attention-aggregator.ts @@ -0,0 +1,149 @@ +// OPR.0.4.4.15 FR-1 — daemon-side attention aggregation (the SEE architecture, +// call A): the LOCAL daemon fans out server-side to each subscribed host's +// shipped GET /api/queue/list?attention=1 over the registered HTTP+bearer +// transport, merges, and returns ONE AggregatedPayload. Bearer tokens never +// reach the browser — this module is the only place remote hosts are +// contacted for the feed. +// +// Reuse, not reimplementation: the LOCAL leg invokes the SAME +// QueueRepository.listAttention query the existing route uses (injected at +// the route wiring); the registry is read through S11's shared +// hosts-registry-reader (interface cell — never re-parsed); the remote hop +// rides the shared remote-daemon-http core with the READ deadline class +// named EXPLICITLY at this call-site (arch sharpening: 5s — a poll, not a +// bootstrap; the up-leaf's 120s budget lives at ITS call-site). +// +// FR-1/R15-2 honesty: EVERY subscribed host appears in hosts[] on EVERY +// call — ok, unreachable, auth-failed, or unsupported-transport — never +// all-or-nothing, never a silently thinner feed. The registry is read ONLY +// when ≥1 remote subscription is enabled (zero-config never touches it). + +import type { AggregatedPayload, PerHostStatus } from "../hosts/fanout-contract.js"; +import { LOCAL_HOST_ID } from "../hosts/fanout-contract.js"; +import type { HostRegistryLoadResult } from "../hosts/hosts-registry-reader.js"; +import { resolveHost } from "../hosts/hosts-registry-reader.js"; +import { remoteJsonRequest } from "../hosts/remote-daemon-http.js"; + +/** The aggregation READ deadline class (per-host bound). Distinct from the + * up-leaf's long-running budget by design — named here, passed explicitly. */ +export const ATTENTION_READ_TIMEOUT_MS = 5_000; + +/** Fixed fan-out cap — a poll across a handful of hosts; adaptive + * throttling is out of scope (same v1 posture as the topology walker). */ +export const ATTENTION_FANOUT_CONCURRENCY = 4; + +export type AttentionItem = Record; + +export interface AttentionAggregatorDeps { + /** The SAME query the shipped /api/queue/list?attention=1 route runs. */ + listLocalAttention: () => AttentionItem[]; + /** settings-store.listFeedHostSubscriptions (the G15-P1 dynamic class). */ + listSubscriptions: () => Array<{ hostId: string; enabled: boolean }>; + /** S11's shared reader — called lazily, only when a remote sub exists. */ + loadRegistry: () => HostRegistryLoadResult; + fetchImpl?: typeof fetch; + env?: Record; + readFile?: (path: string) => string; + /** Test override; production uses ATTENTION_READ_TIMEOUT_MS. */ + timeoutMs?: number; + concurrency?: number; +} + +interface PerHostOutcome { + status: PerHostStatus; + items: AttentionItem[]; +} + +async function readHostAttention(hostId: string, reg: HostRegistryLoadResult, deps: AttentionAggregatorDeps): Promise { + if (!reg.ok) { + // Registry unreadable: every subscribed host reports it honestly — + // the operator sees ONE actionable error per host row, local items + // are untouched. + return { status: { hostId, status: "unreachable", error: reg.error }, items: [] }; + } + const resolved = resolveHost(reg.registry, hostId); + if (!resolved.ok) { + return { status: { hostId, status: "unreachable", error: resolved.error }, items: [] }; + } + if (resolved.host.transport !== "http") { + // R15-2 (the whoami --all-hosts precedent): SSH-declared hosts are a + // structured per-host status, never a silently thinner feed. + return { + status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the aggregated feed read requires an http-transport registry entry (url + bearer)` }, + items: [], + }; + } + const res = await remoteJsonRequest(resolved.host, "/api/queue/list?attention=1", { + method: "GET", + timeoutMs: deps.timeoutMs ?? ATTENTION_READ_TIMEOUT_MS, + fetchImpl: deps.fetchImpl, + env: deps.env, + readFile: deps.readFile, + }); + if (res.ok) { + const arr = Array.isArray(res.payload) ? (res.payload as AttentionItem[]) : []; + return { status: { hostId, status: "ok" }, items: arr.map((i) => ({ ...i, hostId })) }; + } + switch (res.kind) { + case "bearer": + return { status: { hostId, status: "auth-failed", error: res.detail, failedStep: "permission-gate" }, items: [] }; + case "http": + if (res.status === 401 || res.status === 403) { + return { status: { hostId, status: "auth-failed", error: `HTTP ${res.status}${res.detail ? `: ${res.detail}` : ""}`, failedStep: "permission-gate" }, items: [] }; + } + // A remote 4xx/5xx on the read: the feed cannot show that host's + // items — the closest closed-enum truth is unreachable, with the + // remote's own status/text as the honest detail and the shipped + // FailedStep vocabulary riding as the additive detail field. + return { status: { hostId, status: "unreachable", error: `HTTP ${res.status}${res.detail ? `: ${res.detail}` : ""}`, failedStep: "remote-command-failed" }, items: [] }; + case "timeout": + return { + status: { + hostId, + status: "unreachable", + error: res.phase === "body" ? `read timed out: response headers arrived (HTTP ${res.status}) but the body never completed` : `read timed out after ${deps.timeoutMs ?? ATTENTION_READ_TIMEOUT_MS}ms`, + failedStep: "remote-daemon-unreachable", + }, + items: [], + }; + case "network": + return { status: { hostId, status: "unreachable", error: res.detail, failedStep: "remote-daemon-unreachable" }, items: [] }; + } +} + +/** One merged attention payload: local ALWAYS included (stamped with the + * contract's LOCAL_HOST_ID), each enabled remote host fanned out under the + * read deadline + fixed cap, per-host status complete by construction. */ +export async function aggregateAttention(deps: AttentionAggregatorDeps): Promise> { + const localItems = deps.listLocalAttention().map((i) => ({ ...i, hostId: LOCAL_HOST_ID })); + const hosts: PerHostStatus[] = [{ hostId: LOCAL_HOST_ID, status: "ok" }]; + const items: AttentionItem[] = [...localItems]; + + const subs = deps.listSubscriptions().filter((s) => s.enabled); + if (subs.length === 0) { + // Zero remote subscriptions: the registry is NEVER read; the payload + // is today's local feed plus the local status row. + return { items, hosts }; + } + + const reg = deps.loadRegistry(); + const outcomes = new Array(subs.length); + let next = 0; + const cap = Math.max(1, Math.min(deps.concurrency ?? ATTENTION_FANOUT_CONCURRENCY, subs.length)); + const workers = Array.from({ length: cap }, async () => { + while (true) { + const i = next; + if (i >= subs.length) return; + next += 1; + outcomes[i] = await readHostAttention(subs[i]!.hostId, reg, deps); + } + }); + await Promise.all(workers); + + // Subscription order preserved in both arrays (deterministic payload). + for (const outcome of outcomes) { + hosts.push(outcome.status); + items.push(...outcome.items); + } + return { items, hosts }; +} diff --git a/packages/daemon/src/domain/files/file-write-service.ts b/packages/daemon/src/domain/files/file-write-service.ts index 4774ce77b..aa7f091d3 100644 --- a/packages/daemon/src/domain/files/file-write-service.ts +++ b/packages/daemon/src/domain/files/file-write-service.ts @@ -66,7 +66,7 @@ export class WriteConflictError extends Error { export class FileWriteError extends Error { constructor( - public readonly code: "stat_failed" | "tmp_write_failed" | "rename_failed" | "audit_write_failed", + public readonly code: "stat_failed" | "tmp_write_failed" | "rename_failed" | "audit_write_failed" | "target_exists", message: string, public readonly details?: Record, ) { @@ -211,6 +211,83 @@ export class FileWriteService { }; } + /** + * Atomically CREATE a new allowlisted file (OPR.0.4.4.20 FR-6 — the frozen + * review export). Exclusive: throws `target_exists` when the file is + * already there (frozen exports are point-in-time snapshots, never + * rewritten; re-invoking the freeze for the same approval is an idempotent + * no-op at the caller). Same temp-write + fsync + audit-row machinery as + * writeAtomic — one writer family, no parallel write path. + */ + createAtomic(req: Omit): FileWriteResult { + const target = resolveAllowedPath(this.allowlist, req.rootName, req.path); + if (fs.existsSync(target)) { + throw new FileWriteError("target_exists", `refusing to overwrite existing file '${target}'`, { target }); + } + + const tmpName = `.openrig-write-${process.pid}-${Math.random().toString(36).slice(2, 10)}-${path.basename(target)}`; + const tmpPath = path.join(path.dirname(target), tmpName); + let tmpFd: number | null = null; + try { + tmpFd = fs.openSync(tmpPath, "w"); + fs.writeFileSync(tmpFd, req.content); + fs.fsyncSync(tmpFd); + } catch (err) { + try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ } + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + throw new FileWriteError( + "tmp_write_failed", + `failed to write+fsync temp file at '${tmpPath}': ${err instanceof Error ? err.message : String(err)}`, + { target, tmpPath }, + ); + } finally { + try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ } + } + + try { + // linkSync fails if the target appeared meanwhile — true exclusive create. + fs.linkSync(tmpPath, target); + fs.unlinkSync(tmpPath); + } catch (err) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + const code = (err as NodeJS.ErrnoException).code === "EEXIST" ? "target_exists" : "rename_failed"; + throw new FileWriteError( + code, + `failed to atomically create '${target}': ${err instanceof Error ? err.message : String(err)}`, + { target, tmpPath }, + ); + } + + const newStat = fs.statSync(target); + const newContent = fs.readFileSync(target); + const newMtime = newStat.mtime.toISOString(); + const newContentHash = sha256Hex(newContent); + + const auditRow = { + ts: this.now().toISOString(), + actor: req.actor, + root: req.rootName, + path: req.path, + absolutePath: target, + prevMtime: null, + newMtime, + prevContentHash: null, + newContentHash, + byteCountDelta: newContent.byteLength, + }; + try { + this.appendAuditRow(auditRow); + } catch (err) { + throw new FileWriteError( + "audit_write_failed", + `create succeeded but audit append failed: ${err instanceof Error ? err.message : String(err)}`, + { target, auditFilePath: this.auditFilePath, ...auditRow }, + ); + } + + return { absolutePath: target, newMtime, newContentHash, byteCountDelta: newContent.byteLength }; + } + private appendAuditRow(row: Record): void { fs.mkdirSync(path.dirname(this.auditFilePath), { recursive: true }); fs.appendFileSync(this.auditFilePath, `${JSON.stringify(row)}\n`); diff --git a/packages/daemon/src/domain/hosts/fanout-contract.ts b/packages/daemon/src/domain/hosts/fanout-contract.ts new file mode 100644 index 000000000..c2399ba0a --- /dev/null +++ b/packages/daemon/src/domain/hosts/fanout-contract.ts @@ -0,0 +1,50 @@ +// OPR.0.4.4.15 — THE shared intra-P4 fan-out payload contract. +// +// ONE contract for the whole P4 packet (arch adjudication 2026-07-05, +// cross-PRD interface cell): slice 15's aggregated For-You attention feed +// AND slice 21's `rig ps --all-hosts` rollup both speak AggregatedPayload — +// items plus a per-host structured status array. Slice 15 defines this +// module (first lander, per the whichever-builds-first rule named in both +// PRDs); slice 21 IMPORTS it. Any change here is a CROSS-PRD contract +// change requiring re-review of BOTH slices — never driver discretion. + +/** The local host's id literal in every P3/P4 payload (arch pin: defined + * ONCE here, imported everywhere, never retyped). Matches slice 11's + * topology aggregate literal. */ +export const LOCAL_HOST_ID = "local"; + +/** CLOSED enum (arch pin A). `unsupported-transport` is R15-2's explicit + * class (an SSH-declared host is never a silently thinner payload); + * `auth-failed` is distinct from `unreachable` because the operator fix + * differs (rotate/set the bearer vs check the host). Extending this set = + * a cross-PRD contract change (slices 15 AND 21 re-review). */ +export type PerHostStatusKind = "ok" | "unreachable" | "unsupported-transport" | "auth-failed"; + +export interface PerHostStatus { + hostId: string; + status: PerHostStatusKind; + /** Honest failure detail, rendered muted next to the host chip/row. */ + error?: string; + /** ADDITIVE OPTIONAL detail (arch adjudication): the shipped CLI + * FailedStep vocabulary when a transport-level step classified the + * failure. Never load-bearing — `status` is the contract. */ + failedStep?: string; +} + +/** items + per-host status: EVERY subscribed host appears in `hosts` on + * EVERY payload — ok, unreachable, auth-failed, or unsupported-transport. + * Absence is a contract violation (omission-proof; never all-or-nothing, + * never silent thinning). */ +export interface AggregatedPayload { + items: T[]; + hosts: PerHostStatus[]; +} + +/** Contract-level completeness predicate (arch pin B: asserted as near to + * the contract as tests allow): true iff every expected host id appears + * exactly once in the payload's hosts array. */ +export function hostsCovered(payload: AggregatedPayload, expectedHostIds: string[]): boolean { + const seen = new Map(); + for (const h of payload.hosts) seen.set(h.hostId, (seen.get(h.hostId) ?? 0) + 1); + return expectedHostIds.every((id) => seen.get(id) === 1) && payload.hosts.length === expectedHostIds.length; +} diff --git a/packages/daemon/src/domain/hosts/hosts-registry-reader.ts b/packages/daemon/src/domain/hosts/hosts-registry-reader.ts new file mode 100644 index 000000000..1910439d1 --- /dev/null +++ b/packages/daemon/src/domain/hosts/hosts-registry-reader.ts @@ -0,0 +1,199 @@ +// OPR.0.4.4.11 — the SHARED daemon-side hosts-registry read module (the P3/P4 +// interface cell, pm-lead ruling c): P3 uses it for topology placement +// resolution + validation messages; P4's fan-out aggregation consumes the +// SAME module. Land once, here; P4 must not re-implement it. +// +// READ-ONLY FOREVER (arch ruling 3): the registry is operator-managed YAML at +// ~/.openrig/hosts.yaml; nothing in the daemon ever writes it. This module +// MIRRORS the CLI's packages/cli/src/host-registry.ts schema + validation +// (incl. the exactly-one-bearer rule) — the CLI copy is deliberately +// untouched this slice (no-unification boundary); the twin is held in sync by +// packages/daemon/test/hosts-registry-parity.test.ts, the same discipline as +// the scope-audit CLI/daemon twins. + +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseYaml } from "yaml"; +import { getDefaultOpenRigPath } from "../../openrig-compat.js"; + +export interface SshHostEntry { + id: string; + transport: "ssh"; + target: string; + user?: string; + notes?: string; +} + +export interface HttpHostEntry { + id: string; + transport: "http"; + url: string; + bearer_env?: string; + bearer_file?: string; + notes?: string; +} + +export type HostEntry = SshHostEntry | HttpHostEntry; + +export interface HostRegistry { + hosts: HostEntry[]; +} + +export type HostRegistryLoadResult = + | { ok: true; registry: HostRegistry } + | { ok: false; error: string }; + +export type HostResolution = + | { ok: true; host: HostEntry } + | { ok: false; error: string }; + +const KNOWN_TRANSPORTS = new Set(["ssh", "http"]); + +export function defaultHostRegistryPath(): string { + return getDefaultOpenRigPath("hosts.yaml"); +} + +/** Load + validate the operator's hosts registry (read-only). Mirrors the CLI + * loader's error surfaces so operators see one vocabulary everywhere. */ +export function loadHostRegistry(path: string = defaultHostRegistryPath()): HostRegistryLoadResult { + if (!existsSync(path)) { + return { + ok: false, + error: `host registry not found at ${path}. Create it with a 'hosts:' array; transport: ssh (target + user) or http (url + bearer_env/bearer_file).`, + }; + } + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch (err) { + return { ok: false, error: `failed to read host registry at ${path}: ${(err as Error).message}` }; + } + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch (err) { + return { ok: false, error: `failed to parse host registry YAML at ${path}: ${(err as Error).message}` }; + } + return validateHostRegistry(parsed, path); +} + +export function validateHostRegistry(parsed: unknown, sourcePath: string): HostRegistryLoadResult { + if (parsed === null || typeof parsed !== "object") { + return { ok: false, error: `host registry at ${sourcePath} must be a YAML object with a 'hosts' array` }; + } + const obj = parsed as Record; + const hosts = obj["hosts"]; + if (!Array.isArray(hosts)) { + return { ok: false, error: `host registry at ${sourcePath}: 'hosts' must be an array` }; + } + + const seenIds = new Set(); + const validated: HostEntry[] = []; + for (let i = 0; i < hosts.length; i++) { + const raw = hosts[i]; + const prefix = `host registry at ${sourcePath}: hosts[${i}]`; + if (raw === null || typeof raw !== "object") { + return { ok: false, error: `${prefix}: must be an object with id/transport/target` }; + } + const entry = raw as Record; + const id = entry["id"]; + if (typeof id !== "string" || id.trim() === "") { + return { ok: false, error: `${prefix}.id: required non-empty string` }; + } + if (seenIds.has(id)) { + return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` }; + } + seenIds.add(id); + const transport = entry["transport"]; + if (typeof transport !== "string" || !KNOWN_TRANSPORTS.has(transport)) { + return { + ok: false, + error: `${prefix}.transport: must be one of ${[...KNOWN_TRANSPORTS].sort().join(", ")} (got ${JSON.stringify(transport)})`, + }; + } + const notes = entry["notes"]; + if (notes !== undefined && typeof notes !== "string") { + return { ok: false, error: `${prefix}.notes: optional, but if present must be a string` }; + } + + if (transport === "ssh") { + const target = entry["target"]; + if (typeof target !== "string" || target.trim() === "") { + return { ok: false, error: `${prefix}.target: required non-empty string (an ssh target)` }; + } + const user = entry["user"]; + if (user !== undefined && (typeof user !== "string" || user.trim() === "")) { + return { ok: false, error: `${prefix}.user: optional, but if present must be a non-empty string` }; + } + validated.push({ + id, + transport: "ssh", + target, + ...(user !== undefined ? { user: user as string } : {}), + ...(notes !== undefined ? { notes: notes as string } : {}), + }); + } else if (transport === "http") { + const url = entry["url"]; + if (typeof url !== "string" || url.trim() === "") { + return { ok: false, error: `${prefix}.url: required non-empty string (the remote daemon's base URL)` }; + } + const bearerEnv = entry["bearer_env"]; + const bearerFile = entry["bearer_file"]; + const hasEnv = bearerEnv !== undefined; + const hasFile = bearerFile !== undefined; + if (!hasEnv && !hasFile) { + return { ok: false, error: `${prefix}: http transport requires exactly one of bearer_env or bearer_file` }; + } + if (hasEnv && hasFile) { + return { ok: false, error: `${prefix}: specify exactly one of bearer_env or bearer_file, not both` }; + } + if (hasEnv && (typeof bearerEnv !== "string" || bearerEnv.trim() === "")) { + return { ok: false, error: `${prefix}.bearer_env: must be a non-empty env var name` }; + } + if (hasFile && (typeof bearerFile !== "string" || bearerFile.trim() === "")) { + return { ok: false, error: `${prefix}.bearer_file: must be a non-empty file path` }; + } + validated.push({ + id, + transport: "http", + url: url as string, + ...(hasEnv ? { bearer_env: bearerEnv as string } : {}), + ...(hasFile ? { bearer_file: bearerFile as string } : {}), + ...(notes !== undefined ? { notes: notes as string } : {}), + }); + } + } + return { ok: true, registry: { hosts: validated } }; +} + +/** Resolve a host id for placement. Unknown id names the requested id and + * lists up to 10 known ids — the FR-4 per-entry validation message rides + * this (what/why/fix, before any launch attempt). */ +export function resolveHost(registry: HostRegistry, id: string): HostResolution { + const match = registry.hosts.find((h) => h.id === id); + if (match) return { ok: true, host: match }; + const knownIds = registry.hosts.map((h) => h.id).slice(0, 10); + const idsHint = knownIds.length > 0 + ? ` Known host ids: ${knownIds.join(", ")}${registry.hosts.length > knownIds.length ? ` (+${registry.hosts.length - knownIds.length} more)` : ""}.` + : " (registry is empty)"; + return { + ok: false, + error: `unknown host id '${id}'.${idsHint}`, + }; +} + +/** FR-4: placement requires a transport that can carry remote-up. v0 that is + * the http transport (the shipped remote single-rig leaf is POST /api/up + * over runRemoteHttpOp); ssh-transport hosts are reachable for send/capture + * but cannot carry remote-up — a structured per-entry error, not a launch + * attempt. */ +export function resolvePlacementHost(registry: HostRegistry, id: string): HostResolution { + const res = resolveHost(registry, id); + if (!res.ok) return res; + if (res.host.transport !== "http") { + return { + ok: false, + error: `host '${id}' uses transport '${res.host.transport}', which cannot carry remote rig-up. Placement requires an http-transport host (url + bearer_env/bearer_file) — update the registry entry or launch that rig locally.`, + }; + } + return res; +} diff --git a/packages/daemon/src/domain/hosts/remote-daemon-http.ts b/packages/daemon/src/domain/hosts/remote-daemon-http.ts new file mode 100644 index 000000000..2b6dde6ec --- /dev/null +++ b/packages/daemon/src/domain/hosts/remote-daemon-http.ts @@ -0,0 +1,131 @@ +// OPR.0.4.4.15 — the SHARED daemon→daemon HTTP core (arch cell 1, RATIFIED). +// +// Extracted from topology/remote-up-leaf.ts so the P4 aggregation fan-out +// does not mint a second copy of security-adjacent transport code (bearer +// resolution + bounded-abort discipline + status classification). The +// up-leaf is now a thin consumer that FORMATS this module's structured +// result into its shipped error strings (its tests byte-preserved). +// +// DEADLINE IS A REQUIRED ARGUMENT (arch sharpening, no module default): +// the up-leaf passes its 120s long-running rig-up budget at ITS call-site; +// the attention aggregator passes its 5s read-class deadline at ITS +// call-site. A buried default would invite the next consumer to silently +// inherit the wrong class. +// +// The G-R2B1-1 discipline is structural here: ONE deadline armed through +// the WHOLE exchange — request AND body parse (2xx or error body). A host +// that sends headers and never finishes the body yields a structured +// timeout, never a hung caller. The body race is explicit against the +// signal (a hand-built/proxied Response is not necessarily wired to the +// controller — no trust in fetch internals). + +import { readFileSync } from "node:fs"; +import type { HttpHostEntry } from "./hosts-registry-reader.js"; + +export interface RemoteJsonDeps { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: typeof fetch; + env?: Record; + readFile?: (path: string) => string; +} + +export interface RemoteJsonOptions extends RemoteJsonDeps { + method: "GET" | "POST"; + body?: unknown; + /** REQUIRED — the caller names its deadline class explicitly. */ + timeoutMs: number; +} + +export type RemoteJsonFailureKind = "bearer" | "timeout" | "network" | "http"; + +export interface RemoteJsonFailure { + ok: false; + kind: RemoteJsonFailureKind; + /** For kind=timeout: whether the deadline fired before headers + * ("request") or while reading the body ("body"). */ + phase?: "request" | "body"; + /** HTTP status when headers arrived (kind=http, or kind=timeout/phase=body). */ + status?: number; + /** Bearer message / network error message / remote error text (may be ""). */ + detail: string; +} + +export type RemoteJsonResult = { ok: true; status: number; payload: unknown } | RemoteJsonFailure; + +/** Mirrors the CLI's resolveRemoteBearer (host-registry.ts): exactly-one of + * bearer_env / bearer_file, resolved at call time. */ +function resolveBearer(host: HttpHostEntry, deps: RemoteJsonDeps): { ok: true; token: string } | { ok: false; detail: string } { + const env = deps.env ?? process.env; + const readFile = deps.readFile ?? ((p: string) => readFileSync(p, "utf-8")); + if (host.bearer_env) { + const token = env[host.bearer_env]?.trim(); + if (token) return { ok: true, token }; + return { ok: false, detail: `bearer env var ${host.bearer_env} is not set or empty for host ${host.id}` }; + } + if (host.bearer_file) { + try { + const token = readFile(host.bearer_file).trim(); + if (token) return { ok: true, token }; + return { ok: false, detail: `bearer file ${host.bearer_file} is empty for host ${host.id}` }; + } catch { + return { ok: false, detail: `bearer file ${host.bearer_file} not readable for host ${host.id}` }; + } + } + return { ok: false, detail: `host ${host.id} has no bearer_env or bearer_file configured` }; +} + +/** One bounded daemon→daemon JSON exchange. The single deadline covers + * request + body; every outcome is structured — this function never hangs + * and never throws. */ +export async function remoteJsonRequest(host: HttpHostEntry, path: string, opts: RemoteJsonOptions): Promise { + const bearer = resolveBearer(host, opts); + if (!bearer.ok) return { ok: false, kind: "bearer", detail: bearer.detail }; + + const fetchImpl = opts.fetchImpl ?? fetch; + const url = `${host.url.replace(/\/$/, "")}${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs); + + let res: Response; + try { + res = await fetchImpl(url, { + method: opts.method, + headers: { + ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}), + Authorization: `Bearer ${bearer.token}`, + }, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if (controller.signal.aborted) return { ok: false, kind: "timeout", phase: "request", detail: "" }; + return { ok: false, kind: "network", detail: (err as Error).message }; + } + + // Body parse under the SAME still-armed deadline, raced explicitly. + const abortRace = new Promise((_resolve, reject) => { + const onAbort = () => reject(new Error("body read aborted")); + if (controller.signal.aborted) onAbort(); + else controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + + let payload: unknown; + let bodyTimedOut = false; + try { + payload = await Promise.race([res.json(), abortRace]); + } catch { + if (controller.signal.aborted) bodyTimedOut = true; + // else: non-JSON body — payload stays undefined; status is the honest detail + } finally { + clearTimeout(timer); + } + + if (bodyTimedOut) return { ok: false, kind: "timeout", phase: "body", status: res.status, detail: "" }; + if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status, payload }; + + const remoteError = payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string" + ? (payload as { error: string }).error + : ""; + return { ok: false, kind: "http", status: res.status, detail: remoteError }; +} diff --git a/packages/daemon/src/domain/human-route-enforcer.ts b/packages/daemon/src/domain/human-route-enforcer.ts new file mode 100644 index 000000000..f9f637fa4 --- /dev/null +++ b/packages/daemon/src/domain/human-route-enforcer.ts @@ -0,0 +1,132 @@ +/** + * Human-route enforcement (OPR.0.4.4.19 FR-4 + FR-5; conventions C6 + C3). + * + * Sibling of hot-potato-enforcer.ts: a pure domain validator wired at the + * QueueRepository write paths (create / handoff / handoff-and-complete, and + * the FR-6 park transition) so every surface — CLI, HTTP, future UI — + * inherits the same guarantee. NOT enforced at the route, NOT at the CLI. + * + * THE scoping predicate (PRD §5 — the complete trigger list, BR-1): + * + * tier = 'human-gate' + * OR is_human_seat_session(destination_session) + * OR (state = 'blocked' AND is_human_seat_session(blocked_on)) [FR-6] + * + * When the predicate is FALSE, this module validates NOTHING: ordinary + * agent-to-agent queue traffic gains zero new required fields, zero new + * rejection paths, zero new warns (BR-1 zero-friction boundary). Do not + * broaden the trigger by destination tag, body text, or state alone. + */ + +/** + * The EXACT human-seat regex. Single TS source of truth — the same pattern + * QueueRepository registers as the SQLite `is_human_seat_session` function + * (queue-repository.ts constructor) so SQL-side and TS-side checks cannot + * drift. Mirrors the UI's feed-classifier isHumanSeat. + */ +export const HUMAN_SEAT_SESSION_PATTERN = /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/; + +export function isHumanSeatSession(value: unknown): boolean { + return typeof value === "string" && HUMAN_SEAT_SESSION_PATTERN.test(value); +} + +export interface HumanRouteRequest { + tier: string | null | undefined; + destinationSession: string; + summary: string | null | undefined; + evidenceRef: string | null | undefined; +} + +export interface HumanRouteValidationOk { + ok: true; + /** True when the §5 predicate matched (the item is human-routed). */ + humanRouted: boolean; +} + +export interface HumanRouteValidationErr { + ok: false; + code: "human_route_fields_required"; + message: string; + missingFields: string[]; +} + +export type HumanRouteValidation = HumanRouteValidationOk | HumanRouteValidationErr; + +function isBlank(value: string | null | undefined): boolean { + return value === null || value === undefined || value.trim().length === 0; +} + +/** + * Validate the C6/C3 structure of a NEW qitem (create / the created half of a + * handoff). Fires ONLY when the item is human-routed via legs 1–2 of the §5 + * predicate (human-gate tier, human-seat destination). The park leg + * (blocked_on a human seat) is validated by {@link validateHumanPark} at the + * blocked-transition write path. + */ +export function validateHumanRoute(req: HumanRouteRequest): HumanRouteValidation { + return validateRequiredFields( + req.tier === "human-gate" || isHumanSeatSession(req.destinationSession), + req.summary, + req.evidenceRef, + "human-routed queue items", + "Provide --summary / --evidence-ref (ordinary agent-to-agent items are unaffected).", + ); +} + +export interface HumanParkRequest { + /** The blocker the item is being parked on (qitem id OR human-seat session). */ + blockedOn: string | null | undefined; + /** Effective values at park time: the value provided on the park call, + * falling back to what the item already carries. */ + summary: string | null | undefined; + evidenceRef: string | null | undefined; +} + +/** + * OPR.0.4.4.19 FR-6 — leg-1 park enforcement (§5 predicate leg 3). Fires + * ONLY when the blocker is a human seat; blocking on another qitem (today's + * shipped usage) requires nothing new. The parked qitem itself — id, slice + * tag, summary — IS the what-it-unblocks (arch-ruled: no decision_descriptor + * field). Enforcement symmetry: park requires summary + evidence_ref like + * FR-7's resolve requires non-empty decision text. + */ +export function validateHumanPark(req: HumanParkRequest): HumanRouteValidation { + return validateRequiredFields( + isHumanSeatSession(req.blockedOn), + req.summary, + req.evidenceRef, + "parking a qitem on a human seat", + "Provide them at park time (rig queue block --summary --evidence-ref) or on the item beforehand; blocking on another qitem requires nothing new.", + ); +} + +function validateRequiredFields( + predicateFired: boolean, + summary: string | null | undefined, + evidenceRef: string | null | undefined, + subject: string, + remedy: string, +): HumanRouteValidation { + if (!predicateFired) { + return { ok: true, humanRouted: false }; + } + const missing: string[] = []; + if (isBlank(summary)) missing.push("summary"); + if (isBlank(evidenceRef)) missing.push("evidence_ref"); + if (missing.length === 0) { + return { ok: true, humanRouted: true }; + } + const why: string[] = []; + if (missing.includes("summary")) { + why.push("summary: a decision the human reads must be in plain language (convention C6)"); + } + if (missing.includes("evidence_ref")) { + why.push("evidence_ref: the human must have a durable artifact to judge (convention C3)"); + } + return { + ok: false, + code: "human_route_fields_required", + message: `${subject} require${subject.endsWith("s") ? "" : "s"} ${missing.join(" + ")} — ${why.join("; ")}. ${remedy}`, + missingFields: missing, + }; +} diff --git a/packages/daemon/src/domain/mission-control/audit-browse.ts b/packages/daemon/src/domain/mission-control/audit-browse.ts index 37b7749b7..d398f7236 100644 --- a/packages/daemon/src/domain/mission-control/audit-browse.ts +++ b/packages/daemon/src/domain/mission-control/audit-browse.ts @@ -26,6 +26,14 @@ export interface AuditQueryInput { limit?: number; /** Pagination cursor: returns rows whose action_id < beforeId. */ beforeId?: string; + /** OPR.0.4.4.19 FR-9 — the scope-approval target filters (the pinned + * audit_notes_json shape's read path). These make Packet 2's one-query + * UNVERIFIED-stamp cross-check real: filter by the stable scope target + * (tier + dot-ID + canonical path) and/or approval scope. */ + scopeTier?: string; + scopeId?: string; + scopePath?: string; + approvalScope?: string; } export interface AuditQueryResult { @@ -76,6 +84,25 @@ export class MissionControlAuditBrowse { where.push("actor_session = ?"); params.push(input.actorSession); } + // OPR.0.4.4.19 FR-9 — filter on the pinned audit_notes_json shape via + // SQLite JSON1 (ships with better-sqlite3). NULL audit_notes_json rows + // simply don't match. + if (input.scopeTier) { + where.push("json_extract(audit_notes_json, '$.scope_tier') = ?"); + params.push(input.scopeTier); + } + if (input.scopeId) { + where.push("json_extract(audit_notes_json, '$.scope_id') = ?"); + params.push(input.scopeId); + } + if (input.scopePath) { + where.push("json_extract(audit_notes_json, '$.scope_path') = ?"); + params.push(input.scopePath); + } + if (input.approvalScope) { + where.push("json_extract(audit_notes_json, '$.approval_scope') = ?"); + params.push(input.approvalScope); + } if (input.since) { where.push("acted_at >= ?"); params.push(input.since); diff --git a/packages/daemon/src/domain/mission-control/mission-control-action-log.ts b/packages/daemon/src/domain/mission-control/mission-control-action-log.ts index 4f3f36be4..050ef4db8 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-action-log.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-action-log.ts @@ -19,6 +19,10 @@ export const MISSION_CONTROL_VERBS = [ "hold", "drop", "handoff", + // OPR.0.4.4.19 FR-7 — resolve: the human's on-surface answer to a leg-1 + // parked qitem (state=blocked on a human seat). NON-CLOSURE: blocked → + // in-progress only; the decision text lands in queue_transitions. + "resolve", ] as const; export type MissionControlVerb = (typeof MISSION_CONTROL_VERBS)[number]; diff --git a/packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts b/packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts index f4f7588c8..a4ce17167 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts @@ -218,7 +218,7 @@ export class MissionControlFleetCliCapability { staleCliCount, degradedFields: Array.from(allDegradedFields), // v0 reads from the daemon-internal rig registry + queue_items; - // future versions can shell out to `rig ps --nodes --json` and + // future versions can shell out to `rig ps --nodes -A --json` and // set sourceFallback to "daemon-internal" when the CLI is // unavailable. sourceFallback: "daemon-internal-projection", diff --git a/packages/daemon/src/domain/mission-control/mission-control-read-layer.ts b/packages/daemon/src/domain/mission-control/mission-control-read-layer.ts index c901bc76c..17a9484ae 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-read-layer.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-read-layer.ts @@ -4,7 +4,7 @@ // - my-queue → queue_items where destination_session = operator // AND tier='human-gate' // - human-gate → queue_items where tier='human-gate' -// - fleet → shell out to `rig ps --nodes --json` (graceful +// - fleet → shell out to `rig ps --nodes -A --json` (graceful // degradation per 4-sub-clause spec) // - active-work → queue_items where state in (pending, in-progress, // blocked) sorted by priority diff --git a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts index 46f879a4f..cbe6c952e 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts @@ -48,6 +48,7 @@ import { MissionControlActionLogError, type MissionControlVerb, } from "./mission-control-action-log.js"; +import { isHumanSeatSession } from "../human-route-enforcer.js"; export class MissionControlWriteContractError extends Error { constructor( @@ -72,6 +73,9 @@ export interface MissionControlActionInput { annotation?: string; /** Required for `hold` and `drop`; optional advisory text otherwise. */ reason?: string; + /** OPR.0.4.4.19 FR-7 — required for `resolve`: the human's non-empty + * decision text. Lands durably in queue_transitions.transition_note. */ + decision?: string; /** Operator-supplied audit context. */ auditNotes?: Record; /** @@ -125,6 +129,9 @@ export class MissionControlWriteContract { if (input.verb === "annotate") { return this.annotateOnly(input); } + if (input.verb === "resolve") { + return this.resolveParked(input); + } const source = this.requireMutableQitem(input.qitemId); @@ -167,6 +174,8 @@ export class MissionControlWriteContract { body: input.body ?? source.body, priority: source.priority, tier: source.tier ?? undefined, + summary: source.summary, + evidenceRef: source.evidenceRef, tags: source.tags ? [...source.tags, `mission-control:${input.verb}`] : [`mission-control:${input.verb}`], @@ -304,6 +313,146 @@ export class MissionControlWriteContract { }; } + /** + * OPR.0.4.4.19 FR-7 — resolve + unpark (all six arch rails encoded): + * + * 1. A verb in the mission-control family (this method), NOT an overload + * of annotate/route. + * 2. The decision text lands durably + queryably in + * queue_transitions.transition_note with actor_session = the + * resolving session (the composer reads it there forever). + * 3. Unpark = blocked → in-progress via the Phase-A enum-validated + * update (no state-machine change) + the existing nudge machinery. + * 4. Resolution routes BACK to the parked owner — the qitem keeps its + * destination; no new potato owner is ever created here. + * 5. Enforcement symmetry: park required summary + evidence_ref; + * resolve requires NON-EMPTY decision text, daemon-enforced. + * 6. The human acts on the surface/feed card; this is the ONE write + * path (POST /api/mission-control/action verb=resolve) — the CLI + * wrapper is a thin client of the same endpoint. + * + * NON-CLOSURE by contract: verbToClosure has NO resolve mapping — a + * resolved qitem is state=in-progress with closure_reason still null. + * One transaction: transition + decision note + audit row + events. + * The owner nudge (carrying the decision text) is post-commit + * best-effort — the unpark is never lost to a transport failure (BR-8). + */ + private async resolveParked(input: MissionControlActionInput): Promise { + const decision = input.decision?.trim(); + if (!decision) { + throw new MissionControlWriteContractError( + "decision_required", + "verb=resolve requires non-empty decision text — a park without a recorded decision is exactly what this primitive exists to kill", + { verb: input.verb }, + ); + } + const source = this.queueRepo.getById(input.qitemId); + if (!source) { + throw new MissionControlWriteContractError( + "qitem_not_found", + `qitem ${input.qitemId} not found`, + { qitemId: input.qitemId }, + ); + } + if (source.state !== "blocked" || !isHumanSeatSession(source.blockedOn)) { + throw new MissionControlWriteContractError( + "qitem_not_leg1_parked", + `verb=resolve requires a leg-1 parked qitem (state=blocked with a human-seat blocked_on); ` + + `qitem ${input.qitemId} is state=${source.state}, blocked_on=${source.blockedOn ?? "null"}. ` + + `Re-resolving an already-resolved item is a no-op error, not a double transition.`, + { qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn }, + ); + } + + const evaluatedAt = this.now().toISOString(); + const beforeSnapshot = snapshotQitem(source); + let actionEntry: ReturnType | null = null; + const persistedEvents: PersistedEvent[] = []; + + const txn = this.db.transaction(() => { + // Rail 2+3: blocked → in-progress; decision text = transition_note; + // actor_session = the resolving human/relay session. Emits + // queue.updated (the P2 refresh-after-action contract). blocked_on is + // deliberately retained as provenance of whom it was parked on — the + // attention query keys on state='blocked', so the resolved item drops + // out of attention regardless. + const updateResult = this.queueRepo.updateWithinTransaction({ + qitemId: input.qitemId, + actorSession: input.actorSession, + state: "in-progress", + transitionNote: decision, + }); + persistedEvents.push(updateResult.persistedEvent); + + const resolvedQitem = this.queueRepo.getById(input.qitemId); + actionEntry = this.actionLog.record({ + actionVerb: "resolve", + qitemId: input.qitemId, + actorSession: input.actorSession, + actedAt: evaluatedAt, + beforeState: beforeSnapshot, + afterState: resolvedQitem ? snapshotQitem(resolvedQitem) : null, + reason: decision, + auditNotes: input.auditNotes ?? null, + }); + + persistedEvents.push( + this.eventBus.persistWithinTransaction({ + type: "mission_control.action_executed", + actionId: actionEntry.actionId, + actionVerb: "resolve", + qitemId: input.qitemId, + actorSession: input.actorSession, + }), + ); + }); + + try { + txn(); + } catch (err) { + if (err instanceof MissionControlActionLogError) { + throw new MissionControlWriteContractError(err.code, err.message, err.details); + } + throw err; + } + + for (const e of persistedEvents) this.eventBus.notifySubscribers(e); + + // Rail 3 + BR-8: best-effort nudge to the PARKED OWNER carrying the + // decision text, after the commit — nudge outcome recorded via the + // existing last_nudge_* mechanics; a failed nudge never unwinds the + // unpark. + let notifyAttempted = false; + let notifyResult: string | null = null; + if (input.notify !== false) { + try { + await this.queueRepo.maybeNudge( + input.qitemId, + source.destinationSession, + input.notify, + input.actorSession, + `Decision resolved on ${input.qitemId}: ${decision} — unparked (blocked → in-progress); you still own it. Check your queue.`, + ); + notifyAttempted = true; + notifyResult = "attempted-best-effort"; + } catch (err) { + notifyAttempted = true; + notifyResult = `failed:${err instanceof Error ? err.message : String(err)}`; + } + } + + return { + actionId: actionEntry!.actionId, + verb: "resolve", + qitemId: input.qitemId, + closedQitem: this.queueRepo.getById(input.qitemId), + createdQitemId: null, + notifyAttempted, + notifyResult, + auditedAt: evaluatedAt, + }; + } + private requireMutableQitem(qitemId: string): QueueItem { const source = this.queueRepo.getById(qitemId); if (!source) { @@ -379,6 +528,14 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping { "internal_invariant", "annotate verb should have been routed to annotateOnly", ); + case "resolve": + // OPR.0.4.4.19 FR-7 — resolve deliberately has NO closure mapping + // (a resolved qitem is state=in-progress, closure_reason null). + // Handled by resolveParked(); reaching here is a contract violation. + throw new MissionControlWriteContractError( + "internal_invariant", + "resolve verb is non-closure and should have been routed to resolveParked", + ); } } diff --git a/packages/daemon/src/domain/ps-projection.ts b/packages/daemon/src/domain/ps-projection.ts index 4699f9187..eed5895ba 100644 --- a/packages/daemon/src/domain/ps-projection.ts +++ b/packages/daemon/src/domain/ps-projection.ts @@ -1,8 +1,9 @@ import type Database from "better-sqlite3"; -import type { NodeLifecycleState, RigLifecycleState } from "./types.js"; +import type { AgentActivity, NodeInventoryEntry, NodeLifecycleState, RigLifecycleState } from "./types.js"; import { getNodeInventory } from "./node-inventory.js"; import { archiveWhereClause, type RigArchiveFilter } from "./rig-repository.js"; import type { SeatActivityService } from "./seat-activity-service.js"; +import type { AgentActivityStore } from "./agent-activity-store.js"; export interface PsEntry { rigId: string; @@ -49,6 +50,35 @@ export interface PsEntry { periodicSnapshotIntervalSeconds: number; /** OPR.0.3.4.9 — count of auto-periodic snapshots for this rig. */ autoPeriodicSnapshotCount: number; + /** + * OPR.0.4.4.21 — count of seats needing attention (additive field; the + * consolidated default's ATTENTION flag and the host rollup's "K need + * attention" both read it). A seat counts ONCE when ANY signal in + * `seatNeedsAttention` fires. Folded inside the same per-rig inventory + * pass as `lifecycleState` — no extra probes. + */ + attentionCount: number; +} + +/** + * OPR.0.4.4.21 — THE rig-rollup attention predicate (one predicate, one + * count; mirrors + extends the CLI's per-node `needsAttention`). A seat + * needs attention iff ANY of: + * - lifecycle `attention_required` + * - startup `attention_required`/`failed` — counted DIRECTLY from + * startupStatus; `latestError` may legitimately be null here + * - a live runtime hook reporting `needs_input` (stale hooks arrive as + * `unknown` from the store and contribute nothing — never guessed) + * - a held seat (`heldReason` present) + * - a recorded startup error (`latestError` present) + */ +export function seatNeedsAttention(entry: NodeInventoryEntry, activity: AgentActivity | null): boolean { + return entry.lifecycleState === "attention_required" + || entry.startupStatus === "attention_required" + || entry.startupStatus === "failed" + || activity?.state === "needs_input" + || entry.heldReason != null + || entry.latestError != null; } /** @@ -89,12 +119,17 @@ export function deriveRigLifecycleState(nodeStates: NodeLifecycleState[]): RigLi export class PsProjectionService { readonly db: Database.Database; private readonly seatActivity: SeatActivityService | null; + /** OPR.0.4.4.21 — synchronous hook-activity lookup for the attention + * predicate's `needs_input` signal. Optional: absent → the signal + * contributes false (honest degrade), never a guess. */ + private readonly agentActivity: AgentActivityStore | null; private periodicSnapshotActive = false; private periodicSnapshotIntervalSeconds = 0; - constructor(deps: { db: Database.Database; seatActivity?: SeatActivityService }) { + constructor(deps: { db: Database.Database; seatActivity?: SeatActivityService; agentActivity?: AgentActivityStore }) { this.db = deps.db; this.seatActivity = deps.seatActivity ?? null; + this.agentActivity = deps.agentActivity ?? null; } setPeriodicSnapshotState(active: boolean, intervalSeconds: number): void { @@ -171,6 +206,19 @@ export class PsProjectionService { // projection; never from tmux output. const hasWorkCount = countNodesWithPendingWork(this.db, r.rig_id); + // OPR.0.4.4.21 — attention fold, same inventory pass. Hook activity + // is a synchronous events lookup by session name (NodeInventoryEntry + // carries canonicalSessionName, not nodeId); `now` makes staleness + // honest (stale hooks come back `unknown` and contribute nothing). + let attentionCount = 0; + const nowDate = new Date(now); + for (const node of inventory) { + const activity = this.agentActivity && node.canonicalSessionName + ? this.agentActivity.getLatestForNode({ sessionName: node.canonicalSessionName, now: nowDate }) + : null; + if (seatNeedsAttention(node, activity)) attentionCount++; + } + return { rigId: r.rig_id, name: r.name, @@ -188,6 +236,7 @@ export class PsProjectionService { periodicSnapshotActive: this.periodicSnapshotActive, periodicSnapshotIntervalSeconds: this.periodicSnapshotIntervalSeconds, autoPeriodicSnapshotCount: r.auto_periodic_count, + attentionCount, }; }); } diff --git a/packages/daemon/src/domain/queue-repository.ts b/packages/daemon/src/domain/queue-repository.ts index 86c1d8ce9..8b38f9b0a 100644 --- a/packages/daemon/src/domain/queue-repository.ts +++ b/packages/daemon/src/domain/queue-repository.ts @@ -8,6 +8,7 @@ import { validateClosure, type ClosureReason, } from "./hot-potato-enforcer.js"; +import { isHumanSeatSession, validateHumanPark, validateHumanRoute } from "./human-route-enforcer.js"; export const QUEUE_STATES = [ "pending", @@ -44,6 +45,10 @@ export interface QueueItem { * NULL for pre-18 qitems + any an author omitted (the Story consumer * degrades on null). The agent-speak `body` stays the source of truth. */ summary: string | null; + /** OPR.0.4.4.19 FR-5 — pointer to the durable artifact a human judges + * (convention C3). NULL for all non-human-routed items (BR-1); required + * at the domain write path only when the §5 predicate is true. */ + evidenceRef: string | null; closureReason: ClosureReason | null; closureTarget: string | null; closureRequiredAt: string | null; @@ -76,6 +81,7 @@ interface QueueItemRow { chain_of_record: string | null; body: string; summary: string | null; + evidence_ref: string | null; closure_reason: string | null; closure_target: string | null; closure_required_at: string | null; @@ -120,6 +126,9 @@ export interface QueueCreateInput { /** OPR.0.4.1.18 — optional ~1–2 sentence human-readable summary. Persisted * when present; omitted → NULL → Story degrade. */ summary?: string | null; + /** OPR.0.4.4.19 FR-5 — optional durable-artifact pointer. Persisted when + * present; required at the domain layer only for human-routed items. */ + evidenceRef?: string | null; /** * R1 fix (PL-004 Phase A revision): Phase A is durable + waking by default. * When true (or omitted), the repository nudges the destination after the @@ -149,6 +158,16 @@ export interface QueueUpdateInput { * blocker reference (qitem id, gate name) is recoverable from queue state. */ blockedOn?: string; + /** + * OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are + * updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker), + * not create-only: `rig queue block --summary --evidence-ref` persists + * them onto the EXISTING item so the attention query + Packet 2 read + * them. Ignored (not persisted) on non-park transitions to keep the + * update surface tight. + */ + summary?: string | null; + evidenceRef?: string | null; } export interface QueueHandoffInput { @@ -169,6 +188,9 @@ export interface QueueHandoffInput { * inherited from the source (a handoff authors its own summary); omitted * → NULL → Story degrade. */ summary?: string | null; + /** OPR.0.4.4.19 FR-5 — optional durable-artifact pointer for the NEW qitem. + * NOT inherited from the source (same authorship semantics as summary). */ + evidenceRef?: string | null; } /** @@ -250,6 +272,7 @@ export class QueueRepository { * Production daemons always have the column (migration is in startup.ts). */ private readonly hasTargetRepoColumn: boolean; private readonly hasSummaryColumn: boolean; + private readonly hasEvidenceRefColumn: boolean; constructor( db: Database.Database, @@ -274,6 +297,7 @@ export class QueueRepository { this.transport = opts?.transport; this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo"); this.hasSummaryColumn = detectQueueColumn(db, "summary"); + this.hasEvidenceRefColumn = detectQueueColumn(db, "evidence_ref"); // OPR.0.3.2.20 — register the EXACT human-seat regex predicate as // a SQLite function so the attention query can apply the strict @@ -283,10 +307,12 @@ export class QueueRepository { // behind them (guard re-verify-3 qitem-20260518193005 BLOCKER 1). // better-sqlite3 db.function is idempotent; safe to call once at // construction. - db.function("is_human_seat_session", { deterministic: true }, (value: unknown) => { - if (typeof value !== "string") return 0; - return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(value) ? 1 : 0; - }); + // OPR.0.4.4.19: single-source regex — the SQL function delegates to the + // human-route-enforcer's exported predicate so SQL-side and TS-side + // checks cannot drift. + db.function("is_human_seat_session", { deterministic: true }, (value: unknown) => + isHumanSeatSession(value) ? 1 : 0 + ); } /** @@ -322,10 +348,13 @@ export class QueueRepository { destinationSession: string, nudgeOpt: boolean | undefined, sourceSession?: string, + bodyOverride?: string, ): Promise { if (nudgeOpt === false) return; if (!this.transport) return; - const bareBody = `Queue handoff: ${qitemId} - check your queue.`; + // OPR.0.4.4.19 FR-7: bodyOverride lets the resolve verb carry the + // decision text to the parked owner; default stays the handoff nudge. + const bareBody = bodyOverride ?? `Queue handoff: ${qitemId} - check your queue.`; const text = wrapPaneEnvelope(sourceSession, destinationSession, bareBody); try { const res = await this.transport.send(destinationSession, text, { verify: true }); @@ -415,6 +444,20 @@ export class QueueRepository { qitemId: string; persistedEvent: PersistedEvent; } { + // OPR.0.4.4.19 FR-4/FR-5 — human-routed items require summary + + // evidence_ref at the domain write path (the validateClosure pattern). + // The validator is a no-op for non-human-routed items (BR-1). + const humanRoute = validateHumanRoute({ + tier: input.tier ?? null, + destinationSession: input.destinationSession, + summary: input.summary ?? null, + evidenceRef: input.evidenceRef ?? null, + }); + if (!humanRoute.ok) { + throw new QueueRepositoryError(humanRoute.code, humanRoute.message, { + missingFields: humanRoute.missingFields, + }); + } const id = input.qitemId ?? newQitemId(); const ts = new Date().toISOString(); const priority = input.priority ?? "routine"; @@ -444,6 +487,7 @@ export class QueueRepository { .run(id, ts, ts, input.sourceSession, input.destinationSession, priority, tier, tags, expiresAt, chain, input.body); } this.persistSummary(id, input.summary ?? null); + this.persistEvidenceRef(id, input.evidenceRef ?? null); this.transitionLog.append({ qitemId: id, state: "pending", @@ -457,6 +501,7 @@ export class QueueRepository { destinationSession: input.destinationSession, priority, tier, + summary: input.summary ?? null, }); return { qitemId: id, persistedEvent }; } @@ -496,6 +541,21 @@ export class QueueRepository { const chain = JSON.stringify([...(source.chainOfRecord ?? []), source.qitemId]); const targetRepo = input.targetRepo === undefined ? source.targetRepo : input.targetRepo; + // OPR.0.4.4.19 FR-4/FR-5 — the handoff authors a NEW qitem; when that + // new item is human-routed it requires its OWN summary + evidence_ref + // (neither is inherited from the source — 044 semantics preserved). + const humanRoute = validateHumanRoute({ + tier, + destinationSession: input.toSession, + summary: input.summary ?? null, + evidenceRef: input.evidenceRef ?? null, + }); + if (!humanRoute.ok) { + throw new QueueRepositoryError(humanRoute.code, humanRoute.message, { + missingFields: humanRoute.missingFields, + }); + } + const events: Array<{ name: string; payload: import("./types.js").RigEvent }> = []; const txn = this.db.transaction(() => { @@ -541,6 +601,7 @@ export class QueueRepository { } this.persistSummary(newId, input.summary ?? null); + this.persistEvidenceRef(newId, input.evidenceRef ?? null); this.transitionLog.append({ qitemId: newId, @@ -555,6 +616,7 @@ export class QueueRepository { fromSession: input.fromSession, toSession: input.toSession, closureReason: "handed_off_to", + summary: source.summary ?? null, }); events.push({ name: "queue.handed_off", payload: handoffEvent }); @@ -565,6 +627,7 @@ export class QueueRepository { destinationSession: input.toSession, priority, tier, + summary: input.summary ?? null, }); events.push({ name: "queue.created", payload: createdEvent }); }); @@ -619,6 +682,19 @@ export class QueueRepository { const chain = JSON.stringify([...(source.chainOfRecord ?? []), source.qitemId]); const targetRepo = input.targetRepo === undefined ? source.targetRepo : input.targetRepo; + // OPR.0.4.4.19 FR-4/FR-5 — same new-item enforcement as handoff(). + const humanRoute = validateHumanRoute({ + tier, + destinationSession: input.toSession, + summary: input.summary ?? null, + evidenceRef: input.evidenceRef ?? null, + }); + if (!humanRoute.ok) { + throw new QueueRepositoryError(humanRoute.code, humanRoute.message, { + missingFields: humanRoute.missingFields, + }); + } + const events: Array<{ name: string; payload: import("./types.js").RigEvent }> = []; const txn = this.db.transaction(() => { @@ -664,6 +740,7 @@ export class QueueRepository { } this.persistSummary(newId, input.summary ?? null); + this.persistEvidenceRef(newId, input.evidenceRef ?? null); this.transitionLog.append({ qitemId: newId, @@ -678,6 +755,7 @@ export class QueueRepository { fromSession: input.fromSession, toSession: input.toSession, closureReason: "handed_off_to", + summary: source.summary ?? null, }); events.push({ name: "queue.handed_off", payload: handoffEvent }); @@ -688,6 +766,7 @@ export class QueueRepository { destinationSession: input.toSession, priority, tier, + summary: input.summary ?? null, }); events.push({ name: "queue.created", payload: createdEvent }); }); @@ -803,6 +882,7 @@ export class QueueRepository { destinationSession: input.destinationSession, claimedAt: ts, closureRequiredAt, + summary: qitem.summary ?? null, }); }); @@ -848,6 +928,7 @@ export class QueueRepository { qitemId, destinationSession, reason, + summary: qitem.summary ?? null, }); }); @@ -936,6 +1017,32 @@ export class QueueRepository { }); } + // OPR.0.4.4.19 FR-6 — leg-1 park (state=blocked on a HUMAN-seat blocker): + // enforce summary + evidence_ref at the park moment, evaluated on the + // EFFECTIVE values (provided on this call, else already on the item) so + // an item that carried them from create parks without re-entry. The + // enforcement is here at the write path — the `rig queue block` verb and + // raw `update --state blocked` hit the same validator (no verb-only + // enforcement). Blocking on another qitem requires nothing new (BR-1). + const effectiveBlockedOn = input.blockedOn ?? qitem.blockedOn; + const isHumanPark = input.state === "blocked" && isHumanSeatSession(effectiveBlockedOn); + let effectiveSummary = qitem.summary; + let effectiveEvidenceRef = qitem.evidenceRef; + if (isHumanPark) { + effectiveSummary = input.summary ?? qitem.summary; + effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef; + const park = validateHumanPark({ + blockedOn: effectiveBlockedOn, + summary: effectiveSummary, + evidenceRef: effectiveEvidenceRef, + }); + if (!park.ok) { + throw new QueueRepositoryError(park.code, park.message, { + missingFields: park.missingFields, + }); + } + } + const ts = new Date().toISOString(); const fromState = qitem.state; @@ -960,6 +1067,14 @@ export class QueueRepository { input.qitemId ); + // FR-6: park-time summary/evidence_ref are PERSISTED onto the existing + // item (not merely validated-then-dropped) — visible to the attention + // query and to Packet 2. Only the park path writes them. + if (isHumanPark) { + this.persistSummary(input.qitemId, input.summary ?? null); + this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null); + } + this.transitionLog.append({ qitemId: input.qitemId, state: input.state, @@ -977,6 +1092,9 @@ export class QueueRepository { closureReason: validation.closureReason ?? null, closureTarget: validation.closureTarget ?? null, actorSession: input.actorSession, + // FR-1 × FR-6: the event carries the summary as of THIS mutation + // (park-time summary included) so surfaces refresh without a fetch. + summary: effectiveSummary ?? null, }); } @@ -1095,11 +1213,16 @@ export class QueueRepository { // (e.g., 'human-@kernel' — empty name segment) are rejected at // the SQL stage, BEFORE LIMIT, so they cannot saturate the LIMIT // window and hide valid attention items. + // OPR.0.4.4.19 FR-6 — the attention predicate gains the leg-1 park + // clause: a qitem parked as state=blocked on a HUMAN-seat blocker is a + // decision the human owes. Blocking on another qitem (today's shipped + // usage) does NOT match — is_human_seat_session rejects qitem ids. const conditions: string[] = [ `state IN (${statePlaceholders})`, `( tier = 'human-gate' OR is_human_seat_session(destination_session) = 1 + OR (state = 'blocked' AND is_human_seat_session(blocked_on) = 1) )`, ]; const params: unknown[] = [...states]; @@ -1229,6 +1352,14 @@ export class QueueRepository { } } + /** OPR.0.4.4.19 FR-5 — persist the optional evidence_ref additively, same + * contract as persistSummary (pre-048 fixtures degrade; NULL default). */ + private persistEvidenceRef(qitemId: string, evidenceRef: string | null): void { + if (this.hasEvidenceRefColumn && evidenceRef !== null) { + this.db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(evidenceRef, qitemId); + } + } + private rowToItem(row: QueueItemRow): QueueItem { return { qitemId: row.qitem_id, @@ -1249,6 +1380,9 @@ export class QueueRepository { // OPR.0.4.1.18: summary present only when migration 044 has applied; // legacy/minimal fixtures supply rows where summary is undefined → null. summary: row.summary ?? null, + // OPR.0.4.4.19 FR-5: evidence_ref present only when migration 048 has + // applied; legacy fixtures degrade to null. + evidenceRef: row.evidence_ref ?? null, closureReason: row.closure_reason as ClosureReason | null, closureTarget: row.closure_target, closureRequiredAt: row.closure_required_at, diff --git a/packages/daemon/src/domain/review/brief-spine.ts b/packages/daemon/src/domain/review/brief-spine.ts new file mode 100644 index 000000000..261e71949 --- /dev/null +++ b/packages/daemon/src/domain/review/brief-spine.ts @@ -0,0 +1,103 @@ +// Living Notes Packet 2 — MISSION_BRIEF status-spine convergence (OPR.0.4.4.20 FR-8). +// +// ONE computation path: the spine sections (Building / Progress / Proven / +// Needs you) are rendered HERE from the composed mission review; the tab +// serves them always-fresh (pure projection, zero writes) and the SAME +// strings are written into MISSION_BRIEF.md ONLY at the deliberate freeze +// moments — never a watcher, never a continuous writer. Generation is +// SECTION-SCOPED: hand-authored prose in non-spine sections ("What & why", +// "Pointers") is byte-untouched, and the pinned exact-order H2 schema the +// scope audit enforces is preserved by construction (the header list is +// IMPORTED from the audit, not re-declared). + +import { MISSION_BRIEF_HEADERS } from "../scope/scope-audit.js"; +import type { ComposedMissionReview } from "./types.js"; + +export interface BriefSpine { + building: string; + progress: string; + proven: string; + needsYou: string; +} + +/** The four generated section bodies — derived from the same composer + * queries FR-7 renders (no second computation path). */ +export function renderBriefSpine(m: ComposedMissionReview): BriefSpine { + const byLane = (lane: string) => m.board.filter((b) => b.laneLabel === lane); + const buildingRows = [...byLane("BUILD"), ...byLane("PLAN")]; + const building = + buildingRows.length === 0 + ? "_Nothing in flight._" + : buildingRows.map((b) => `- ${b.slice} — ${b.laneLabel} · ${b.stageCell}`).join("\n"); + + const progress = + m.board.length === 0 + ? "_No slices yet._" + : ["INTENT", "PLAN", "BUILD", "REVIEW", "LOCKED"] + .map((lane) => `- ${lane}: ${byLane(lane).length}`) + .join("\n"); + + const greenRows = m.ledger.filter((r) => r.green); + const proven = + greenRows.length === 0 + ? "_Nothing proven yet._" + : greenRows + .map((r) => `- ${r.slice} — proven at ${r.candidateSha ?? "unknown"} · merged ${r.mergeSha ?? "UNMERGED"}`) + .join("\n") + `\n\nCut-gating: ${m.cutComplete ? "COMPLETE" : "incomplete"} — ${m.cutCompleteBasis}`; + + const needsYou = + m.needsYou.items.length === 0 + ? `_${m.needsYou.provenance}_` + : m.needsYou.items.map((i) => `- ${i.source === "derived" ? "▲" : "●"} ${i.summary} (${i.leg})`).join("\n"); + + return { building, progress, proven, needsYou }; +} + +const SPINE_BY_HEADER: Record = { + Building: "building", + Progress: "progress", + Proven: "proven", + "Needs you": "needsYou", +}; + +/** + * Section-scoped application: replaces ONLY the four spine section bodies in + * an existing MISSION_BRIEF.md, preserving every other byte (hand-authored + * "What & why"/"Pointers", the H1, frontmatter, ordering). Returns null when + * the brief does not carry the pinned schema (never guess-rewrite a + * malformed brief — the audit owns that finding). + */ +export function applyBriefSpine(briefContent: string, spine: BriefSpine): string | null { + const lines = briefContent.split("\n"); + // Locate each pinned H2 (exact-order schema). + const headerIdx: number[] = []; + const headerName: string[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i]!.match(/^##\s+(.+?)\s*$/); + if (m) { + headerIdx.push(i); + headerName.push(m[1]!); + } + } + // The brief must carry the exact pinned sequence to be generation-safe. + if (headerName.length !== MISSION_BRIEF_HEADERS.length) return null; + for (let i = 0; i < MISSION_BRIEF_HEADERS.length; i++) { + if (headerName[i] !== MISSION_BRIEF_HEADERS[i]) return null; + } + + const out: string[] = []; + out.push(...lines.slice(0, headerIdx[0]!)); + for (let h = 0; h < headerIdx.length; h++) { + const start = headerIdx[h]!; + const end = h + 1 < headerIdx.length ? headerIdx[h + 1]! : lines.length; + const name = headerName[h]!; + const spineKey = SPINE_BY_HEADER[name]; + out.push(lines[start]!); + if (spineKey) { + out.push("", spine[spineKey], ""); + } else { + out.push(...lines.slice(start + 1, end)); + } + } + return out.join("\n"); +} diff --git a/packages/daemon/src/domain/review/compose.ts b/packages/daemon/src/domain/review/compose.ts new file mode 100644 index 000000000..41e85b8a6 --- /dev/null +++ b/packages/daemon/src/domain/review/compose.ts @@ -0,0 +1,1072 @@ +// Living Notes — the deterministic composer (OPR.0.4.4.20, rebuilt per the +// CORRECTIVE REDESIGN of 2026-07-05). +// +// PURE: `(gathered inputs) -> composed doc`. Same inputs (including the +// caller-supplied view-time facts nowIso/mainTip/git facts) -> byte-identical +// output. Every section has a named SSOT and a degrade value; a missing +// source renders its degrade, never invented content. The gatherer that +// assembles inputs from disk/queue/git lives beside this file; keeping the +// core pure is what makes the idempotence AC hold by construction. +// +// CORRECTIVE §3.1 — the composer builds ONE renderable structure per slice: +// the INTENT → PLAN → DELIVERED stack. It no longer emits `sections`, +// `acceptance`, `compare`, `join`, or a coequal `green` field. The +// recorded-verdict rigor behind the old green lives on in two places only: +// the per-deliverable `verified` signal (§11) and the mission ledger's +// completion green (FR-7 — a mission-altitude fact, not a slice structure). + +import YAML from "yaml"; +import * as posixPath from "node:path/posix"; +import { renderBriefSpine } from "./brief-spine.js"; +import { + C1_ARTIFACT_TYPES, + C1_VERDICTS, + GATE_ROLES, + PHASE_LANE_LABELS, + type AgentRow, + type AgentsBand, + type AgentsScope, + type BoardSlot, + type C1ArtifactType, + type C1Verdict, + type ComposedMissionReview, + type ComposedRigAgents, + type ComposedSliceReview, + type DeliveredItem, + type DerivedException, + type GateRole, + type LedgerRow, + type LockState, + type LockedArtifact, + type NeedsYouBand, + type NeedsYouItem, + type ProofArtifact, + type ReviewMedia, + type ReviewPhase, + type SettledRow, + type VerdictCell, + type VerdictTone, + type VerifyLineage, +} from "./types.js"; + +// --- Fixed, visible v1 thresholds (markdown-steered tuning is a named fast-follow) --- +export const IDLE_WITH_WORK_THRESHOLD_MIN = 30; + +// --------------------------------------------------------------------------- +// Media refs (shared shape helpers — pure string work, no filesystem) +// --------------------------------------------------------------------------- + +const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); +const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".m4v"]); + +function extOf(ref: string): string { + const clean = ref.split(/[?#]/)[0]!; + const i = clean.lastIndexOf("."); + return i === -1 ? "" : clean.slice(i).toLowerCase(); +} + +export function mediaKind(ref: string): ReviewMedia["kind"] | null { + const ext = extOf(ref); + if (IMAGE_EXTS.has(ext)) return "image"; + if (VIDEO_EXTS.has(ext)) return "video"; + return null; +} + +/** Markdown/HTML media refs in a source string (http(s) refs excluded — + * media is co-located slice content, FR-5). */ +export function extractMediaRefs(markdown: string | null): string[] { + if (!markdown) return []; + const refs: string[] = []; + const re = /!\[[^\]]*\]\(([^)]+)\)|<(?:video|img|source)[^>]*\ssrc="([^"]+)"/g; + for (const m of markdown.matchAll(re)) { + const ref = (m[1] ?? m[2] ?? "").trim(); + if (ref && !ref.startsWith("http")) refs.push(ref); + } + return refs; +} + +/** Normalizes a media ref written relative to `baseDir` (slice-relative "") + * into a slice-relative path. Returns null when the ref is absolute or + * escapes the slice dir — the caller records the defect (FR-5), never a + * silent drop. */ +export function sliceRelativeMediaPath(ref: string, baseDir: string): string | null { + if (ref.startsWith("/")) return null; + const joined = baseDir ? posixPath.join(baseDir, ref) : ref; + const normalized = posixPath.normalize(joined); + if (normalized === ".." || normalized.startsWith("../")) return null; + return normalized; +} + +function toReviewMedia(ref: string, baseDir: string): ReviewMedia | null { + const kind = mediaKind(ref); + if (!kind) return null; + const src = sliceRelativeMediaPath(ref, baseDir); + if (!src) return null; + return { kind, src, caption: ref }; +} + +function dedupMedia(media: ReviewMedia[]): ReviewMedia[] { + const seen = new Set(); + return media.filter((m) => { + if (seen.has(m.src)) return false; + seen.add(m.src); + return true; + }); +} + +// --------------------------------------------------------------------------- +// C1 header parsing +// --------------------------------------------------------------------------- + +/** Parses a proof artifact's YAML frontmatter into a ProofArtifact. + * Out-of-set / missing verdicts become null (a present artifact is not a + * verdict — FR-2); the parse never throws on malformed input. Body media + * refs are captured for the §3.4 curated-proof projection. */ +export function parseC1Header(content: string, relPath: string, droppedAtIso: string): ProofArtifact { + const out: ProofArtifact = { + relPath, + slice: null, + candidateSha: null, + artifactType: null, + verdict: null, + moneyEvidence: null, + evidences: [], + selfCheck: null, + mediaRefs: [], + droppedAt: droppedAtIso, + }; + const m = content.match(/^---\n([\s\S]*?)\n---/); + out.mediaRefs = extractMediaRefs(m ? content.slice(m[0].length) : content); + if (!m) return out; + let fm: Record; + try { + fm = (YAML.parse(m[1]!) ?? {}) as Record; + } catch { + return out; + } + const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : v != null && typeof v !== "object" ? String(v) : null); + out.slice = str(fm["slice"]); + out.candidateSha = str(fm["candidate_sha"]); + const at = str(fm["artifact_type"]); + out.artifactType = at && (C1_ARTIFACT_TYPES as readonly string[]).includes(at) ? (at as C1ArtifactType) : null; + const v = str(fm["verdict"]); + out.verdict = v && (C1_VERDICTS as readonly string[]).includes(v) ? (v as C1Verdict) : null; + out.moneyEvidence = str(fm["money_evidence"]); + out.selfCheck = str(fm["self_check"]); + const ev = fm["evidences"]; + if (Array.isArray(ev)) out.evidences = ev.map((e) => String(e)).filter((e) => e.trim().length > 0); + return out; +} + +// --------------------------------------------------------------------------- +// FR-2 — verdict selection, pass-mapping, lineage (KEEP) +// --------------------------------------------------------------------------- + +/** The pinned pass-mapping ("passing" is never left to interpretation). */ +export function isPassing(artifactType: C1ArtifactType, verdict: C1Verdict | null): boolean { + if (verdict === null) return false; + if (artifactType === "qa") return verdict === "PASS"; + if (artifactType === "adjudication") return verdict === "CLEAR" || verdict === "PASS"; + // guard, rev1-r1, rev1-r2 + return verdict === "CLEAR"; +} + +function toneFor(artifactType: C1ArtifactType, verdict: C1Verdict | null): VerdictTone { + if (verdict === null) return "unknown"; + return isPassing(artifactType, verdict) ? "pass" : "fail"; +} + +/** Latest-wins per (candidate_sha, artifact_type) — the ratified C1 selection + * rule. A non-passing verdict is superseded only by a LATER artifact of the + * SAME tuple, never by adjacent artifacts, presence, or approval. */ +export function selectWinning( + artifacts: ProofArtifact[], + candidateSha: string | null, +): Map { + const winning = new Map(); + for (const a of artifacts) { + if (!a.artifactType) continue; + if (candidateSha !== null && a.candidateSha !== candidateSha) continue; + const prev = winning.get(a.artifactType); + if (!prev || a.droppedAt > prev.droppedAt || (a.droppedAt === prev.droppedAt && a.relPath > prev.relPath)) { + winning.set(a.artifactType, a); + } + } + return winning; +} + +/** The candidate under judgment: the candidate_sha of the latest-dropped gate + * artifact (deterministic; ties broken by relPath). Null when no artifact + * carries one. */ +export function deriveCandidateSha(artifacts: ProofArtifact[]): string | null { + let best: ProofArtifact | null = null; + for (const a of artifacts) { + if (!a.candidateSha || !a.artifactType || a.artifactType === "adjudication") continue; + if (!best || a.droppedAt > best.droppedAt || (a.droppedAt === best.droppedAt && a.relPath > best.relPath)) best = a; + } + return best?.candidateSha ?? null; +} + +export function deriveGateCells(artifacts: ProofArtifact[], candidateSha: string | null): VerdictCell[] { + const winning = selectWinning(artifacts, candidateSha); + return GATE_ROLES.map((role: GateRole): VerdictCell => { + const a = winning.get(role); + if (!a || a.verdict === null) { + return { role, recordedToken: null, tone: "unknown", state: "missing", source: a?.relPath ?? null }; + } + return { + role, + recordedToken: a.verdict, + tone: toneFor(role, a.verdict), + state: isPassing(role, a.verdict) ? "passing" : "non-passing", + source: a.relPath, + }; + }); +} + +export interface GitFacts { + mainTip: string; + /** Parsed from `Merge OPR.` subjects; null = unmerged. */ + mergeSha: string | null; + /** Post-merge: is the merge an ancestor of tip? */ + mergeIsAncestorOfTip: boolean | null; + /** Pre-merge: commits the candidate's merge-base is behind tip; null = unknown. */ + candidateBehindTip: number | null; +} + +/** Near-tip tolerance for the pre-merge fresh label. */ +const FRESH_BEHIND_TOLERANCE = 3; + +export function composeLineage( + candidateSha: string | null, + git: GitFacts, + gateCells: VerdictCell[], +): VerifyLineage { + let freshness: VerifyLineage["freshness"] = "unknown"; + let staleBehind: number | null = null; + if (git.mergeSha !== null) { + if (git.mergeIsAncestorOfTip !== null) freshness = git.mergeIsAncestorOfTip ? "fresh" : "stale"; + } else if (candidateSha !== null && git.candidateBehindTip !== null) { + if (git.candidateBehindTip <= FRESH_BEHIND_TOLERANCE) { + freshness = "fresh"; + } else { + freshness = "stale"; + staleBehind = git.candidateBehindTip; + } + } + return { candidateSha, mergeSha: git.mergeSha, mainTip: git.mainTip, freshness, staleBehind, gateCells }; +} + +// --------------------------------------------------------------------------- +// §4 — the two locks (the SHIPPED staged-approval stamps, arch F-A) +// --------------------------------------------------------------------------- + +export interface ApprovalStampFacts { + by: string; + at: string; + /** One-query cross-check against the pinned scope-approval audit shape. */ + auditRowPresent: boolean; +} + +export interface ApprovalFacts { + /** `--scope spec` stamp (approved-spec-by/at) → plan.lock. */ + spec: ApprovalStampFacts | null; + /** `--scope delivery` stamp (approved-by/at) → delivered.lock. */ + delivery: ApprovalStampFacts | null; +} + +export function lockFrom(stamp: ApprovalStampFacts | null): LockState | null { + if (!stamp) return null; + return { by: stamp.by, at: stamp.at, auditVerified: stamp.auditRowPresent }; +} + +// --------------------------------------------------------------------------- +// Mission-ledger green (FR-7). NOT a slice-review structure: the slice +// contract's coequal `green` field is REMOVED (§11); this recorded-verdict +// computation feeds the mission completion ledger + the regime-2 +// confirm-faithful trigger only. Approval NEVER colors it (BR-6). +// --------------------------------------------------------------------------- + +export interface RecordedGreen { + green: boolean; + /** 1 = full gate-verdict set; 2 = adjudicated confirm-faithful; null when not green. */ + regime: 1 | 2 | null; +} + +export function computeRecordedGreen( + gateCells: VerdictCell[], + artifacts: ProofArtifact[], + candidateSha: string | null, +): RecordedGreen { + if (gateCells.every((c) => c.state === "passing")) return { green: true, regime: 1 }; + const adjudication = selectWinning(artifacts, candidateSha).get("adjudication"); + if (adjudication && isPassing("adjudication", adjudication.verdict)) return { green: true, regime: 2 }; + return { green: false, regime: null }; +} + +/** Convenience for the mission gatherer: derive the ledger green straight + * from a slice's artifacts (candidate + gate cells derived internally). */ +export function composeRecordedGreenForSlice(artifacts: ProofArtifact[]): RecordedGreen { + const candidateSha = deriveCandidateSha(artifacts); + return computeRecordedGreen(deriveGateCells(artifacts, candidateSha), artifacts, candidateSha); +} + +// --------------------------------------------------------------------------- +// FR-1 — markdown structure extraction (KEEP the extractors; the sections/ +// acceptance/compare structures they used to feed are gone) +// --------------------------------------------------------------------------- + +/** Extracts a `## ` section body, verbatim (character-identical). */ +export function extractSection(markdown: string | null, heading: string): string | null { + if (!markdown) return null; + const re = new RegExp(`^##\\s+${heading}\\s*$`, "im"); + const m = re.exec(markdown); + if (!m) return null; + const start = m.index + m[0].length; + const rest = markdown.slice(start); + const next = rest.search(/^##?\s/m); + const body = next === -1 ? rest : rest.slice(0, next); + return body.replace(/^\n+/, "").replace(/\s+$/, ""); +} + +/** The pinned mini-requirements tier at the PRD top (plan.concise.text). */ +export function extractMiniReqs(prd: string | null): string | null { + return extractSection(prd, "Mini-requirements(?:[^\\n]*)?"); +} + +/** One promised deliverable from the D2 `## Proof contract` (§3.1): the item + * text plus an optional planned-mockup ref written as a markdown image on + * the same checkbox line (`- [ ] drawer opens right ![mockup](mockups/x.png)`). */ +export interface PromisedItem { + text: string; + plannedRef: string | null; +} + +export function extractProofContract(prd: string | null): PromisedItem[] { + const body = extractSection(prd, "Proof contract"); + if (!body) return []; + const items: PromisedItem[] = []; + for (const line of body.split("\n")) { + const m = line.match(/^\s*-?\s*\[(?:\s|x|X)\]\s+(.+)$/); + if (!m) continue; + let text = m[1]!.trim(); + let plannedRef: string | null = null; + const img = text.match(/!\[[^\]]*\]\(([^)]+)\)/); + if (img) { + plannedRef = img[1]!.trim(); + text = text.replace(img[0], "").replace(/\s{2,}/g, " ").trim(); + } + items.push({ text, plannedRef }); + } + return items; +} + +// --------------------------------------------------------------------------- +// FR-3 — derived phase (five-way, top-down precedence — KEEP) +// --------------------------------------------------------------------------- + +export interface PhaseSignals { + prdPresent: boolean; + /** ANY delivery signal: a proof artifact OR a claimed/in-progress slice-tagged qitem. */ + proofArtifactPresent: boolean; + activeQitemPresent: boolean; + /** Evidence/verdict set present (any recorded verdict OR a claimed PROOF.md + media set). */ + verdictOrEvidenceSetPresent: boolean; + /** The delivery approval stamp. */ + approved: boolean; +} + +/** Top-down by precedence — locked > review > building > spec > intent — + * stated explicitly because one signal can satisfy two lanes. */ +export function derivePhase(s: PhaseSignals): ReviewPhase { + if (s.approved) return "locked"; + if (s.verdictOrEvidenceSetPresent) return "review"; + if (s.proofArtifactPresent || s.activeQitemPresent) return "building"; + if (s.prdPresent) return "spec"; + return "intent"; +} + +// --------------------------------------------------------------------------- +// §3.1 DELIVERED — the redesigned join: planned ↔ curated proof ↔ verified +// --------------------------------------------------------------------------- + +/** An `evidences:` ref matches a promised item by exact text or 1-based index. */ +function refMatches(ref: string, promised: PromisedItem[], index: number): boolean { + const trimmed = ref.trim(); + return trimmed === promised[index]!.text || trimmed === String(index + 1); +} + +export interface ComposedDelivered { + items: DeliveredItem[]; + extraProof: ReviewMedia[]; + /** Feeds ▲ insufficient-proof (FR-4) and the board's building-cell n/m (FR-7). */ + missingCount: number; + /** Artifact media refs that escape the slice dir (FR-5 defect findings). */ + escapingRefs: string[]; +} + +/** + * `delivered.items` IS the join, reframed (§3.1): each `## Proof contract` + * deliverable pairs with the CURATED proof media of the artifacts covering it + * and QA's recorded comparison signal. + * + * `verified` binds to the SHIPPED C1 fields (arch F3), never presence: + * verified — a covering qa|adjudication artifact records the comparison + * (self_check) AND its recorded verdict is passing; + * unverified — some covering artifact exists but no passing recorded QA + * comparison (QA's why-kicked-back note still surfaces); + * missing — promised, nothing delivered. + * Fail-open by construction: these are render states, never blocks. + */ +export function composeDelivered(promised: PromisedItem[], artifacts: ProofArtifact[]): ComposedDelivered { + const escaping = new Set(); + const mediaOf = (a: ProofArtifact): ReviewMedia[] => { + const out: ReviewMedia[] = []; + for (const ref of a.mediaRefs) { + const m = toReviewMedia(ref, "proof"); + if (m) out.push(m); + else if (mediaKind(ref)) escaping.add(ref); + } + return out; + }; + const byLatest = (a: ProofArtifact, b: ProofArtifact) => + a.droppedAt < b.droppedAt ? 1 : a.droppedAt > b.droppedAt ? -1 : a.relPath.localeCompare(b.relPath); + + const covered = new Set(); + const items: DeliveredItem[] = promised.map((p, i) => { + const covering = artifacts.filter((a) => a.evidences.some((ref) => refMatches(ref, promised, i))).sort(byLatest); + covering.forEach((a) => covered.add(a.relPath)); + const qaCovering = covering.filter((a) => a.artifactType === "qa" || a.artifactType === "adjudication"); + const verifiedBy = qaCovering.find((a) => a.selfCheck !== null && a.artifactType !== null && isPassing(a.artifactType, a.verdict)); + const noteSource = qaCovering.find((a) => a.selfCheck !== null) ?? covering.find((a) => a.selfCheck !== null); + const plannedRef = p.plannedRef ? toReviewMedia(p.plannedRef, "") : null; + if (p.plannedRef && !plannedRef && mediaKind(p.plannedRef)) escaping.add(p.plannedRef); + const item: DeliveredItem = { + promised: plannedRef ? { text: p.text, plannedRef } : { text: p.text }, + proof: dedupMedia(covering.flatMap(mediaOf)), + verified: verifiedBy ? "verified" : covering.length > 0 ? "unverified" : "missing", + }; + const note = noteSource?.selfCheck ?? null; + if (note) item.note = note; + return item; + }); + + // Helpful-but-unmapped artifacts (§6): their media renders bounded under + // the extraProof label — visible, never dropped, never a primary-view pile. + const extraProof = dedupMedia( + artifacts + .filter((a) => !covered.has(a.relPath)) + .sort(byLatest) + .flatMap(mediaOf), + ); + + return { + items, + extraProof, + missingCount: items.filter((it) => it.verified === "missing").length, + escapingRefs: [...escaping].sort(), + }; +} + +// --------------------------------------------------------------------------- +// FR-4 — NEEDS YOU (two sources, one queue) + AGENTS (KEEP) +// --------------------------------------------------------------------------- + +export interface AttentionInput { + qitemId: string; + summary: string | null; + leg: string; + where: string; + createdAtIso: string | null; + priority: string | null; + tier: string | null; + evidenceRef: string | null; + unblocks: string | null; + destinationSession: string | null; + closureRequiredAtIso: string | null; +} + +export interface AgentInput { + agentName: string; + sessionName: string; + runtime: AgentRow["runtime"]; + /** Queue-proven park target (human/qitem/etc.); null means no parked row state. */ + parkedOn: string | null; + /** null = telemetry down (honest-unknown). */ + idle: boolean | null; + idleSinceIso: string | null; + doing: string | null; + holdsCount: number; + lastTransitionIso: string | null; + slices: string[]; +} + +function minutesBetween(aIso: string, bIso: string): number { + return Math.floor((Date.parse(bIso) - Date.parse(aIso)) / 60_000); +} + +/** The delivered-completeness facts the ▲ insufficient-proof rule reads + * (was the old join's counts; §3.1 re-bind — the signal is the + * delivered.items MISSING count). */ +export interface DeliveredCounts { + promisedCount: number; + missingCount: number; +} + +/** The four ▲ exception rules over captured signals. Every row carries its + * evidence + crossed threshold; no evidence -> no exception (never a bare + * accusation). A ▲ is information for the human, invisible to the flagged + * agent's workflow. */ +export function deriveExceptions( + agents: AgentInput[], + attention: AttentionInput[], + delivered: DeliveredCounts, + scopeLabel: string, + nowIso: string, + latestArtifactIso: string | null, + governingStampIso: string | null, +): NeedsYouItem[] { + const items: NeedsYouItem[] = []; + const push = (identity: string, summary: string, d: DerivedException) => { + items.push({ + source: "derived", + identity, + summary, + leg: d.kind, + where: scopeLabel, + ageIso: null, + priority: null, + tier: null, + evidenceRef: null, + unblocks: null, + qitemId: null, + destinationSession: null, + derived: d, + }); + }; + + for (const a of agents) { + if (a.idle === true && a.holdsCount > 0 && a.idleSinceIso) { + const idleMin = minutesBetween(a.idleSinceIso, nowIso); + if (idleMin >= IDLE_WITH_WORK_THRESHOLD_MIN) { + push( + `${a.sessionName}|stuck|${a.idleSinceIso}`, + `${a.agentName} looks stuck`, + { + kind: "stuck", + evidence: `idle ${idleMin}m >= ${IDLE_WITH_WORK_THRESHOLD_MIN}m default · holds ${a.holdsCount}`, + threshold: `idle-with-work >= ${IDLE_WITH_WORK_THRESHOLD_MIN}m`, + }, + ); + } + } + } + for (const q of attention) { + if (q.closureRequiredAtIso && q.closureRequiredAtIso < nowIso) { + push( + `${q.qitemId}|overdue|${q.closureRequiredAtIso}`, + `${q.summary ?? q.qitemId} is overdue`, + { + kind: "overdue", + evidence: `closure required at ${q.closureRequiredAtIso} · now ${nowIso}`, + threshold: "past closure_required_at", + }, + ); + } + } + if (delivered.promisedCount > 0 && delivered.missingCount > 0) { + push( + `${scopeLabel}|insufficient-proof|${delivered.missingCount}`, + `insufficient proof: ${delivered.missingCount}/${delivered.promisedCount} promised items missing`, + { + kind: "insufficient-proof", + evidence: `${delivered.missingCount} of ${delivered.promisedCount} promised deliverables have no delivered evidence`, + threshold: "delivered.items MISSING count > 0", + }, + ); + } + if (latestArtifactIso && governingStampIso && latestArtifactIso > governingStampIso) { + push( + `${scopeLabel}|stale-after-change|${latestArtifactIso}`, + "artifacts changed after the governing stamp", + { + kind: "stale-after-change", + evidence: `artifact at ${latestArtifactIso} is newer than the stamp at ${governingStampIso}`, + threshold: "artifact newer than governing stamp", + }, + ); + } + return items; +} + +export function composeNeedsYou( + attention: AttentionInput[], + derived: NeedsYouItem[], + confirmFaithful: NeedsYouItem[], + computedOver: string, + nowIso: string, +): NeedsYouBand { + const agentItems: NeedsYouItem[] = attention.map((q) => ({ + source: "agent", + identity: q.qitemId, + summary: q.summary ?? q.qitemId, + leg: q.leg, + where: q.where, + ageIso: q.createdAtIso, + priority: q.priority, + tier: q.tier, + evidenceRef: q.evidenceRef, + unblocks: q.unblocks, + qitemId: q.qitemId, + destinationSession: q.destinationSession, + derived: null, + })); + // One-count identity rule: distinct identities within this scope only. + const seen = new Set(); + const all = [...agentItems, ...confirmFaithful, ...derived].filter((i) => { + if (seen.has(i.identity)) return false; + seen.add(i.identity); + return true; + }); + // Priority-ordered, most-consequential-first: explicit priority rank, then age. + const rank: Record = { urgent: 0, high: 1, normal: 2, low: 3 }; + all.sort((a, b) => { + const ra = rank[a.priority ?? "normal"] ?? 2; + const rb = rank[b.priority ?? "normal"] ?? 2; + if (ra !== rb) return ra - rb; + return (a.ageIso ?? "9999") < (b.ageIso ?? "9999") ? -1 : 1; + }); + return { + items: all, + provenance: + all.length === 0 + ? `0 attention items · 0 parks · 0 unconfirmed proofs — computed from ${computedOver} at ${nowIso}` + : `computed from ${computedOver} at ${nowIso}`, + }; +} + +// --- OPR.0.4.4.22 (slice 22): agent-scope exceptions + the rig read root (KEEP) --- + +/** The third NAMED ▲ heuristic's visible v1 default (slice-22 FR-3: + * too-long-in-state — no transition beyond threshold). Lives HERE because + * compose.ts is the single threshold home (P2 arch note N2): changes land + * once and every altitude inherits. Same markdown-steered tuning + * fast-follow as IDLE_WITH_WORK_THRESHOLD_MIN. */ +export const TOO_LONG_IN_STATE_THRESHOLD_MIN = 120; + +/** + * Slice-22 FR-3 — the agent-scope ▲ set: exactly the three NAMED heuristics + * (idle-with-assigned-work, overdue handoff, too-long-in-state). The first + * two REUSE deriveExceptions' rules (called with zero delivered counts and + * no artifact/stamp facts, so the slice-only insufficient-proof / + * stale-after-change rules cannot fire); too-long-in-state is added here — + * additively, so slice-scope composition is byte-unchanged. No evidence → + * no exception (unknown is not idle; unknown lastTransition is not + * too-long). + */ +export function deriveAgentScopeExceptions( + agents: AgentInput[], + attention: AttentionInput[], + scopeLabel: string, + nowIso: string, +): NeedsYouItem[] { + const items = deriveExceptions(agents, attention, { promisedCount: 0, missingCount: 0 }, scopeLabel, nowIso, null, null); + for (const a of agents) { + if (a.holdsCount > 0 && a.lastTransitionIso) { + const sinceMin = minutesBetween(a.lastTransitionIso, nowIso); + if (sinceMin >= TOO_LONG_IN_STATE_THRESHOLD_MIN) { + items.push({ + source: "derived", + identity: `${a.sessionName}|too-long-in-state|${a.lastTransitionIso}`, + summary: `${a.agentName} has not transitioned in ${sinceMin}m`, + leg: "stuck", + where: scopeLabel, + ageIso: null, + priority: null, + tier: null, + evidenceRef: null, + unblocks: null, + qitemId: null, + destinationSession: null, + derived: { + kind: "stuck", + evidence: `no transition for ${sinceMin}m >= ${TOO_LONG_IN_STATE_THRESHOLD_MIN}m default · holds ${a.holdsCount}`, + threshold: `too-long-in-state >= ${TOO_LONG_IN_STATE_THRESHOLD_MIN}m`, + }, + }); + } + } + } + return items; +} + +export interface RigComposeInputs { + agents: AgentInput[]; + overdue: AttentionInput[]; + attention: AttentionInput[]; + settled: SettledRow[]; + handoffsToday: number; + overdueCount: number; + /** The FR-1 roster display window, named on-surface in provenance + * (plan-review ruling: "computed from queue+ps · window: today"). */ + rosterWindow: string; + nowIso: string; +} + +/** Slice-22 FR-1..FR-4 — the rig-scope composition root. PURE: same inputs, + * byte-identical output (idempotence is a money proof). */ +export function composeRigAgents(inputs: RigComposeInputs): ComposedRigAgents { + const { nowIso } = inputs; + const scopeLabel = "rig"; + const derived = deriveAgentScopeExceptions(inputs.agents, inputs.overdue, scopeLabel, nowIso); + const needsYou = composeNeedsYou( + inputs.attention, + derived, + [], + `queue+ps (rig scope) · window: ${inputs.rosterWindow}`, + nowIso, + ); + const band = composeAgentsBand(inputs.agents, "rig", derived, nowIso); + return { + scope: "rig", + needsYou, + agents: { + ...band, + provenance: + band.rows.length === 0 + ? `no agents holding or recently holding work — computed from queue+ps · window: ${inputs.rosterWindow} · at ${nowIso}` + : `computed from queue+ps · window: ${inputs.rosterWindow} · at ${nowIso}`, + // FR-4: one health line per scope, from the transitions log. + coordinationHealth: `${inputs.handoffsToday} handoffs today · ${inputs.overdueCount} overdue`, + }, + settled: inputs.settled, + settledProvenance: + inputs.settled.length === 0 + ? `0 handoffs today — computed from queue transitions · window: today · at ${nowIso}` + : `computed from queue transitions · window: today · at ${nowIso}`, + composedAt: nowIso, + }; +} + +/** Region membership derives from work-on-THIS-scope, never rig co-residency. */ +export function composeAgentsBand( + agents: AgentInput[], + scope: AgentsScope, + exceptions: NeedsYouItem[], + nowIso: string, +): AgentsBand { + const rows: AgentRow[] = agents.map((a) => { + const ex = exceptions.find((e) => e.derived && e.identity.startsWith(`${a.sessionName}|`)); + return { + agentName: a.agentName, + runtime: a.runtime, + stateGlyph: a.parkedOn ? "parked" : a.idle === null ? "unknown" : a.idle ? "idle" : "active", + doing: a.doing, + holdsCount: a.holdsCount, + lastTransitionIso: a.lastTransitionIso, + exception: ex?.derived ?? null, + sessionName: a.sessionName, + slices: a.slices, + }; + }); + return { + scope, + rows, + provenance: + rows.length === 0 + ? `no agents holding or recently holding work — computed from queue at ${nowIso}` + : `computed from queue at ${nowIso}`, + coordinationHealth: null, + }; +} + +// --------------------------------------------------------------------------- +// The slice composition root — the ONE structure (§3.1) +// --------------------------------------------------------------------------- + +export interface SliceComposeInputs { + slice: { name: string; id: string | null; title: string; missionId: string | null }; + /** Raw file contents (null = absent). */ + readme: string | null; + prd: string | null; + proofMd: string | null; + artifacts: ProofArtifact[]; + /** The pinned plan set — a frontmatter READ (`locked-artifacts:` on the slice README). */ + lockedArtifacts: LockedArtifact[]; + /** Media refs found in composed sources; absolute/escaping paths are defect findings (FR-5). */ + mediaRefs: string[]; + /** True when the slice's proof/ dir exists (the "see all proof" drill-in target). */ + proofDirExists: boolean; + attention: AttentionInput[]; + agents: AgentInput[]; + activeQitemPresent: boolean; + git: GitFacts; + approval: ApprovalFacts; + nowIso: string; +} + +/** A self-asserted PASS in the slice's own PROOF.md (never a verdict). */ +export function proofClaimsPass(proofMd: string | null): boolean { + if (!proofMd) return false; + return proofMd + .split(/\r?\n/) + .some((line) => /^\s*(?:Closed by:.*\bVerdict:|Verdict:|Result:)\s*PASS\b/i.test(line)); +} + +function sectionMedia(sectionBody: string | null, escaping: Set): ReviewMedia[] { + const out: ReviewMedia[] = []; + for (const ref of extractMediaRefs(sectionBody)) { + const m = toReviewMedia(ref, ""); + if (m) out.push(m); + else if (mediaKind(ref)) escaping.add(ref); + } + return out; +} + +export function composeSliceReview(inputs: SliceComposeInputs): ComposedSliceReview { + const { slice, nowIso } = inputs; + const sliceRef = `${slice.missionId ?? "?"}/slices/${slice.name}`; + + const candidateSha = deriveCandidateSha(inputs.artifacts); + const gateCells = deriveGateCells(inputs.artifacts, candidateSha); + const lineage = composeLineage(candidateSha, inputs.git, gateCells); + const planLock = lockFrom(inputs.approval.spec); + const deliveredLock = lockFrom(inputs.approval.delivery); + + const escaping = new Set(); + const intentText = extractSection(inputs.readme, "Intent"); + const intentMedia = sectionMedia(intentText, escaping); + const miniReqs = extractMiniReqs(inputs.prd); + const planMedia = dedupMedia([ + ...sectionMedia(miniReqs, escaping), + ...inputs.lockedArtifacts + .map((a): ReviewMedia | null => (mediaKind(a.path) ? toReviewMedia(a.path, "") : null)) + .filter((m): m is ReviewMedia => m !== null), + ]); + + const promised = extractProofContract(inputs.prd); + const delivered = composeDelivered(promised, inputs.artifacts); + delivered.escapingRefs.forEach((r) => escaping.add(r)); + + const claimedPass = proofClaimsPass(inputs.proofMd); + const anyRecordedVerdict = gateCells.some((c) => c.state !== "missing"); + const evidencePresent = inputs.artifacts.length > 0 || claimedPass; + + const phase = derivePhase({ + prdPresent: inputs.prd !== null, + proofArtifactPresent: inputs.artifacts.length > 0 || inputs.proofMd !== null, + activeQitemPresent: inputs.activeQitemPresent, + verdictOrEvidenceSetPresent: anyRecordedVerdict || claimedPass, + approved: deliveredLock !== null, + }); + + // Regime 2: evidence present, NO recorded passing state -> confirm-faithful. + const recordedGreen = computeRecordedGreen(gateCells, inputs.artifacts, candidateSha); + const confirmFaithful: NeedsYouItem[] = []; + if (!recordedGreen.green && evidencePresent && claimedPass) { + confirmFaithful.push({ + source: "agent", + identity: `${slice.name}|confirm-faithful|${candidateSha ?? "no-sha"}`, + summary: "confirm this proof is faithful", + leg: "confirm-faithful", + where: sliceRef, + ageIso: null, + priority: "high", + tier: null, + evidenceRef: `${sliceRef}/PROOF.md`, + unblocks: `${slice.name} green (regime 2)`, + qitemId: null, + destinationSession: null, + derived: null, + }); + } + + const latestArtifactIso = inputs.artifacts.reduce( + (acc, a) => (acc === null || a.droppedAt > acc ? a.droppedAt : acc), + null, + ); + const derived = deriveExceptions( + inputs.agents, + inputs.attention, + { promisedCount: promised.length, missingCount: delivered.missingCount }, + sliceRef, + nowIso, + latestArtifactIso, + inputs.approval.delivery?.at ?? null, + ); + const needsYou = composeNeedsYou(inputs.attention, derived, confirmFaithful, "queue+artifacts", nowIso); + const agents = composeAgentsBand(inputs.agents, `slice:${slice.name}`, derived, nowIso); + + // FR-5: an out-of-slice media ref = a defect finding, never a silent + // no-render. Absolute paths AND ../ traversal segments both escape the + // co-located contract (rev1 fixback at d6135921 — slice-19 containment class). + const defects = [ + ...inputs.mediaRefs + .filter((r) => r.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(r)) + .map((r) => + r.startsWith("/") + ? `absolute media path (must be slice-relative): ${r}` + : `media ref escapes the slice dir (must be co-located): ${r}`, + ), + ...[...escaping] + .sort() + .map((r) => + r.startsWith("/") + ? `absolute media path (must be slice-relative): ${r}` + : `media ref escapes the slice dir (must be co-located): ${r}`, + ), + ]; + + return { + slice: slice.name, + sliceId: slice.id, + title: slice.title, + missionId: slice.missionId, + phase, + laneLabel: PHASE_LANE_LABELS[phase], + intent: { + text: intentText, + media: intentMedia, + ssotPath: inputs.readme !== null ? `${sliceRef}/README.md` : null, + degrade: intentText === null ? "no intent recorded" : null, + }, + plan: { + concise: { text: miniReqs, media: planMedia }, + lockedArtifacts: inputs.lockedArtifacts, + lock: planLock, + ssotPath: inputs.prd !== null ? `${sliceRef}/IMPLEMENTATION-PRD.md` : null, + }, + delivered: { + items: delivered.items, + extraProof: delivered.extraProof, + lock: deliveredLock, + proofDirPath: inputs.proofDirExists ? `${sliceRef}/proof` : null, + }, + needsYou, + agents, + lineage, + defects, + composedAt: nowIso, + }; +} + +// --------------------------------------------------------------------------- +// FR-7 — mission composition (board + ledger + union bands — KEEP; the +// ledger's green is the recorded-verdict completion fact at mission altitude) +// --------------------------------------------------------------------------- + +export interface MissionSliceEntry { + review: ComposedSliceReview; + /** Recorded-verdict green for the completion ledger (computeRecordedGreen + * over the slice's artifacts — supplied by the gatherer, which holds them). */ + green: boolean; +} + +export interface MissionComposeInputs { + mission: { name: string; id: string | null; title: string; intent?: string | null }; + slices: MissionSliceEntry[]; + missionAttention: AttentionInput[]; + agents: AgentInput[]; + nowIso: string; +} + +export function composeMissionReview(inputs: MissionComposeInputs): ComposedMissionReview { + const { nowIso } = inputs; + + const board: BoardSlot[] = inputs.slices.map(({ review: s, green }) => { + let stageCell: string; + switch (s.phase) { + case "spec": + stageCell = s.plan.lock ? `spec-approved ${s.plan.lock.at}` : "spec unstamped"; + break; + case "building": + stageCell = + s.delivered.items.length > 0 + ? `${s.delivered.items.filter((it) => it.verified !== "missing").length}/${s.delivered.items.length} proofs` + : "building"; + break; + case "review": + stageCell = `${green ? "GREEN" : "not green"} · ${s.lineage.mergeSha ?? "UNMERGED"}`; + break; + case "locked": + stageCell = `stamped ${s.delivered.lock?.at ?? "?"}`; + break; + default: + stageCell = "intent"; + } + const changedSinceStamp = s.needsYou.items.some((i) => i.derived?.kind === "stale-after-change"); + return { + slice: s.slice, + title: s.title, + phase: s.phase, + laneLabel: s.laneLabel, + agentsCount: s.agents.rows.length, + stageCell, + changedSinceStamp, + attentionWorthy: s.needsYou.items.length > 0 || changedSinceStamp, + }; + }); + + // The completion ledger — a query over the mission's slice set, never an + // authored list (omission-proof by construction). + const ledger: LedgerRow[] = inputs.slices.map(({ review: s, green }) => ({ + slice: s.slice, + candidateSha: s.lineage.candidateSha, + gateCells: s.lineage.gateCells, + mergeSha: s.lineage.mergeSha, + needsHumanCount: s.needsYou.items.length, + green, + })); + + // Cut-complete: TRUE only when EVERY in-cut slice is (a) green, (b) merged, + // (c) zero open needs-human items — never asserted from a status field. + const incomplete = ledger.filter((r) => !(r.green && r.mergeSha !== null && r.needsHumanCount === 0)); + const cutComplete = ledger.length > 0 && incomplete.length === 0; + + // Mission NEEDS YOU = the union query (slice ∪ mission ∪ ▲), distinct + // identities — an item at N altitudes is one item seen from N heights. + const seen = new Set(); + const unionItems: NeedsYouItem[] = []; + for (const { review: s } of inputs.slices) { + for (const i of s.needsYou.items) { + if (!seen.has(i.identity)) { + seen.add(i.identity); + unionItems.push(i); + } + } + } + const missionBand = composeNeedsYou(inputs.missionAttention, [], [], "mission queue+slice unions", nowIso); + for (const i of missionBand.items) { + if (!seen.has(i.identity)) { + seen.add(i.identity); + unionItems.push(i); + } + } + + const agents = composeAgentsBand(inputs.agents, `mission:${inputs.mission.name}`, [], nowIso); + + const composed: ComposedMissionReview = { + mission: inputs.mission.name, + missionId: inputs.mission.id, + title: inputs.mission.title, + intent: inputs.mission.intent ?? null, + briefSpine: { building: "", progress: "", proven: "", needsYou: "" }, + board, + ledger, + cutComplete, + cutCompleteBasis: cutComplete + ? `all ${ledger.length} in-cut slices green + merged + zero needs-human · computed at ${nowIso}` + : `${incomplete.length} of ${ledger.length} slices not cut-complete (${incomplete.map((r) => r.slice).join(", ") || "none"}) · computed at ${nowIso}`, + needsYou: { + items: unionItems, + provenance: + unionItems.length === 0 + ? `0 attention items across ${inputs.slices.length} slices — computed from queue+artifacts at ${nowIso}` + : `union of ${inputs.slices.length} slice scopes + mission scope · computed at ${nowIso}`, + }, + agents, + composedAt: nowIso, + }; + composed.briefSpine = renderBriefSpine(composed); + return composed; +} diff --git a/packages/daemon/src/domain/review/freeze.ts b/packages/daemon/src/domain/review/freeze.ts new file mode 100644 index 000000000..f0f7f2498 --- /dev/null +++ b/packages/daemon/src/domain/review/freeze.ts @@ -0,0 +1,280 @@ +// Living Notes Packet 2 — approval-triggered frozen export (OPR.0.4.4.20 FR-6). +// +// ONE synchronous compose-and-freeze path, invoked AFTER the approval +// stamp + audit row commit (Packet 1 FR-9's side of the interface cell). +// The renderer is a MINIMAL string-template renderer (arch pin): the +// composed doc -> one static, self-contained HTML file — CSS inline, +// images inlined as data: URIs, video referenced by LINK with its poster +// inline, zero external fetches, zero heavy dependencies. The contract is +// the ACs (file:// self-contained), not the twin build's vite plugin — +// that plugin is build-time prior art, mirrored here at runtime, not reused. +// +// Failure semantics: the stamp and audit row are never touched here — a +// failed render never un-approves. The write is exclusive-create through +// the daemon's atomic file-write path (allowlist-governed, actor-audited); +// an existing export makes re-invocation an idempotent no-op. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { FileWriteService } from "../files/file-write-service.js"; +import { FileWriteError } from "../files/file-write-service.js"; +import type { AllowlistRoot } from "../files/path-safety.js"; +import type { ComposedSliceReview, LockState, VerdictCell } from "./types.js"; + +const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); +const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov"]); +const MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", +}; + +function esc(s: string | null | undefined): string { + return (s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function dataUri(absPath: string): string | null { + const ext = path.extname(absPath).toLowerCase(); + const mime = MIME[ext]; + if (!mime) return null; + try { + return `data:${mime};base64,${fs.readFileSync(absPath).toString("base64")}`; + } catch { + return null; + } +} + +function verdictChip(cell: VerdictCell): string { + // G1: the chip text is the RECORDED token verbatim; tone is a separate class. + const token = cell.recordedToken ?? "missing"; + return `${esc(cell.role)}: ${esc(token)}`; +} + +/** + * Slice-dir containment for media inlining (rev1 fixback at d6135921 — the + * slice-19 path-containment class): the frozen export is the designed future + * broadcast payload, so a traversal/symlink ref must NEVER data-URI-inline a + * file from outside the slice dir. Resolve-prefix check first, then a + * realpath check on existing files so a symlink inside the slice cannot + * escape either. Returns the safe absolute path, or null (renders the muted + * unavailable branch — never silent, never inlined). + */ +function containedMediaPath(sliceDir: string, ref: string): string | null { + if (ref.startsWith("/")) return null; + const rootResolved = path.resolve(sliceDir); + const resolved = path.resolve(sliceDir, ref); + if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) return null; + try { + const real = fs.realpathSync(resolved); + const realRoot = fs.realpathSync(rootResolved); + if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null; + return real; + } catch { + // Missing file: containment already proven by the resolve-prefix check; + // the caller's existsSync/dataUri handles absence with the muted branch. + return resolved; + } +} + +/** Renders media found in the slice dir: images inline, video by link + poster. */ +function mediaBlock(sliceDir: string, mediaRefs: string[]): string { + const parts: string[] = []; + const images = mediaRefs.filter((r) => IMAGE_EXTS.has(path.extname(r).toLowerCase())); + const videos = mediaRefs.filter((r) => VIDEO_EXTS.has(path.extname(r).toLowerCase())); + for (const ref of images) { + const safe = containedMediaPath(sliceDir, ref); + const uri = safe ? dataUri(safe) : null; + if (uri) parts.push(`

${esc(ref)}
${esc(ref)}
`); + else parts.push(`

${safe ? "image unavailable" : "media outside slice dir"}: ${esc(ref)}

`); + } + for (const ref of videos) { + if (containedMediaPath(sliceDir, ref) === null) { + parts.push(`

media outside slice dir: ${esc(ref)}

`); + continue; + } + // Video is NEVER embedded (single-file simplicity); the co-located + // screenshot poster (same basename) inlines beside the link when present. + const base = ref.slice(0, ref.length - path.extname(ref).length); + const poster = [".png", ".jpg", ".jpeg"] + .map((e) => `${base}${e}`) + .find((p) => { + const safePoster = containedMediaPath(sliceDir, p); + return safePoster !== null && fs.existsSync(safePoster); + }); + const safePosterPath = poster ? containedMediaPath(sliceDir, poster) : null; + const posterUri = safePosterPath ? dataUri(safePosterPath) : null; + parts.push( + `
${posterUri ? `poster for ${esc(ref)}` : ""}` + + `
video (by link): ${esc(ref)}
`, + ); + } + return parts.join("\n"); +} + +const STYLE = ` +body{font-family:ui-serif,Georgia,serif;max-width:52rem;margin:2rem auto;padding:0 1rem;color:#1c1917;background:#fafaf9} +h1,h2{font-family:ui-sans-serif,system-ui,sans-serif} +.chip{display:inline-block;border:1px solid #a8a29e;border-radius:4px;padding:.1rem .45rem;margin:.1rem;font-family:ui-monospace,monospace;font-size:.8rem} +.tone-pass{background:#dcfce7}.tone-fail{background:#fee2e2}.tone-unknown{background:#f5f5f4} +.locked{border:2px solid #1c1917;padding:.6rem 1rem;margin:1rem 0;font-weight:600} +.unverified{border:3px solid #b91c1c;color:#b91c1c;padding:.6rem 1rem;margin:1rem 0;font-weight:700} +.muted{color:#78716c} +.col{border:1px solid #d6d3d1;padding: .75rem;margin:.5rem 0} +table{border-collapse:collapse;width:100%}td,th{border:1px solid #d6d3d1;padding:.3rem .5rem;text-align:left;font-size:.9rem} +img{max-width:100%;height:auto;border:1px solid #d6d3d1} +figure{margin:1rem 0}figcaption{font-size:.8rem;color:#78716c} +pre{white-space:pre-wrap;font-family:inherit} +`; + +function stampLine(label: string, lock: LockState | null): string { + if (!lock) return `

○ ${esc(label)} — not stamped

`; + return lock.auditVerified + ? `
✓ ${esc(label)} — ${esc(lock.by)} at ${esc(lock.at)}
` + : `
UNVERIFIED ${esc(label)} stamp — frontmatter claims ${esc(lock.by)} at ${esc(lock.at)} but NO matching audit row exists
`; +} + +const VERIFIED_WORDS: Record = { + verified: "✓ QA-verified against the plan", + unverified: "◇ unverified — no recorded QA comparison", + missing: "✗ missing — promised, nothing delivered", +}; + +/** The frozen single-file HTML for a composed slice review — the ONE + * INTENT → PLAN → DELIVERED stack (CORRECTIVE §3.1), mirrored statically. */ +export function renderFrozenSliceHtml(composed: ComposedSliceReview, sliceDir: string, mediaRefs: string[]): string { + const lineage = composed.lineage; + const deliveredRows = composed.delivered.items + .map( + (it) => + `${esc(it.promised.text)}${esc(VERIFIED_WORDS[it.verified] ?? it.verified)}${esc(it.note ?? "—")}${it.proof.length === 0 ? "—" : it.proof.map((p) => esc(p.src)).join("
")}`, + ) + .join("\n"); + + // Evidence media = the source-scan refs plus the curated proof set + // (slice-relative srcs) — inlined via the same contained-path discipline. + const evidenceRefs = [ + ...new Set([ + ...mediaRefs, + ...composed.delivered.items.flatMap((it) => it.proof.map((p) => p.src)), + ...composed.delivered.extraProof.map((p) => p.src), + ]), + ]; + + return ` + + + + +REVIEW — ${esc(composed.title)} + + + +

${esc(composed.title)}

+

${esc(composed.slice)}${composed.sliceId ? ` · ${esc(composed.sliceId)}` : ""} · lane ${esc(composed.laneLabel)} · frozen from composition at ${esc(composed.composedAt)}

+${stampLine("proof-lock (done)", composed.delivered.lock)} + +

Verify lineage

+

proven-at: ${esc(lineage.candidateSha ?? "unknown")} · merged-at: ${esc(lineage.mergeSha ?? "UNMERGED")} · main tip: ${esc(lineage.mainTip)} · ${esc(lineage.freshness)}${lineage.staleBehind !== null ? ` (${lineage.staleBehind} behind)` : ""}

+

${lineage.gateCells.map(verdictChip).join(" ")}

+ +

Intent

+
${esc(composed.intent.text ?? composed.intent.degrade)}
+ +

Plan

+
${esc(composed.plan.concise.text ?? "—")}
+${composed.plan.lockedArtifacts.length > 0 ? `

locked set: ${composed.plan.lockedArtifacts.map((a) => `${esc(a.name)} (${esc(a.kind)}: ${esc(a.path)})`).join(" · ")}

` : ""} +${stampLine("plan-lock (this set gets built)", composed.plan.lock)}
+ +

Delivered

+${composed.delivered.items.length === 0 ? `

no proof contract declared in the plan

` : `${deliveredRows}
promisedverifiedQA notecurated proof
`} +${composed.delivered.extraProof.length > 0 ? `

extra proof (not tied to one deliverable): ${composed.delivered.extraProof.map((p) => esc(p.src)).join(" · ")}

` : ""} + +

Evidence

+${mediaBlock(sliceDir, evidenceRefs)} + +

Needs-you at freeze time

+${composed.needsYou.items.length === 0 ? `

${esc(composed.needsYou.provenance)}

` : `
    ${composed.needsYou.items.map((i) => `
  • ${esc(i.summary)} (${esc(i.leg)})
  • `).join("\n")}
`} + +${composed.defects.length > 0 ? `

Defect findings

    ${composed.defects.map((d) => `
  • ${esc(d)}
  • `).join("")}
` : ""} + + +`; +} + +export function frozenFileName(scopeId: string | null, fallbackName: string, approvedAtIso: string | null): string { + const id = (scopeId ?? fallbackName).replaceAll("/", "-"); + const date = approvedAtIso ? approvedAtIso.slice(0, 10) : "undated"; + return `REVIEW-${id}-${date}.html`; +} + +export type FreezeOutcome = + | { ok: true; path: string; alreadyFrozen: boolean } + | { ok: false; error: "stamp_missing" | "not_found" | "allowlist_missing" | "write_failed"; message: string; hint?: string }; + +/** Maps an absolute dir into (allowlist root, relative path) or null. */ +export function resolveAllowlisted(allowlist: AllowlistRoot[], absDir: string): { root: string; rel: string } | null { + let real: string; + try { + real = fs.realpathSync(absDir); + } catch { + return null; + } + for (const r of allowlist) { + if (real === r.canonicalPath || real.startsWith(r.canonicalPath + path.sep)) { + return { root: r.name, rel: path.relative(r.canonicalPath, real) }; + } + } + return null; +} + +export function freezeSliceExport(opts: { + composed: ComposedSliceReview; + sliceDir: string; + mediaRefs: string[]; + allowlist: AllowlistRoot[]; + writeService: FileWriteService; + actor: string; +}): FreezeOutcome { + const { composed } = opts; + if (!composed.delivered.lock) { + return { + ok: false, + error: "stamp_missing", + message: `slice '${composed.slice}' carries no delivery approval stamp; the freeze fires only after the approve verb commits (stamp + audit row stand regardless of this render)`, + }; + } + const mapped = resolveAllowlisted(opts.allowlist, opts.sliceDir); + if (!mapped) { + return { + ok: false, + error: "allowlist_missing", + message: `slice folder '${opts.sliceDir}' is not under any OPENRIG_FILES_ALLOWLIST root — the freeze write path is allowlist-governed`, + hint: `add OPENRIG_FILES_ALLOWLIST=:${path.dirname(opts.sliceDir)} (or a parent root) and restart the daemon`, + }; + } + const fileName = frozenFileName(composed.sliceId, composed.slice, composed.delivered.lock.at); + const relPath = path.join(mapped.rel, fileName); + const html = renderFrozenSliceHtml(composed, opts.sliceDir, opts.mediaRefs); + try { + const result = opts.writeService.createAtomic({ rootName: mapped.root, path: relPath, content: html, actor: opts.actor }); + return { ok: true, path: result.absolutePath, alreadyFrozen: false }; + } catch (err) { + if (err instanceof FileWriteError && err.code === "target_exists") { + // Idempotent re-invoke for the same approval: the export already exists + // and frozen exports are never rewritten (invariant 8). + return { ok: true, path: path.join(opts.sliceDir, fileName), alreadyFrozen: true }; + } + return { + ok: false, + error: "write_failed", + message: `freeze render/write failed (the approval stamp and audit row still stand; re-invoke after fixing): ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/packages/daemon/src/domain/review/gather.ts b/packages/daemon/src/domain/review/gather.ts new file mode 100644 index 000000000..adc1760f2 --- /dev/null +++ b/packages/daemon/src/domain/review/gather.ts @@ -0,0 +1,732 @@ +// Living Notes Packet 2 — the input gatherer (OPR.0.4.4.20). +// +// The impure shell around the pure composer: reads slice docs + proof +// artifacts from disk, attention/agent rows from SQLite, approval stamps +// from frontmatter (cross-checked against the Packet-1 audit-target +// contract), and git facts from the workspace's default repo. Every source +// that cannot be read degrades honestly (nulls / "unknown") — the composer +// renders the named degrade, never invented content. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import YAML from "yaml"; +import type Database from "better-sqlite3"; +import type { SliceIndexer, SliceRecord } from "../slices/slice-indexer.js"; +import { + composeMissionReview, + composeRecordedGreenForSlice, + composeSliceReview, + deriveCandidateSha, + extractMediaRefs, + extractSection, + parseC1Header, + type AgentInput, + type ApprovalFacts, + type ApprovalStampFacts, + type AttentionInput, + type GitFacts, + type MissionSliceEntry, + type SliceComposeInputs, +} from "./compose.js"; +import type { AgentsBand, AgentsScope, ComposedMissionReview, ComposedRigAgents, ComposedSliceReview, LockedArtifact, SettledRow } from "./types.js"; +import { composeAgentsBand, composeRigAgents } from "./compose.js"; +import type { AgentActivityStore } from "../agent-activity-store.js"; +import { isHumanSeatSession } from "../human-route-enforcer.js"; + +export interface ReviewGathererDeps { + db: Database.Database; + indexer: SliceIndexer; + /** Repo path for git lineage facts (the workspace default repo); null = degrade to unknown. */ + gitRepoPath?: string | null; + /** OPR.0.4.4.22 — hook-activity reads for the agent state glyph (FR-2). + * Optional: absent → every glyph degrades to honest `unknown` (never + * guessed). Synchronous SQLite reads; no polling, no agent contact. */ + activityStore?: AgentActivityStore | null; + /** Injected clock so composition stays reproducible in tests. */ + now?: () => string; +} + +interface QitemRow { + qitem_id: string; + ts_created: string; + destination_session: string; + state: string; + priority: string | null; + tier: string | null; + tags: string | null; + summary: string | null; + blocked_on: string | null; + closure_required_at: string | null; + ts_updated: string; +} + +const ACTIVE_STATES = ["pending", "in-progress", "claimed", "blocked", "handed-off"]; + +export class ReviewGatherer { + private readonly db: Database.Database; + private readonly indexer: SliceIndexer; + private readonly gitRepoPath: string | null; + private readonly activityStore: AgentActivityStore | null; + private readonly now: () => string; + + constructor(deps: ReviewGathererDeps) { + this.db = deps.db; + this.indexer = deps.indexer; + this.gitRepoPath = deps.gitRepoPath ?? null; + this.activityStore = deps.activityStore ?? null; + this.now = deps.now ?? (() => new Date().toISOString()); + } + + composeSlice(name: string): ComposedSliceReview | null { + const inputs = this.gatherSlice(name); + return inputs ? composeSliceReview(inputs) : null; + } + + /** Composition plus the on-disk context the freeze renderer needs (FR-6). */ + composeSliceWithContext(name: string): { composed: ComposedSliceReview; sliceDir: string; mediaRefs: string[] } | null { + const slice = this.indexer.get(name); + const inputs = this.gatherSlice(name); + if (!slice || !inputs) return null; + return { composed: composeSliceReview(inputs), sliceDir: slice.slicePath, mediaRefs: inputs.mediaRefs }; + } + + composeMission(mission: string): ComposedMissionReview | null { + const slices = this.indexer.list().filter((s) => s.missionId === mission); + if (slices.length === 0 && !this.missionDirExists(mission)) return null; + const nowIso = this.now(); + // The ledger's recorded-verdict green rides beside each composed review + // (FR-7): the gatherer holds the artifacts, so it derives the mission- + // altitude completion fact the slice contract no longer carries (§11). + const composed = slices + .map((s): MissionSliceEntry | null => { + const inputs = this.gatherSlice(s.name); + if (!inputs) return null; + return { + review: composeSliceReview(inputs), + green: composeRecordedGreenForSlice(inputs.artifacts).green, + }; + }) + .filter((s): s is MissionSliceEntry => s !== null); + const missionMeta = this.readMissionMeta(mission); + return composeMissionReview({ + mission: { name: mission, id: missionMeta.id, title: missionMeta.title, intent: missionMeta.intent }, + slices: composed, + missionAttention: this.attentionForTag(`mission:${mission}`, `slice:`), + agents: this.agentsForSlices(slices.map((s) => s.name)), + nowIso, + }); + } + + /** + * OPR.0.4.4.22 — the composed rig-agents read root (FR-1..FR-4): NEEDS + * YOU + AGENTS (health line) + SETTLED at rig scope. Pure projection over + * queue + hook-activity; nothing contacts an agent. The roster is + * active-holders UNION recently-holding (transitions-on-scope-TODAY — the + * plan-review-ruled display window, named in provenance). + */ + composeRig(): ComposedRigAgents { + const nowIso = this.now(); + const todayStart = `${nowIso.slice(0, 10)}T00:00:00.000Z`; + const agents = this.withTelemetry(this.rigRoster(todayStart), nowIso); + const attention = this.attentionAll(); + const overdue = this.overdueWork(nowIso); + const { settled, handoffsToday } = this.settledToday(todayStart); + return composeRigAgents({ + agents, + overdue, + attention, + settled, + handoffsToday, + overdueCount: overdue.length, + rosterWindow: "today", + nowIso, + }); + } + + /** The scope-parameterized agents projection — ONE contract, all consumers. */ + composeAgents(scope: AgentsScope): AgentsBand | null { + const nowIso = this.now(); + if (scope === "rig") { + return composeAgentsBand(this.agentsForSlices(null), scope, [], nowIso); + } + if (scope.startsWith("slice:")) { + const name = scope.slice("slice:".length); + if (!this.indexer.get(name)) return null; + return composeAgentsBand(this.agentsForSlices([name]), scope, [], nowIso); + } + const mission = scope.slice("mission:".length); + const slices = this.indexer.list().filter((s) => s.missionId === mission); + if (slices.length === 0 && !this.missionDirExists(mission)) return null; + return composeAgentsBand(this.agentsForSlices(slices.map((s) => s.name)), scope, [], nowIso); + } + + // ------------------------------------------------------------------------- + + gatherSlice(name: string): SliceComposeInputs | null { + const slice = this.indexer.get(name); + if (!slice) return null; + + const readme = this.readFile(path.join(slice.slicePath, "README.md")); + const prd = this.readFile(path.join(slice.slicePath, "IMPLEMENTATION-PRD.md")); + const proofMd = this.readFile(path.join(slice.slicePath, "PROOF.md")); + + const artifacts = this.readProofArtifacts(slice.slicePath); + const attention = this.attentionForTag(`slice:${name}`); + const agents = this.agentsForSlices([name]); + const frontmatter = this.parseFrontmatter(readme); + const approval = this.gatherApproval(slice, frontmatter); + const candidateRef = deriveCandidateSha(artifacts); + + return { + slice: { + name: slice.name, + id: typeof frontmatter["id"] === "string" ? (frontmatter["id"] as string) : null, + title: slice.displayName, + missionId: slice.missionId, + }, + readme, + prd, + proofMd, + artifacts, + lockedArtifacts: this.parseLockedArtifacts(frontmatter), + mediaRefs: this.collectMediaRefs([readme, prd, proofMd]), + proofDirExists: fs.existsSync(path.join(slice.slicePath, "proof")), + attention, + agents, + activeQitemPresent: this.hasActiveQitem(name, slice), + git: this.gatherGitFacts(frontmatter, candidateRef), + approval, + nowIso: this.now(), + }; + } + + /** §3.1's ONLY genuinely new datum: the pinned plan set, a frontmatter READ + * (`locked-artifacts:` list on the slice README — the scope-fs pattern, + * zero new file kinds, zero new write machinery). Malformed entries are + * skipped, never invented. */ + private parseLockedArtifacts(fm: Record): LockedArtifact[] { + const raw = fm["locked-artifacts"]; + if (!Array.isArray(raw)) return []; + const out: LockedArtifact[] = []; + for (const entry of raw) { + if (entry === null || typeof entry !== "object") continue; + const e = entry as Record; + const p = typeof e["path"] === "string" ? e["path"].trim() : ""; + if (!p) continue; + out.push({ + name: typeof e["name"] === "string" && e["name"].trim() ? e["name"].trim() : p, + path: p, + kind: typeof e["kind"] === "string" && e["kind"].trim() ? e["kind"].trim() : "artifact", + }); + } + return out; + } + + private readFile(p: string): string | null { + try { + return fs.readFileSync(p, "utf8"); + } catch { + return null; + } + } + + private missionDirExists(mission: string): boolean { + // The indexer knows slices; a slice-less mission still composes (empty board). + return this.indexer.list().some((s) => s.missionId === mission); + } + + private readMissionMeta(mission: string): { id: string | null; title: string; intent: string | null; missionDir: string | null } { + const anySlice = this.indexer.list().find((s) => s.missionId === mission); + if (anySlice) { + const missionDir = path.dirname(path.dirname(anySlice.slicePath)); + const readme = this.readFile(path.join(missionDir, "README.md")); + const fm = this.parseFrontmatter(readme); + // FR-8: the brief's "What & why" projects VERBATIM as the intent opener. + const brief = this.readFile(path.join(missionDir, "MISSION_BRIEF.md")); + return { + id: typeof fm["id"] === "string" ? (fm["id"] as string) : null, + title: typeof fm["title"] === "string" ? (fm["title"] as string) : mission, + intent: extractSection(brief, "What & why"), + missionDir, + }; + } + return { id: null, title: mission, intent: null, missionDir: null }; + } + + /** FR-8 freeze-moment brief write target: {absolute path, current content}. */ + missionBriefTarget(mission: string): { briefPath: string; content: string } | null { + const meta = this.readMissionMeta(mission); + if (!meta.missionDir) return null; + const briefPath = path.join(meta.missionDir, "MISSION_BRIEF.md"); + const content = this.readFile(briefPath); + return content === null ? null : { briefPath, content }; + } + + private parseFrontmatter(content: string | null): Record { + if (!content) return {}; + const m = content.match(/^---\n([\s\S]*?)\n---/); + if (!m) return {}; + try { + return (YAML.parse(m[1]!) ?? {}) as Record; + } catch { + return {}; + } + } + + private readProofArtifacts(sliceDir: string) { + const proofDir = path.join(sliceDir, "proof"); + let entries: string[] = []; + try { + entries = fs.readdirSync(proofDir).filter((f) => f.endsWith(".md")); + } catch { + return []; + } + return entries + .sort() + .map((f) => { + const full = path.join(proofDir, f); + try { + const content = fs.readFileSync(full, "utf8"); + const mtime = fs.statSync(full).mtime.toISOString(); + return parseC1Header(content, `proof/${f}`, mtime); + } catch { + return null; + } + }) + .filter((a): a is NonNullable => a !== null); + } + + /** Markdown image/video refs across the composed sources (FR-5 defect scan). */ + private collectMediaRefs(sources: Array): string[] { + return sources.flatMap((s) => extractMediaRefs(s)); + } + + private tableExists(name: string): boolean { + try { + const row = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name); + return row !== undefined; + } catch { + return false; + } + } + + private columnExists(table: string, column: string): boolean { + try { + const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + return rows.some((r) => r.name === column); + } catch { + return false; + } + } + + /** Human-routed attention rows carrying the given tag (the §5 predicate is + * Packet 1's; until it lands, human-tier/human-dest/park-on-human rows are + * selected with the same shape). */ + private attentionForTag(tag: string, excludeTagPrefix?: string): AttentionInput[] { + if (!this.tableExists("queue_items")) return []; + const hasEvidenceRef = this.columnExists("queue_items", "evidence_ref"); + const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary"; + const rows = this.db + .prepare( + `SELECT qitem_id, ts_created, ts_updated, destination_session, state, priority, tier, tags, ${summaryCol}, + blocked_on, closure_required_at${hasEvidenceRef ? ", evidence_ref" : ""} + FROM queue_items + WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) + AND tags LIKE ? + AND (tier = 'human-gate' + OR destination_session LIKE 'human%' + OR (state = 'blocked' AND blocked_on LIKE 'human%'))`, + ) + .all(...ACTIVE_STATES, `%"${tag}"%`) as Array; + return rows + .filter((r) => { + if (!excludeTagPrefix) return true; + try { + const tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? []; + return !tags.some((t) => t.startsWith(excludeTagPrefix)); + } catch { + return true; + } + }) + .map((r) => ({ + qitemId: r.qitem_id, + summary: r.summary, + leg: r.state === "blocked" ? "park-on-human" : "human-routed", + where: r.destination_session, + createdAtIso: r.ts_created, + priority: r.priority, + tier: r.tier, + evidenceRef: r.evidence_ref ?? null, + unblocks: r.state === "blocked" ? r.qitem_id : null, + destinationSession: r.destination_session, + closureRequiredAtIso: r.closure_required_at, + })); + } + + /** Sessions holding active work on the named slices (null = rig-wide). + * Region membership derives from work-on-scope, never rig co-residency. + * Runtime/idle telemetry is honest-unknown at v1 (queue-derived only). */ + private agentsForSlices(sliceNames: string[] | null): AgentInput[] { + if (!this.tableExists("queue_items")) return []; + const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary"; + const rows = this.db + .prepare( + `SELECT qitem_id, ts_created, ts_updated, destination_session, state, priority, tier, tags, ${summaryCol}, + blocked_on, closure_required_at + FROM queue_items + WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")})`, + ) + .all(...ACTIVE_STATES) as QitemRow[]; + const bySession = new Map }>(); + for (const r of rows) { + if (isHumanSeatSession(r.destination_session)) continue; + let tags: string[] = []; + try { + tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? []; + } catch { + tags = []; + } + const rowSlices = tags.filter((t) => t.startsWith("slice:")).map((t) => t.slice("slice:".length)); + if (sliceNames !== null && !rowSlices.some((s) => sliceNames.includes(s))) continue; + const entry = bySession.get(r.destination_session) ?? { rows: [], slices: new Set() }; + entry.rows.push(r); + for (const s of rowSlices) entry.slices.add(s); + bySession.set(r.destination_session, entry); + } + return [...bySession.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([session, e]) => { + const latest = e.rows.reduce((acc, r) => (r.ts_updated > acc.ts_updated ? r : acc), e.rows[0]!); + const parked = e.rows + .filter((r) => !!r.blocked_on) + .reduce((acc, r) => (!acc || r.ts_updated > acc.ts_updated ? r : acc), null); + return { + agentName: session.split("@")[0] ?? session, + sessionName: session, + runtime: "unknown" as const, + parkedOn: parked?.blocked_on ?? null, + idle: null, // honest-unknown: queue rows alone cannot prove liveness + idleSinceIso: null, + doing: parked?.summary ?? latest.summary, + holdsCount: e.rows.length, + lastTransitionIso: (parked ?? latest).ts_updated, + slices: [...e.slices].sort(), + }; + }); + } + + // --- OPR.0.4.4.22 rig-scope helpers (all synchronous SQLite reads) --- + + /** FR-1 roster: agents HOLDING active work on any slice-tagged item, UNION + * agents RECENTLY holding (their slice-tagged items transitioned today — + * the ruled display window). Membership derives from work-on-scope, + * never rig co-residency. */ + private rigRoster(todayStartIso: string): AgentInput[] { + const holders = this.agentsForSlices(null); + if (!this.tableExists("queue_items")) return holders; + const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary"; + // Recently-holding: destination of a slice-tagged qitem whose latest + // update landed today but is no longer in an active state. + const rows = this.db + .prepare( + `SELECT qitem_id, ts_created, ts_updated, destination_session, state, priority, tier, tags, ${summaryCol}, + blocked_on, closure_required_at + FROM queue_items + WHERE ts_updated >= ? + AND state NOT IN (${ACTIVE_STATES.map(() => "?").join(",")}) + AND tags LIKE '%"slice:%'`, + ) + .all(todayStartIso, ...ACTIVE_STATES) as QitemRow[]; + const known = new Set(holders.map((h) => h.sessionName)); + const recent = new Map }>(); + for (const r of rows) { + if (isHumanSeatSession(r.destination_session)) continue; + if (known.has(r.destination_session)) continue; + let tags: string[] = []; + try { + tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? []; + } catch { + tags = []; + } + const rowSlices = tags.filter((t) => t.startsWith("slice:")).map((t) => t.slice("slice:".length)); + if (rowSlices.length === 0) continue; + const entry = recent.get(r.destination_session) ?? { latest: r, slices: new Set() }; + if (r.ts_updated > entry.latest.ts_updated) entry.latest = r; + for (const s of rowSlices) entry.slices.add(s); + recent.set(r.destination_session, entry); + } + const recentInputs: AgentInput[] = [...recent.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([session, e]) => ({ + agentName: session.split("@")[0] ?? session, + sessionName: session, + runtime: "unknown" as const, + parkedOn: null, + idle: null, + idleSinceIso: null, + // FR-1: an agent with no tracked ACTIVE item renders the truth — + // itself a coordination signal; nothing is invented. + doing: "no tracked work item", + holdsCount: 0, + lastTransitionIso: e.latest.ts_updated, + slices: [...e.slices].sort(), + })); + return [...holders, ...recentInputs].sort((a, b) => a.sessionName.localeCompare(b.sessionName)); + } + + /** FR-2 telemetry enrichment: runtime from the sessions/nodes tables, the + * active/idle light from recorded hook activity (AgentActivityStore). + * Anything unprovable stays honest-unknown — never guessed. */ + private withTelemetry(agents: AgentInput[], nowIso: string): AgentInput[] { + const runtimes = this.sessionRuntimes(); + return agents.map((a) => { + const runtime = runtimes.get(a.sessionName) ?? "unknown"; + let idle: boolean | null = a.idle; + let idleSinceIso: string | null = a.idleSinceIso; + if (this.activityStore) { + try { + const activity = this.activityStore.getLatestForNode({ sessionName: a.sessionName, now: new Date(nowIso) }); + if (activity?.state === "running") { + idle = false; + idleSinceIso = null; + } else if (activity?.state === "idle") { + idle = true; + idleSinceIso = activity.eventAt ?? activity.sampledAt ?? null; + } + // needs_input / unknown / null → stays honest-unknown. + } catch { + // Telemetry read failure = unknown, never a guess. + } + } + const normalizedRuntime = + runtime === "claude-code" || runtime === "codex" || runtime === "terminal" ? runtime : ("unknown" as const); + return { ...a, runtime: normalizedRuntime, idle, idleSinceIso }; + }); + } + + private sessionRuntimes(): Map { + const out = new Map(); + if (!this.tableExists("sessions") || !this.tableExists("nodes")) return out; + try { + const rows = this.db + .prepare( + `SELECT s.session_name AS session_name, n.runtime AS runtime + FROM sessions s JOIN nodes n ON n.id = s.node_id + WHERE s.session_name IS NOT NULL`, + ) + .all() as Array<{ session_name: string; runtime: string | null }>; + for (const r of rows) if (r.runtime) out.set(r.session_name, r.runtime); + } catch { + /* degrade to unknown runtimes */ + } + return out; + } + + /** Rig-scope NEEDS YOU: ALL human-routed active items (no tag filter). */ + private attentionAll(): AttentionInput[] { + if (!this.tableExists("queue_items")) return []; + const hasEvidenceRef = this.columnExists("queue_items", "evidence_ref"); + const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary"; + const rows = this.db + .prepare( + `SELECT qitem_id, ts_created, ts_updated, destination_session, state, priority, tier, tags, ${summaryCol}, + blocked_on, closure_required_at${hasEvidenceRef ? ", evidence_ref" : ""} + FROM queue_items + WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) + AND (tier = 'human-gate' + OR destination_session LIKE 'human%' + OR (state = 'blocked' AND blocked_on LIKE 'human%'))`, + ) + .all(...ACTIVE_STATES) as Array; + return rows.map((r) => ({ + qitemId: r.qitem_id, + summary: r.summary, + leg: r.state === "blocked" ? "park-on-human" : "human-routed", + where: r.destination_session, + createdAtIso: r.ts_created, + priority: r.priority, + tier: r.tier, + evidenceRef: r.evidence_ref ?? null, + unblocks: r.state === "blocked" ? r.qitem_id : null, + destinationSession: r.destination_session, + closureRequiredAtIso: r.closure_required_at, + })); + } + + /** FR-4: today's closed handoffs from the transitions log — the SETTLED + * band and the health line's handoff count come from the SAME query + * (two renders, one computation). */ + private settledToday(todayStartIso: string): { settled: SettledRow[]; handoffsToday: number } { + if (!this.tableExists("queue_transitions")) return { settled: [], handoffsToday: 0 }; + const summaryJoin = this.tableExists("queue_items") && this.columnExists("queue_items", "summary") + ? "LEFT JOIN queue_items q ON q.qitem_id = t.qitem_id" + : null; + try { + const rows = this.db + .prepare( + `SELECT t.qitem_id AS qitem_id, t.ts AS ts, t.actor_session AS actor_session, + t.closure_target AS closure_target${summaryJoin ? ", q.summary AS summary" : ", NULL AS summary"} + FROM queue_transitions t + ${summaryJoin ?? ""} + WHERE t.closure_reason = 'handed_off_to' AND t.ts >= ? + ORDER BY t.ts DESC`, + ) + .all(todayStartIso) as Array<{ qitem_id: string; ts: string; actor_session: string; closure_target: string | null; summary: string | null }>; + const settled: SettledRow[] = rows.map((r) => ({ + fromSession: r.actor_session, + toSession: r.closure_target ?? "unknown", + summary: r.summary, + closedAtIso: r.ts, + qitemId: r.qitem_id, + })); + return { settled, handoffsToday: settled.length }; + } catch { + return { settled: [], handoffsToday: 0 }; + } + } + + /** FR-3/FR-4: in-progress slice work past closure_required_at, rig-wide. + * Feeds both the derived NEEDS YOU exception and the health count. */ + private overdueWork(nowIso: string): AttentionInput[] { + if (!this.tableExists("queue_items")) return []; + try { + const summaryCol = this.columnExists("queue_items", "summary") ? "summary" : "NULL AS summary"; + const rows = this.db + .prepare( + `SELECT qitem_id, ts_created, ts_updated, destination_session, state, priority, tier, tags, ${summaryCol}, + blocked_on, closure_required_at + FROM queue_items + WHERE state = 'in-progress' + AND closure_required_at IS NOT NULL + AND closure_required_at < ? + AND tags LIKE '%"slice:%'`, + ) + .all(nowIso) as QitemRow[]; + return rows.map((r) => ({ + qitemId: r.qitem_id, + summary: r.summary, + leg: "overdue", + where: r.destination_session, + createdAtIso: r.ts_created, + priority: r.priority, + tier: r.tier, + evidenceRef: null, + unblocks: null, + destinationSession: r.destination_session, + closureRequiredAtIso: r.closure_required_at, + })); + } catch { + return []; + } + } + + private hasActiveQitem(name: string, slice: SliceRecord): boolean { + if (!this.tableExists("queue_items")) return false; + const row = this.db + .prepare( + `SELECT 1 FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ? LIMIT 1`, + ) + .get(...ACTIVE_STATES, `%"slice:${name}"%`); + if (row) return true; + if (slice.qitemIds.length === 0) return false; + const placeholders = slice.qitemIds.map(() => "?").join(","); + try { + return this.db + .prepare( + `SELECT 1 FROM queue_items + WHERE qitem_id IN (${placeholders}) + AND state IN (${ACTIVE_STATES.map(() => "?").join(",")}) + LIMIT 1`, + ) + .get(...slice.qitemIds, ...ACTIVE_STATES) !== undefined; + } catch { + return false; + } + } + + /** §4 — the two staged-approval stamps (arch F-A: the SHIPPED verb's + * frontmatter fields), each cross-checked against the pinned + * scope-approval audit shape (`approval_scope` inside audit_notes_json). + * A stamp with no matching row -> auditVerified false (UNVERIFIED stamp, + * rendered loudly — never a block). */ + private gatherApproval(slice: SliceRecord, fm: Record): ApprovalFacts { + const str = (k: string): string | null => { + const v = fm[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + if (v instanceof Date) return v.toISOString(); + return null; + }; + const sliceId = str("id"); + + const auditRowPresent = (approvalScope: "spec" | "delivery"): boolean => { + if (!this.tableExists("mission_control_actions")) return false; + try { + return ( + this.db + .prepare( + `SELECT 1 FROM mission_control_actions + WHERE action_verb='approve' + AND audit_notes_json LIKE ? + AND (audit_notes_json LIKE ? OR audit_notes_json LIKE ?) + LIMIT 1`, + ) + .get( + `%"approval_scope":"${approvalScope}"%`, + // A null slice id must never match an empty scope_id row. + sliceId ? `%"scope_id":"${sliceId}"%` : `%"scope_id":"${slice.name}"%`, + `%${slice.name}%`, + ) !== undefined + ); + } catch { + return false; + } + }; + + const stamp = (byKey: string, atKey: string, approvalScope: "spec" | "delivery"): ApprovalStampFacts | null => { + const by = str(byKey); + const at = str(atKey); + if (!by || !at) return null; + return { by, at, auditRowPresent: auditRowPresent(approvalScope) }; + }; + + return { + spec: stamp("approved-spec-by", "approved-spec-at", "spec"), + delivery: stamp("approved-by", "approved-at", "delivery"), + }; + } + + private git(args: string[]): string | null { + if (!this.gitRepoPath) return null; + try { + return execFileSync("git", args, { cwd: this.gitRepoPath, timeout: 4000, encoding: "utf8" }).trim(); + } catch { + return null; + } + } + + private gatherGitFacts(fm: Record, candidateSha: string | null): GitFacts { + const mainTip = this.git(["rev-parse", "--short", "HEAD"]) ?? "unknown"; + const sliceId = typeof fm["id"] === "string" ? (fm["id"] as string) : null; + let mergeSha: string | null = null; + if (sliceId) { + const found = this.git(["log", "--fixed-strings", `--grep=Merge ${sliceId}`, "--format=%h", "-1"]); + mergeSha = found && found.length > 0 ? found : null; + } + let mergeIsAncestorOfTip: boolean | null = null; + if (mergeSha) { + const out = this.git(["merge-base", "--is-ancestor", mergeSha, "HEAD"]); + // exec throws (returns null) on non-ancestor exit 1; success returns "". + mergeIsAncestorOfTip = out !== null; + } + let candidateBehindTip: number | null = null; + if (!mergeSha && candidateSha) { + const base = this.git(["merge-base", candidateSha, "HEAD"]); + if (base) { + const count = this.git(["rev-list", "--count", `${base}..HEAD`]); + candidateBehindTip = count !== null && /^\d+$/.test(count) ? Number(count) : null; + } + } + return { mainTip, mergeSha, mergeIsAncestorOfTip, candidateBehindTip }; + } +} diff --git a/packages/daemon/src/domain/review/types.ts b/packages/daemon/src/domain/review/types.ts new file mode 100644 index 000000000..eac45d810 --- /dev/null +++ b/packages/daemon/src/domain/review/types.ts @@ -0,0 +1,320 @@ +// Living Notes — the composed-review read contract (OPR.0.4.4.20, rebuilt +// per the CORRECTIVE REDESIGN of 2026-07-05). +// +// ONE contract, all consumers: the slice Review tab, the mission board's +// U5 row expansion, and the For-You expansion all read these shapes; the +// agents projection is scope-parameterized (slice: | mission: | rig) +// on the same contract — never a second endpoint. +// +// CORRECTIVE §3.1 — a slice review is ONE vertical stack of three sections: +// INTENT → PLAN → DELIVERED. The four parallel renderable structures the +// original build carried (sections / acceptance / join / compare) are +// DELETED, not demoted; the separate two-regime `green` field is REMOVED +// (§11) — its recorded-verdict rigor is the source of the per-deliverable +// `verified` signal. +// +// The composer that fills these shapes is PURE: same inputs (including the +// caller-supplied view-time facts like `nowIso` and `mainTip`) produce a +// byte-identical output. Every section names its SSOT and degrades to a +// declared value — never invented content. + +// --- C1 header (ratified closed sets — Packet 1 FR-8; extending them is a +// convention change, not a code decision) --- + +export const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const; +export type C1ArtifactType = (typeof C1_ARTIFACT_TYPES)[number]; + +export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const; +export type C1Verdict = (typeof C1_VERDICTS)[number]; + +export interface ProofArtifact { + /** Path relative to the slice dir (proof/.md). */ + relPath: string; + slice: string | null; + candidateSha: string | null; + artifactType: C1ArtifactType | null; + /** The RECORDED token, verbatim. null = missing/out-of-set (counts as MISSING). */ + verdict: C1Verdict | null; + moneyEvidence: string | null; + /** D2 attestation (optional C1 fields). */ + evidences: string[]; + selfCheck: string | null; + /** Media refs found in the artifact body (proof-dir-relative as written); + * the composer curates them into `delivered.items[].proof` (§3.4). */ + mediaRefs: string[]; + /** File mtime ISO — the latest-wins tiebreaker within (slice, candidate_sha, artifact_type). */ + droppedAt: string; +} + +// --- FR-2: recorded verdicts + verify-lineage (KEEP — gate chips render +// recorded tokens verbatim; the separate green readout is gone, §11) --- + +/** Derived TONE for coloring only — the chip TEXT is always the recorded token verbatim (G1). */ +export type VerdictTone = "pass" | "fail" | "unknown"; + +export type GateRole = "guard" | "qa" | "rev1-r1" | "rev1-r2"; +export const GATE_ROLES: GateRole[] = ["guard", "qa", "rev1-r1", "rev1-r2"]; + +export interface VerdictCell { + role: GateRole; + /** Verbatim recorded token; null when the artifact is missing or carries no valid verdict. */ + recordedToken: C1Verdict | null; + tone: VerdictTone; + /** "passing" per the pinned mapping; "missing" covers absent artifact AND present-but-no-verdict. */ + state: "passing" | "non-passing" | "missing"; + /** proof/ relPath of the winning (latest) artifact, or null. */ + source: string | null; +} + +export interface VerifyLineage { + /** N1: the three view-time facts — always all rendered; the label derives from them. */ + candidateSha: string | null; + /** null = UNMERGED (an explicit lineage fact, never a precondition at slice altitude). */ + mergeSha: string | null; + mainTip: string; + /** Derived from the three facts per the N1 rule; never renders without them. */ + freshness: "fresh" | "stale" | "unknown"; + /** Commits behind tip when pre-merge stale; null otherwise. */ + staleBehind: number | null; + gateCells: VerdictCell[]; +} + +// --- CORRECTIVE §3.1 — the ONE structure's building blocks --- + +/** Inline media (curated proof, planned mockups). `src` is a slice-relative + * path on the daemon contract (the UI builds the asset URL); the twin's + * fixtures inline data: URIs against the same shape. */ +export interface ReviewMedia { + kind: "image" | "video"; + src: string; + poster?: string; + caption: string; +} + +/** One of the two deliberate stamps (§4): plan-lock = the shipped + * `--scope spec` staged-approval stamp; proof-lock = the `--scope delivery` + * stamp (scope.ts staged approval — frontmatter stamp + audit row). + * `auditVerified: false` renders the UNVERIFIED-stamp state loudly. */ +export interface LockState { + by: string; + at: string; + auditVerified: boolean; +} + +/** One pinned plan artifact ("this set gets built") — a frontmatter READ + * (`locked-artifacts:` on the slice README), never new write machinery. */ +export interface LockedArtifact { + name: string; + path: string; + kind: string; +} + +/** §3.1 delivered.items[] — the redesigned join: one planned deliverable + * paired with its CURATED proof and QA's recorded comparison signal. + * `verified` binds to the SHIPPED C1 fields (artifact_type qa|adjudication + * + evidences/self_check + a passing recorded verdict) — never mere + * artifact presence. Fail-open: unverified/missing render as visibly + * incomplete, they never block. */ +export interface DeliveredItem { + promised: { text: string; plannedRef?: ReviewMedia }; + proof: ReviewMedia[]; + verified: "verified" | "unverified" | "missing"; + note?: string; +} + +// --- FR-3: derived phase (five-way, top-down precedence — KEEP) --- + +export const REVIEW_PHASES = ["locked", "review", "building", "spec", "intent"] as const; +export type ReviewPhase = (typeof REVIEW_PHASES)[number]; + +/** BR-10 render vocabulary (SS14). Derivation names map 1:1; only the label differs. */ +export const PHASE_LANE_LABELS: Record = { + intent: "INTENT", + spec: "PLAN", + building: "BUILD", + review: "REVIEW", + locked: "LOCKED", +}; + +// --- FR-4: NEEDS YOU + AGENTS (KEEP — orthogonal, already sound) --- + +export interface DerivedException { + kind: "stuck" | "overdue" | "insufficient-proof" | "stale-after-change"; + /** The inline evidence — never a bare accusation ("idle 47m >= 30m default · holds 2"). */ + evidence: string; + /** The visible v1 threshold it crossed. */ + threshold: string; +} + +export interface NeedsYouItem { + /** "agent" = ● (agent-initiated leg); "derived" = ▲ (composer exception). Equal rank. */ + source: "agent" | "derived"; + /** One-count identity: qitem id, or the ▲ tuple key "seat-or-slice|kind|since". */ + identity: string; + summary: string; + leg: string; + where: string; + ageIso: string | null; + priority: string | null; + tier: string | null; + evidenceRef: string | null; + /** Computed what-it-unblocks (the park relation), or null. */ + unblocks: string | null; + /** #6 read-contract members: the actionable qitem's real destination/actor identity. */ + qitemId: string | null; + destinationSession: string | null; + derived: DerivedException | null; +} + +export interface NeedsYouBand { + items: NeedsYouItem[]; + /** U4: the proven-empty/computed-from provenance line — always present. */ + provenance: string; +} + +// --- FR-4 SSOT: the shared agent-row anatomy (both scopes render exactly this) --- + +export type AgentsScope = `slice:${string}` | `mission:${string}` | "rig"; + +export interface AgentRow { + agentName: string; + runtime: "claude-code" | "codex" | "terminal" | "unknown"; + /** Honest-unknown when telemetry is down — never guessed. Parked is queue-proven. */ + stateGlyph: "active" | "parked" | "idle" | "unknown"; + /** The C6 plain-language "doing" line. */ + doing: string | null; + holdsCount: number; + lastTransitionIso: string | null; + /** ▲ mark with inline evidence when a derived exception targets this agent. */ + exception: DerivedException | null; + sessionName: string; + /** Slice ids grouping this row at mission scope. */ + slices: string[]; +} + +export interface AgentsBand { + scope: AgentsScope; + rows: AgentRow[]; + /** BR-11 provenance (proven-empty carries it too — never a blank region). */ + provenance: string; + coordinationHealth: string | null; +} + +// --- OPR.0.4.4.22 (slice 22): the RIG-scope standalone altitude root (KEEP) --- +// Same contract family as ComposedSliceReview / ComposedMissionReview — the +// agent-scope query members the slice-22 PRD adds; never a second family. + +/** One settled handoff (the record band): today's closed handoffs, one line + * each, in C6 summaries (BR-10 — plain language first). */ +export interface SettledRow { + fromSession: string; + toSession: string; + /** The qitem's C6 summary; null degrades to the qitem id at render. */ + summary: string | null; + closedAtIso: string; + qitemId: string; +} + +/** The composed rig-agents read root (slice 22 FR-1..FR-4): NEEDS YOU + + * AGENTS (with coordination health) + SETTLED at rig scope. Pure + * projection over queue + ps — agents author nothing for it. */ +export interface ComposedRigAgents { + scope: "rig"; + needsYou: NeedsYouBand; + agents: AgentsBand; + settled: SettledRow[]; + /** BR-11: proven-empty carries provenance too — never a blank region. */ + settledProvenance: string; + composedAt: string; +} + +// --- The composed slice review (the read contract root — CORRECTIVE §3.1) --- + +/** §3.1 — the ONE structure. A projection of on-disk artifacts: every section + * always composes, degrading to a muted "—" line when its source is absent — + * never invented, never blocking. */ +export interface ComposedSliceReview { + slice: string; + sliceId: string | null; + title: string; + missionId: string | null; + phase: ReviewPhase; + laneLabel: string; + /** §1 — recorded intent, verbatim, usually text. */ + intent: { + text: string | null; + media: ReviewMedia[]; + ssotPath: string | null; + degrade: string | null; + }; + /** §2 — mini-reqs + planned mockup(s); the PINNED locked set; plan-lock. */ + plan: { + concise: { text: string | null; media: ReviewMedia[] }; + lockedArtifacts: LockedArtifact[]; + lock: LockState | null; + ssotPath: string | null; + }; + /** §3 — the redesigned join: planned ↔ curated proof ↔ QA-verified. */ + delivered: { + items: DeliveredItem[]; + /** Helpful artifacts not tied to one deliverable — bounded at render (§6). */ + extraProof: ReviewMedia[]; + lock: LockState | null; + /** Drill-in target for "see all proof" (the full fix-loop history). */ + proofDirPath: string | null; + }; + // KEEP (orthogonal, already sound): attention + coordination + freshness. + needsYou: NeedsYouBand; + agents: AgentsBand; + lineage: VerifyLineage; + /** Absolute/escaping media-path findings etc. — surfaced, never silently dropped (FR-5). */ + defects: string[]; + /** View-time input echoed back (an INPUT to the pure composer — idempotence holds). */ + composedAt: string; +} + +// --- FR-7: mission altitude (KEEP — the completion ledger's recorded-verdict +// green is a mission-ledger fact, not a slice-review structure) --- + +export interface BoardSlot { + slice: string; + title: string; + phase: ReviewPhase; + laneLabel: string; + agentsCount: number; + /** The stage-specific cell (spec-stamp state / n-of-m / green+merged pair / stamp). */ + stageCell: string; + changedSinceStamp: boolean; + /** Deterministic attention-worthiness: needs-you ∪ ▲ ∪ stage-changed-today. */ + attentionWorthy: boolean; +} + +export interface LedgerRow { + slice: string; + candidateSha: string | null; + gateCells: VerdictCell[]; + mergeSha: string | null; + needsHumanCount: number; + green: boolean; +} + +export interface ComposedMissionReview { + mission: string; + missionId: string | null; + title: string; + /** The brief's "What & why" — projected VERBATIM as the mission intent opener (FR-8). */ + intent: string | null; + /** FR-8: the generated status-spine bodies — ONE computation path; the tab + * renders these always-fresh and the SAME strings land in MISSION_BRIEF.md + * only at freeze moments. */ + briefSpine: { building: string; progress: string; proven: string; needsYou: string }; + board: BoardSlot[]; + /** SETTLED: the completion ledger — a query over the mission's slice set, never an authored list. */ + ledger: LedgerRow[]; + /** TRUE only when EVERY in-cut slice is green AND merged AND has zero open needs-human items. */ + cutComplete: boolean; + cutCompleteBasis: string; + needsYou: NeedsYouBand; + agents: AgentsBand; + composedAt: string; +} diff --git a/packages/daemon/src/domain/scope/scope-approve.ts b/packages/daemon/src/domain/scope/scope-approve.ts new file mode 100644 index 000000000..723d6e96a --- /dev/null +++ b/packages/daemon/src/domain/scope/scope-approve.ts @@ -0,0 +1,246 @@ +// OPR.0.4.4.19 FR-9 — scope approve: frontmatter sole-writer + append-only +// audit row (kills vibe-shuttled approval). +// +// Daemon-side by design (plan-review CONFIRMED; arch-lead interface-cell +// PASS): the stamp is a workspace frontmatter write, the audit row is a +// mission_control_actions insert, and the freeze-trigger interface cell +// (Packet 2) invokes the compose-and-freeze endpoint AFTER the stamp+audit +// commit — so the stamp+audit pair lives behind ONE daemon operation. +// +// Ordering (arch-lead PIN, 2026-07-04): frontmatter-first → audit-second → +// on audit failure LOUD-FAIL + byte-restore the prior frontmatter. The +// compensating-DELETE variant is REJECTED — no row is ever deleted from +// mission_control_actions; append-only stands. +// +// Two-regime role clarity (BR-6, ratified): approval is the freeze/LOCKED +// trigger and the regime-2 sign-off — it is NEVER the source of proven-green. +// Nothing here computes or stores "green". + +import * as fs from "node:fs"; +import * as path from "node:path"; +import YAML from "yaml"; +import type { MissionControlActionLog } from "../mission-control/mission-control-action-log.js"; + +export type ScopeTier = "slice" | "mission"; +export type ApprovalScope = "spec" | "delivery"; + +export interface ScopeApproveInput { + scopeTier: ScopeTier; + /** Canonical missions-root-relative path (e.g. + * "release-0.4.4/slices/19-living-notes-signal-layer" or "release-0.4.4"). */ + scopePath: string; + /** STAGED APPROVAL (founder un-deferred): `spec` = "the PRD matches my + * intent" (the first accept-point); `delivery` = the terminal sign-off + * (the freeze trigger). Omitted upstream ⇒ delivery (back-compat). */ + approvalScope: ApprovalScope; + /** The REAL invoking session (honest provenance — never overwritten by + * delegation). */ + actorSession: string; + /** DELEGATED APPROVAL: whose decision this stamp records when an agent + * invokes on the founder's behalf. Recorded in the audit notes only. */ + onBehalfOf?: string | null; +} + +export interface ScopeApproveResult { + scopeTier: ScopeTier; + scopeId: string; + scopePath: string; + approvalScope: ApprovalScope; + approvedBy: string; + approvedAt: string; + onBehalfOf: string | null; + actionId: string; + /** Packet-2 interface cell: only the DELIVERY stamp fires the freeze; the + * compose-and-freeze endpoint ships in Packet 2, so P1 always reports + * false. The stamp + audit row stand regardless of any render outcome. */ + freezeFired: false; +} + +export class ScopeApproveError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly details?: Record, + ) { + super(message); + this.name = "ScopeApproveError"; + } +} + +/** The pinned audit_notes_json shape (spec-guard blocker 2): a stable, + * queryable scope-target identity + the approval scope + delegation + * provenance. The audit-browse read path filters on these keys — the pair + * is what makes Packet 2's one-query UNVERIFIED-stamp cross-check real. */ +export interface ScopeApprovalAuditNotes extends Record { + kind: "scope-approval"; + scope_tier: ScopeTier; + scope_id: string; + scope_path: string; + approval_scope: ApprovalScope; + on_behalf_of: string | null; +} + +const STAMP_FIELDS: Record = { + delivery: { by: "approved-by", at: "approved-at" }, + spec: { by: "approved-spec-by", at: "approved-spec-at" }, +}; + +interface ScopeApproveDeps { + /** Resolves the live missions root (SliceIndexer.slicesRoot), or null when + * the workspace is not configured. */ + missionsRoot: () => string | null; + actionLog: MissionControlActionLog; + now?: () => Date; +} + +export class ScopeApproveService { + private readonly deps: ScopeApproveDeps; + + constructor(deps: ScopeApproveDeps) { + this.deps = deps; + } + + approve(input: ScopeApproveInput): ScopeApproveResult { + const missionsRoot = this.deps.missionsRoot(); + if (!missionsRoot) { + throw new ScopeApproveError( + "workspace_not_configured", + "The daemon has no missions root configured; scope approve needs the workspace primitive.", + ); + } + + // Path containment: the scope path must resolve INSIDE the missions root + // (content-surfaces discipline — no ../ escapes). + const resolved = path.resolve(missionsRoot, input.scopePath); + if (resolved !== missionsRoot && !resolved.startsWith(missionsRoot + path.sep)) { + throw new ScopeApproveError( + "scope_path_escape", + `scopePath '${input.scopePath}' resolves outside the missions root.`, + { scopePath: input.scopePath }, + ); + } + const readmePath = path.join(resolved, "README.md"); + if (!fs.existsSync(readmePath)) { + throw new ScopeApproveError( + "scope_not_found", + `No README.md at ${input.scopePath} under the missions root — not a declared ${input.scopeTier}.`, + { scopePath: input.scopePath, scopeTier: input.scopeTier }, + ); + } + + const originalBytes = fs.readFileSync(readmePath, "utf8"); + const frontmatter = parseFrontmatter(originalBytes); + + const scopeId = typeof frontmatter["id"] === "string" && frontmatter["id"].trim().length > 0 + ? (frontmatter["id"] as string) + : null; + if (!scopeId) { + throw new ScopeApproveError( + "scope_id_missing", + `${input.scopePath} has no frontmatter id (dot-ID) — the audit target contract requires a stable scope_id.`, + { scopePath: input.scopePath, action: "Run: rig scope " + input.scopeTier + " reconcile to mint the id, then re-approve." }, + ); + } + + // Re-stamp at the SAME scope fails loudly naming the existing stamp + // (no silent re-stamp; un-approve/re-approve is out of scope v1). A + // spec stamp followed by a delivery stamp is the normal staged sequence. + const fields = STAMP_FIELDS[input.approvalScope]; + const existingBy = frontmatter[fields.by]; + if (typeof existingBy === "string" && existingBy.trim().length > 0) { + throw new ScopeApproveError( + "already_approved", + `${input.scopePath} already carries a ${input.approvalScope} approval stamp: ${fields.by}: ${existingBy}, ${fields.at}: ${String(frontmatter[fields.at] ?? "?")}. Re-approval after un-approval is out of scope v1.`, + { scopePath: input.scopePath, approvalScope: input.approvalScope, existingBy, existingAt: frontmatter[fields.at] ?? null }, + ); + } + + const approvedAt = (this.deps.now?.() ?? new Date()).toISOString(); + + // 1. Frontmatter FIRST (the arch-pinned ordering). + const updated = writeFrontmatterFields(originalBytes, { + [fields.by]: input.actorSession, + [fields.at]: approvedAt, + }); + fs.writeFileSync(readmePath, updated, "utf8"); + + // 2. Audit SECOND. On failure: byte-restore the prior frontmatter and + // fail loudly — a failed audit write can never leave a trusted half-stamp + // (QA plan-review guardrail), and no audit row is ever deleted. + const scopePathCanonical = path.relative(missionsRoot, resolved).split(path.sep).join("/"); + const auditNotes: ScopeApprovalAuditNotes = { + kind: "scope-approval", + scope_tier: input.scopeTier, + scope_id: scopeId, + scope_path: scopePathCanonical, + approval_scope: input.approvalScope, + on_behalf_of: input.onBehalfOf ?? null, + }; + let actionId: string; + try { + const entry = this.deps.actionLog.record({ + actionVerb: "approve", + qitemId: null, // scope approvals are NOT qitem actions + actorSession: input.actorSession, + actedAt: approvedAt, + reason: input.onBehalfOf + ? `scope-approval (${input.approvalScope}) on behalf of ${input.onBehalfOf}` + : `scope-approval (${input.approvalScope})`, + auditNotes, + }); + actionId = entry.actionId; + } catch (err) { + fs.writeFileSync(readmePath, originalBytes, "utf8"); + throw new ScopeApproveError( + "audit_write_failed", + `The approval audit row could not be written; the frontmatter stamp was restored to its prior state (no half-stamp). Cause: ${err instanceof Error ? err.message : String(err)}`, + { scopePath: input.scopePath, approvalScope: input.approvalScope }, + ); + } + + // 3. Freeze-trigger interface cell (Packet 2): the DELIVERY stamp will + // synchronously invoke the ONE compose-and-freeze endpoint AFTER this + // point. The endpoint does not exist at P1; when it lands, a failed + // render never un-approves and never half-stamps — the stamp + audit + // row above stand regardless of the render outcome. + + return { + scopeTier: input.scopeTier, + scopeId, + scopePath: scopePathCanonical, + approvalScope: input.approvalScope, + approvedBy: input.actorSession, + approvedAt, + onBehalfOf: input.onBehalfOf ?? null, + actionId, + freezeFired: false, + }; + } +} + +// ——— frontmatter helpers (daemon-side mirror of the CLI scope-fs shape: +// hand-rolled split + YAML.parse, safe-by-default per PRD §4 leg 2) ——— + +function parseFrontmatter(content: string): Record { + if (!content.startsWith("---")) return {}; + const match = /^---\s*\n([\s\S]*?)\n---/.exec(content); + if (!match) return {}; + try { + const parsed = YAML.parse(match[1]!) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function writeFrontmatterFields(content: string, fields: Record): string { + const match = /^---\s*\n([\s\S]*?)\n---/.exec(content); + if (match) { + const existing = parseFrontmatter(content); + const merged = { ...existing, ...fields }; + const yaml = YAML.stringify(merged).trimEnd(); + return content.replace(/^---\s*\n[\s\S]*?\n---/, `---\n${yaml}\n---`); + } + const yaml = YAML.stringify(fields).trimEnd(); + return `---\n${yaml}\n---\n\n${content}`; +} diff --git a/packages/daemon/src/domain/scope/scope-audit.ts b/packages/daemon/src/domain/scope/scope-audit.ts index 409e88720..215c98058 100644 --- a/packages/daemon/src/domain/scope/scope-audit.ts +++ b/packages/daemon/src/domain/scope/scope-audit.ts @@ -13,7 +13,18 @@ export type FindingKind = | "malformed_mission_brief" | "missing_mission_notes" | "missing_proof" - | "progress_not_updated_on_commit"; + | "progress_not_updated_on_commit" + // OPR.0.4.4.19 FR-10 — the belt-and-suspenders BACKSTOPS (never the + // primary enforcement; the primary is the drop path / write path): + | "proof_artifact_c1_invalid" + | "missing_impl_prd" + // OPR.0.4.4.23 — SDLC convention-section advisories (fail-open by + // construction: low/info severities never flip the audit exit code; + // conventions SSOT: docs/reference/sdlc-conventions.md): + | "missing_intent_section" + | "mini_requirements_missing_or_malformed" + | "proof_contract_missing_or_malformed" + | "ui_slice_missing_mockup"; export interface AuditFinding { kind: FindingKind; @@ -52,6 +63,21 @@ export interface ScopeAuditInput { // is unavailable the CLI leaves them undefined: no false-green, no false-positive. sliceTouchedByRecentCommit?: boolean; progressTouchedByRecentCommit?: boolean; + // OPR.0.4.4.19 FR-10 (C1 backstop) — the slice's proof/ dir markdown + // artifacts with their raw frontmatter, caller-listed. Undefined = the + // caller has no proof-dir context; the check is inert (no false findings). + // Media files (video/screenshot) are exempt by construction — callers list + // .md artifacts only. + proofArtifacts?: Array<{ path: string; frontmatterRaw: string | null }>; + // OPR.0.4.4.19 FR-10 (C7 backstop) — whether IMPLEMENTATION-PRD.md exists + // at the slice root. Undefined = inert (caller has no fs context). + implementationPrdExists?: boolean; + // OPR.0.4.4.23 — convention-section advisory inputs: full file contents, + // caller-read. Undefined = the caller has no content context and every + // section check is inert (no false findings). null = the file does not + // exist (the proof-contract check falls back to the README on a null PRD). + readmeContent?: string | null; + implementationPrdContent?: string | null; } export interface ScopeAuditResult { @@ -60,7 +86,11 @@ export interface ScopeAuditResult { frontmatterError: string | null; } -const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"]; +// OPR.0.4.4.20 FR-8: exported so the review brief-spine writer conforms to +// the SAME pinned exact-order schema this audit enforces (parity by +// construction — the generated output can never trip malformed_mission_brief +// without this file changing too). +export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"]; function childPath(parent: string, child: string): string { return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`; @@ -105,6 +135,72 @@ function statusRequiresProof(status: string | null | undefined): boolean { || normalized.includes("promoted"); } +// OPR.0.4.4.19 FR-10 (C7) — the statusRequiresProof pattern applied one +// phase earlier: a slice whose status implies SPECCED/BUILDING (or later) +// must carry IMPLEMENTATION-PRD.md at its root ("no build without a +// specifying doc"). Shaping / pre-spec statuses are NOT violations. +function statusRequiresSpec(status: string | null | undefined): boolean { + if (!status) return false; + const normalized = status.toLowerCase().trim(); + return normalized.includes("build") + || normalized.includes("implement") + || normalized.includes("in-progress") + || normalized.includes("review") + || normalized.includes("qa") + || statusRequiresProof(normalized); +} + +// OPR.0.4.4.19 FR-10 (C1) — the ratified closed sets (BR-4; source of truth +// for the drop path lives in the CLI proof command; this mirrored file +// carries its own copy because both scope-audit copies must stay +// self-contained + byte-identical. Extending the sets is a pm-lead +// convention change, made in BOTH places). +const C1_REQUIRED_FIELDS = ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const; +const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const; +const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const; + +/** Validate one proof artifact's raw frontmatter against the C1 contract. + * Returns null when valid; else the human-readable problem list. */ +function c1ArtifactProblems(frontmatterRaw: string | null): string[] | null { + if (frontmatterRaw === null) { + return [`no frontmatter header at all (required C1 fields: ${C1_REQUIRED_FIELDS.join(", ")})`]; + } + let parsed: unknown = null; + try { + parsed = YAML.parse(frontmatterRaw); + } catch (err) { + return [`frontmatter fails to parse: ${err instanceof Error ? err.message : String(err)}`]; + } + const fm = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + const problems: string[] = []; + const missing = C1_REQUIRED_FIELDS.filter((f) => typeof fm[f] !== "string" || (fm[f] as string).trim().length === 0); + if (missing.length > 0) problems.push(`missing field(s): ${missing.join(", ")}`); + if (typeof fm.artifact_type === "string" && !(C1_ARTIFACT_TYPES as readonly string[]).includes(fm.artifact_type)) { + problems.push(`artifact_type '${fm.artifact_type}' not in closed set (${C1_ARTIFACT_TYPES.join(" | ")})`); + } + if (typeof fm.verdict === "string" && !(C1_VERDICTS as readonly string[]).includes(fm.verdict)) { + problems.push(`verdict '${fm.verdict}' not in closed set (${C1_VERDICTS.join(" | ")})`); + } + return problems.length > 0 ? problems : null; +} + +// OPR.0.4.4.23 — markdown H2-section helpers for the convention-section +// advisories. Headings are literals owned by this file ("Intent", +// "Proof contract", "Intent visual") — no user input reaches the regex. +function hasH2(content: string, heading: string): boolean { + return new RegExp(`^##\\s+${heading}\\s*$`, "m").test(content); +} + +function h2Body(content: string, heading: string): string | null { + const match = new RegExp(`^##\\s+${heading}\\s*$`, "m").exec(content); + if (!match) return null; + const rest = content.slice(match.index + match[0].length); + const next = rest.search(/^##\s+/m); + return next === -1 ? rest : rest.slice(0, next); +} + // A mission is ACTIVE unless its status names a terminal / archived state. // The SOP wants missing_mission_notes to fire for an ACTIVE mission only — a // shipped/archived mission no longer needs a live continuity file. No status @@ -264,6 +360,115 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult { }); } + // OPR.0.4.4.19 FR-10 (C1 backstop) — flag proof/ artifacts missing the + // C1 header or carrying out-of-set values. The backstop catches what + // bypassed the drop path (raw file writes are never gated at write + // time; this is where they surface). + for (const artifact of input.proofArtifacts ?? []) { + const problems = c1ArtifactProblems(artifact.frontmatterRaw); + if (problems) { + findings.push({ + kind: "proof_artifact_c1_invalid", + severity: "medium", + path: artifact.path, + message: `Proof artifact violates the C1 header contract: ${problems.join("; ")}.`, + remediation: `Re-drop via: rig proof add --artifact-type <${C1_ARTIFACT_TYPES.join("|")}> --verdict <${C1_VERDICTS.join("|")}> --candidate-sha --money-evidence "" — or add the missing frontmatter fields in place.`, + }); + } + } + + // OPR.0.4.4.19 FR-10 (C7 backstop) — a specced/building-or-later slice + // must carry IMPLEMENTATION-PRD.md (the pinned expected filename) at its + // root. Status-gated: shaping is not a violation. Inert when the caller + // provided no fs context (implementationPrdExists undefined). + if (input.implementationPrdExists === false && statusRequiresSpec(status)) { + findings.push({ + kind: "missing_impl_prd", + severity: "medium", + path: childPath(input.path, "IMPLEMENTATION-PRD.md"), + message: `Slice status "${status}" implies specced/building but there is no IMPLEMENTATION-PRD.md at the slice root (C7: no build without a specifying doc).`, + remediation: "Author (or relocate) the slice's IMPLEMENTATION-PRD.md at the slice root — the pinned C7 filename — before building continues.", + }); + } + + // OPR.0.4.4.23 — SDLC convention-section advisories (SSOT: + // docs/reference/sdlc-conventions.md). Structurally fail-open: the + // audit command flips its exit code on HIGH findings only, and these + // are low/info by construction — they record and advise, never gate. + // Inert when the caller provided no content context (undefined inputs). + if (typeof input.readmeContent === "string" && !hasH2(input.readmeContent, "Intent")) { + findings.push({ + kind: "missing_intent_section", + severity: "low", + path: childPath(input.path, "README.md"), + message: "Slice README has no `## Intent` section, so the Living Notes UI cannot project its INTENT section.", + remediation: "Add `## Intent` carrying the recorded intent verbatim (conventions SSOT: docs/reference/sdlc-conventions.md; `rig scope slice create` scaffolds it).", + }); + } + + const contractSource = typeof input.implementationPrdContent === "string" + ? { content: input.implementationPrdContent, path: childPath(input.path, "IMPLEMENTATION-PRD.md") } + : typeof input.readmeContent === "string" + ? { content: input.readmeContent, path: childPath(input.path, "README.md") } + : null; + if (contractSource) { + // OPR.0.4.4.23 rev1-r2 B1 (PRD L34 guard F-3): well-formed + // `## Mini-requirements` — the PLAN leg of the Living Notes + // projection. Same source-selection policy as the proof contract + // (PRD preferred, README fallback). Well-formed = the heading plus + // at least one numbered list item; a heading over prose-only is + // malformed (no usable requirements projection). + const miniBody = h2Body(contractSource.content, "Mini-requirements"); + const hasNumberedItem = miniBody !== null && /^\s*\d+\.\s+\S/m.test(miniBody); + if (!hasNumberedItem) { + findings.push({ + kind: "mini_requirements_missing_or_malformed", + severity: "low", + path: contractSource.path, + message: miniBody === null + ? "No `## Mini-requirements` section — the Living Notes PLAN section has no concise requirements tier to project." + : "`## Mini-requirements` carries no numbered items (`1. …`) — the one-glance requirement tier is where approval starts.", + remediation: "Add `## Mini-requirements` with a numbered list of observable outcomes (conventions SSOT: docs/reference/sdlc-conventions.md; for a small slice this may BE the whole PRD).", + }); + } + + const contractBody = h2Body(contractSource.content, "Proof contract"); + const hasCheckboxItem = contractBody !== null && /^\s*-\s*\[[ xX]\]/m.test(contractBody); + if (!hasCheckboxItem) { + findings.push({ + kind: "proof_contract_missing_or_malformed", + severity: "low", + path: contractSource.path, + message: contractBody === null + ? "No `## Proof contract` section — the DELIVERED pairing has no promised-deliverables source to join proof against." + : "`## Proof contract` carries no checkbox deliverables (`- [ ] …`) — the DELIVERED pairing joins proof against those items.", + remediation: "Add `## Proof contract` with one checkbox line per promised deliverable, written as an observable outcome (conventions SSOT: docs/reference/sdlc-conventions.md).", + }); + } + + if (typeof input.readmeContent === "string") { + const visualBody = h2Body(input.readmeContent, "Intent visual"); + const isUiSlice = visualBody !== null && !/\bN\/A\b/i.test(visualBody); + // rev1-r2 B2: a mockup is PRESENT only via a real markdown + // image/media ref in `## Intent visual` or an explicit plannedRef + // token in the proof contract. Generic prose containing the word + // "mockup" (the scaffold placeholder says "name their planned + // mockup") is NOT a reference and must not suppress the advisory. + const hasMockupRef = /!\[/.test(visualBody ?? "") + || /plannedRef/i.test(contractBody ?? "") + || /!\[/.test(contractBody ?? ""); + if (isUiSlice && !hasMockupRef) { + findings.push({ + kind: "ui_slice_missing_mockup", + severity: "info", + path: childPath(input.path, "README.md"), + message: "Slice declares an Intent visual (UI slice) but no mockup reference is present — a UI slice with no mockup in its locked set is an incomplete plan.", + remediation: "Attach the planned mockup: an image ref in `## Intent visual` or a plannedRef on the proof-contract deliverable (conventions SSOT: docs/reference/sdlc-conventions.md §3).", + }); + } + } + } + // committed-without-PROGRESS. Fires only on POSITIVE evidence: the most // recent commit (HEAD) touched this slice AND definitively did NOT touch its // PROGRESS.md. Inputs are git-derived and CLI-only; when git context is diff --git a/packages/daemon/src/domain/topology/multi-rig-launcher.ts b/packages/daemon/src/domain/topology/multi-rig-launcher.ts new file mode 100644 index 000000000..bf6a82fa7 --- /dev/null +++ b/packages/daemon/src/domain/topology/multi-rig-launcher.ts @@ -0,0 +1,175 @@ +// OPR.0.4.4.11 — MultiRigLauncher: the THIN walker (FR-3/FR-4/FR-5). +// +// It INVOKES the existing single-rig leaves — it never re-implements stages, +// locks, provenance, or result shapes. A local entry runs through the SAME +// public bootstrap() entry + tryAcquire/release lock pair routes/up.ts uses +// (guard G-2: the per-sourceRef lock is ROUTE-side today — bootstrap() never +// acquires; the launcher explicitly participates in that same lock set, +// acquire before the leaf, release in finally on success AND failure). A +// host-placed entry runs through the shipped remote POST /api/up leaf. Both +// leaves are injected so this module stays testable and the wiring in +// routes/up.ts (the ONLY production caller) binds the real seams. +// +// Aggregate contract (arch ruling 5, verbatim): status is the CLOSED enum +// {ok | failed | skipped}; entries after the failed one report `skipped` +// EXPLICITLY (never absent — omission-proof); `host` is present uniformly on +// every entry (the literal "local" for local entries); overall success = +// every entry ok. Stop-on-failure is the ONLY v0 behavior (FR-5): no new +// entry starts after a failure; already-started rigs are reported honestly +// (mixed state, no rollback ceremony). + +import type { TopologyManifest } from "./topology-manifest.js"; +import type { HostEntry, HostRegistryLoadResult } from "../hosts/hosts-registry-reader.js"; +import { resolvePlacementHost } from "../hosts/hosts-registry-reader.js"; + +export type TopologyEntryStatus = "ok" | "failed" | "skipped"; + +export interface TopologyEntryResult { + rigRef: string; + /** Uniform on every entry: the placed host id, or the literal "local". */ + host: string; + status: TopologyEntryStatus; + error?: string; +} + +export interface TopologyLaunchResult { + /** True ONLY when every entry is ok (FR-5 — no false green). */ + ok: boolean; + entries: TopologyEntryResult[]; +} + +export interface MultiRigLauncherDeps { + /** The SAME public lock pair the up route uses (bootstrap-orchestrator). */ + tryAcquire: (sourceRef: string) => boolean; + release: (sourceRef: string) => void; + /** The existing local single-rig leaf (public bootstrap() via routes/up.ts + * wiring). Resolves ok:false with the leaf's own error on failure — the + * launcher invents no failure taxonomy. */ + launchLocal: (source: string) => Promise<{ ok: boolean; error?: string }>; + /** The shipped remote POST /api/up leaf for a placed entry. */ + launchRemote: (source: string, host: HostEntry) => Promise<{ ok: boolean; error?: string }>; + /** Lazy hosts-registry read (shared reader) — consulted only when the + * manifest actually places entries on hosts. */ + loadRegistry: () => HostRegistryLoadResult; + /** Resolve a local entry's manifest source string to the EXACT ref used + * for BOTH the lock and the leaf (guard fixback F1): the orchestrator + * normalizes lock keys with path.resolve, so locking the raw relative + * string would key against the daemon cwd while the leaf launches the + * manifest-dir-resolved file — false lock negatives (standalone up of + * the real path doesn't conflict) AND false positives (two manifests' + * './a.yaml' collide across different files). Identity when omitted. */ + resolveLocalRef?: (source: string) => string; +} + +/** Mirrors the route's 409 lock-conflict semantics for a topology entry. */ +const LOCK_CONFLICT_ERROR = "Already in progress for this source (conflict): a concurrent up holds this rig's launch lock"; + +export class MultiRigLauncher { + private deps: MultiRigLauncherDeps; + + constructor(deps: MultiRigLauncherDeps) { + this.deps = deps; + } + + async launch(manifest: TopologyManifest): Promise { + const entries = manifest.rigs; + // Every entry starts explicitly skipped — an entry the walk never + // reaches is REPORTED, not absent (omission-proof aggregate). + const results: TopologyEntryResult[] = entries.map((e) => ({ + rigRef: e.source, + host: e.host ?? "local", + status: "skipped" as TopologyEntryStatus, + })); + + // ── Pre-launch placement validation (FR-1/FR-4: per-entry structured + // errors BEFORE any launch attempt). Registry is read once, only if a + // host-placed entry exists. Any placement failure aborts the whole run + // before the first leaf call: bad entries report failed, the rest stay + // skipped. + const placed = new Map(); + if (entries.some((e) => e.host !== undefined)) { + const reg = this.deps.loadRegistry(); + let placementFailed = false; + for (let i = 0; i < entries.length; i++) { + const hostId = entries[i]!.host; + if (hostId === undefined) continue; + if (!reg.ok) { + results[i]! = { ...results[i]!, status: "failed", error: reg.error }; + placementFailed = true; + continue; + } + const res = resolvePlacementHost(reg.registry, hostId); + if (!res.ok) { + results[i]! = { ...results[i]!, status: "failed", error: res.error }; + placementFailed = true; + } else { + placed.set(i, res.host); + } + } + if (placementFailed) return { ok: false, entries: results }; + } + + // ── Staged walk: entries START in manifest order under the fixed cap + // (default 1 = strictly sequential); a failure stops all further starts + // while in-flight entries complete and report honestly. + let nextIndex = 0; + let stopped = false; + + const runEntry = async (i: number): Promise => { + const entry = entries[i]!; + const hostEntry = placed.get(i); + try { + if (hostEntry) { + const res = await this.deps.launchRemote(entry.source, hostEntry); + results[i] = { + rigRef: entry.source, + host: hostEntry.id, + status: res.ok ? "ok" : "failed", + ...(res.ok ? {} : { error: res.error ?? "remote up failed" }), + }; + return; + } + // Local: the route-side lock discipline, explicitly (guard G-2). + // Lock key and launch ref are the SAME resolved string (guard F1) — + // the aggregate's rigRef stays the raw manifest string for display. + const launchRef = this.deps.resolveLocalRef ? this.deps.resolveLocalRef(entry.source) : entry.source; + if (!this.deps.tryAcquire(launchRef)) { + results[i] = { rigRef: entry.source, host: "local", status: "failed", error: LOCK_CONFLICT_ERROR }; + return; + } + try { + const res = await this.deps.launchLocal(launchRef); + results[i] = { + rigRef: entry.source, + host: "local", + status: res.ok ? "ok" : "failed", + ...(res.ok ? {} : { error: res.error ?? "bootstrap failed" }), + }; + } finally { + this.deps.release(launchRef); + } + } catch (err) { + results[i] = { + rigRef: entry.source, + host: hostEntry ? hostEntry.id : "local", + status: "failed", + error: (err as Error).message, + }; + } + }; + + const cap = Math.max(1, manifest.concurrency); + const workers = Array.from({ length: Math.min(cap, entries.length) }, async () => { + while (!stopped) { + const i = nextIndex; + if (i >= entries.length) return; + nextIndex += 1; + await runEntry(i); + if (results[i]!.status === "failed") stopped = true; // FR-5: stop at the failed entry + } + }); + await Promise.all(workers); + + return { ok: results.every((r) => r.status === "ok"), entries: results }; + } +} diff --git a/packages/daemon/src/domain/topology/remote-up-leaf.ts b/packages/daemon/src/domain/topology/remote-up-leaf.ts new file mode 100644 index 000000000..312199af3 --- /dev/null +++ b/packages/daemon/src/domain/topology/remote-up-leaf.ts @@ -0,0 +1,85 @@ +// OPR.0.4.4.11 — the daemon-side remote single-rig leaf (FR-4). +// +// A host-placed topology entry launches by POSTing the SHIPPED remote-up +// surface: POST {host.url}/api/up with the same body shape the CLI's +// --host path sends. Since OPR.0.4.4.15 this is a THIN CONSUMER of the +// shared daemon→daemon transport core (domain/hosts/remote-daemon-http.ts +// — arch cell 1: ONE copy of the security-adjacent bearer/bounded-abort/ +// classification code for the whole P3/P4 family). This module keeps its +// shipped error-string surface byte-for-byte; only the transport plumbing +// moved. The failure classes remain the EXISTING ones ([permission-gate] / +// [remote-command-failed] / [remote-daemon-unreachable]) — no new taxonomy +// (FR-4 negative AC). + +import type { HttpHostEntry } from "../hosts/hosts-registry-reader.js"; +import { remoteJsonRequest } from "../hosts/remote-daemon-http.js"; + +export interface RemoteUpLeafDeps { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: typeof fetch; + env?: Record; + readFile?: (path: string) => string; + /** Bounded remote-up deadline (rev1-r2 R2-B1). Defaults to the + * long-running rig-up budget — remote bootstrap legitimately runs + * minutes, but never forever. Passed EXPLICITLY to the shared core + * (the deadline class is this call-site's decision, per the arch + * required-argument sharpening). */ + timeoutMs?: number; +} + +/** Matches the CLI's LONG_RUNNING_UP_TIMEOUT_MS for standalone remote ups. */ +export const REMOTE_UP_TIMEOUT_MS = 120_000; + +export interface RemoteUpBody { + sourceRef: string; + autoApprove?: boolean; +} + +/** POST the placed entry to the remote daemon's shipped /api/up. Returns the + * launcher's normalized {ok, error?} shape; the remote daemon's own error + * text rides through verbatim where available. Never hangs: the shared + * core holds ONE deadline through request AND body parse (the G-R2B1-1 + * class is structurally closed there). */ +export async function remoteUpLeaf( + body: RemoteUpBody, + host: HttpHostEntry, + deps: RemoteUpLeafDeps = {}, +): Promise<{ ok: boolean; error?: string }> { + const timeoutMs = deps.timeoutMs ?? REMOTE_UP_TIMEOUT_MS; + const url = `${host.url.replace(/\/$/, "")}/api/up`; + const res = await remoteJsonRequest(host, "/api/up", { + method: "POST", + body, + timeoutMs, + fetchImpl: deps.fetchImpl, + env: deps.env, + readFile: deps.readFile, + }); + if (res.ok) return { ok: true }; + + switch (res.kind) { + case "bearer": + return { ok: false, error: `[permission-gate] ${res.detail}` }; + case "timeout": + // R2-B1 / G-R2B1-1: a stalled remote /api/up — before OR after + // headers — is a STRUCTURED per-entry failure, never a hung walk. + return res.phase === "body" + ? { + ok: false, + error: `[remote-daemon-unreachable] POST ${url} timed out after ${timeoutMs}ms for host ${host.id}: response headers arrived (HTTP ${res.status}) but the error body never completed`, + } + : { + ok: false, + error: `[remote-daemon-unreachable] POST ${url} timed out after ${timeoutMs}ms for host ${host.id}: the remote daemon never settled the request within the rig-up budget`, + }; + case "network": + return { ok: false, error: `[remote-daemon-unreachable] POST ${url} failed for host ${host.id}: ${res.detail}` }; + case "http": { + // Same classification vocabulary as the CLI transport (classifyHttpFailedStep). + const status = res.status ?? 0; + const cls = status === 401 || status === 403 ? "permission-gate" : status >= 400 && status < 600 ? "remote-command-failed" : "remote-daemon-unreachable"; + const detail = res.detail ? `HTTP ${res.status}: ${res.detail}` : `HTTP ${res.status}`; + return { ok: false, error: `[${cls}] remote up on host ${host.id} failed: ${detail}` }; + } + } +} diff --git a/packages/daemon/src/domain/topology/topology-manifest.ts b/packages/daemon/src/domain/topology/topology-manifest.ts new file mode 100644 index 000000000..b0854db0f --- /dev/null +++ b/packages/daemon/src/domain/topology/topology-manifest.ts @@ -0,0 +1,221 @@ +// OPR.0.4.4.11 — the topology manifest: the SHAREABLE artifact (FR-1). +// +// The key set is CLOSED (arch return R11-1 — this IS the thin-manifest +// enforcement): an ordered `rigs[]` of entries {source, host?} plus an +// optional manifest-level `concurrency` cap (default 1 = sequential). +// NOTHING ELSE. Unknown keys reject loudly — silently ignoring one would +// reopen the founder's ratified non-goal by stealth. Cross-rig +// edge/routing/dependency keys reject with a message NAMING that non-goal +// (thin manifest; inter-rig routing is deferred topology-Q1). `on_failure` +// was considered and REMOVED for v0 (spec-guard + arch fold): stop-on-failure +// is the only behavior; re-adding it is an explicit pm-lead decision. +// +// Detection contract (PRD FR-1 + guard G-1 folds; the router consumes the +// exported sniffs): `.rigtopology` extension OR a YAML document with a +// top-level `rigs:` LIST. Extension wins when both could apply (arch ruling +// 1 — declared kind binds); a `.rigtopology` file failing validation errors +// AS a topology, never falls through to rig-spec parsing. +// +// Unknown-host-id validation (the FR-1 fourth structural class) resolves +// against the hosts registry via the shared hosts-registry-reader at the +// launcher's pre-launch validation pass — still before any launch attempt; +// this module is filesystem/registry-free by design. + +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseYaml } from "yaml"; + +export interface TopologyRigEntry { + source: string; + host?: string; +} + +export interface TopologyManifest { + rigs: TopologyRigEntry[]; + /** Normalized: the manifest's cap, or the default 1 (sequential). */ + concurrency: number; +} + +export type TopologyManifestResult = + | { ok: true; manifest: TopologyManifest } + | { ok: false; errors: string[] }; + +/** The ratified non-goal family: any key that would smuggle cross-rig + * edges/routing/dependency semantics into the thin manifest. */ +const EDGE_ROUTING_KEYS = new Set([ + "edge", + "edges", + "route", + "routes", + "routing", + "dependency", + "dependencies", + "deps", + "depends_on", + "dependsOn", + "needs", + "after", + "links", + "wires", + "connections", +]); + +const MANIFEST_KEYS = new Set(["rigs", "concurrency"]); +const ENTRY_KEYS = new Set(["source", "host"]); + +function edgeRoutingRejection(prefix: string, key: string): string { + return `${prefix}: '${key}' is a cross-rig edge/routing/dependency key — REJECTED. The topology manifest is deliberately THIN (founder-ratified non-goal: no cross-rig edges, no routing semantics; inter-rig routing is the deferred topology-Q1 decision). Remove the key; the manifest carries only rigs[]{source, host?} + concurrency.`; +} + +function unknownKeyRejection(prefix: string, key: string, allowed: string): string { + if (key === "on_failure") { + return `${prefix}: 'on_failure' was removed for v0 — stop-on-failure is the only behavior (the walk stops at the first failed entry; already-started rigs are reported honestly). Re-adding the key is an explicit pm-lead decision. Remove it.`; + } + return `${prefix}: unknown key '${key}' — the manifest key set is CLOSED (${allowed}). Remove or fix the key.`; +} + +/** Validate an already-parsed YAML document as a topology manifest. + * Collects EVERY structural error (per-entry, what/why/fix) rather than + * failing fast — FR-1 reports them all before any launch. */ +export function validateTopologyManifest(parsed: unknown, sourcePath: string): TopologyManifestResult { + const where = `topology manifest at ${sourcePath}`; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, errors: [`${where}: must be a YAML object with a top-level 'rigs' list`] }; + } + const obj = parsed as Record; + const errors: string[] = []; + + for (const key of Object.keys(obj)) { + if (MANIFEST_KEYS.has(key)) continue; + if (EDGE_ROUTING_KEYS.has(key)) { + errors.push(edgeRoutingRejection(where, key)); + } else { + errors.push(unknownKeyRejection(where, key, "'rigs' + optional 'concurrency'")); + } + } + + let concurrency = 1; + const rawConcurrency = obj["concurrency"]; + if (rawConcurrency !== undefined) { + if (typeof rawConcurrency !== "number" || !Number.isInteger(rawConcurrency) || rawConcurrency < 1) { + errors.push( + `${where}: 'concurrency' must be a positive integer (a fixed staged-launch cap; default 1 = sequential; adaptive throttling is out of scope) — got ${JSON.stringify(rawConcurrency)}`, + ); + } else { + concurrency = rawConcurrency; + } + } + + const rigs = obj["rigs"]; + if (!Array.isArray(rigs)) { + errors.push(`${where}: 'rigs' must be a list of entries {source, host?} — the ordered launch plan`); + return { ok: false, errors }; + } + if (rigs.length === 0) { + errors.push(`${where}: 'rigs' is empty — a topology must name at least one rig to launch`); + return { ok: false, errors }; + } + + const validated: TopologyRigEntry[] = []; + for (let i = 0; i < rigs.length; i++) { + const raw = rigs[i]; + const prefix = `${where}: rigs[${i}]`; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + errors.push(`${prefix}: must be an object with a 'source' rig ref (spec path, library name, or .rigbundle path) and optional 'host'`); + continue; + } + const entry = raw as Record; + + for (const key of Object.keys(entry)) { + if (ENTRY_KEYS.has(key)) continue; + if (EDGE_ROUTING_KEYS.has(key)) { + errors.push(edgeRoutingRejection(prefix, key)); + } else { + errors.push(unknownKeyRejection(prefix, key, "'source' + optional 'host'")); + } + } + + const source = entry["source"]; + if (typeof source !== "string" || source.trim() === "") { + errors.push(`${prefix}.source: required non-empty string — a rig SPEC PATH (v0 boundary; see the per-form rejections below)`); + continue; + } + // ── v0 source-form boundary (arch ruling 2026-07-05, guard F2/F3 fold): + // topology entries are SPEC PATHS ONLY, rejected at PARSE time — uniform + // entry semantics beat mid-walk failure, and neither widening is free: + // bundle targetRoot is a pm-gated closed-key extension (R11-1); library + // names need a daemon resolution seam (P4-adjacent+). Both are NAMED + // DEFERRALS. Direct-to-rig-up forms are untouched (FR-2 stands). + if (/\.rigbundle$/i.test(source)) { + errors.push( + `${prefix}.source: '.rigbundle' entries are not supported in v0 — topology entries are SPEC PATHS ONLY (a bundle entry needs an explicit per-entry targetRoot, a pm-gated extension of the closed key set; deferred). Workaround: launch that rig directly — single-rig 'rig up ${source}' accepts all source kinds unchanged.`, + ); + continue; + } + if (/\.rigtopology$/i.test(source)) { + errors.push(`${prefix}.source: nested topology manifests are not supported — every entry must be a single-rig spec path.`); + continue; + } + if (!source.includes("/") && !/\.ya?ml$/i.test(source)) { + errors.push( + `${prefix}.source: '${source}' is a bare library/rig name — v0 topology entries are SPEC PATHS ONLY (name entries need a daemon-side resolution seam; deferred). Workaround: run 'rig up ${source}' individually, or reference its spec file by path.`, + ); + continue; + } + const host = entry["host"]; + if (host !== undefined && (typeof host !== "string" || host.trim() === "")) { + errors.push(`${prefix}.host: optional, but if present must be a non-empty registered host id (per-entry 'host:' is the ONLY topology placement mechanism)`); + continue; + } + validated.push({ source, ...(host !== undefined ? { host: host as string } : {}) }); + } + + if (errors.length > 0) return { ok: false, errors }; + return { ok: true, manifest: { rigs: validated, concurrency } }; +} + +/** Load + parse + validate a topology manifest file. Parse failures error AS + * a topology (arch ruling 1: the declared kind binds — no rig-spec + * fall-through). */ +export function loadTopologyManifest(path: string): TopologyManifestResult { + if (!existsSync(path)) { + return { + ok: false, + errors: [ + `topology manifest not found at ${path}. Create a .rigtopology YAML file with a top-level 'rigs:' list of {source, host?} entries (+ optional 'concurrency').`, + ], + }; + } + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch (err) { + return { ok: false, errors: [`failed to read topology manifest at ${path}: ${(err as Error).message}`] }; + } + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch (err) { + return { ok: false, errors: [`failed to parse topology manifest YAML at ${path}: ${(err as Error).message}`] }; + } + return validateTopologyManifest(parsed, path); +} + +/** Detection sniff (FR-1 + guard G-1/yaml folds): a parsed YAML document is + * topology-SHAPED iff it carries a top-level `rigs:` LIST. A rig spec never + * carries top-level `rigs`, so the sniff is unambiguous. Shape only — full + * validation still runs (and rejects) as a topology afterwards. */ +export function hasTopLevelRigsList(parsed: unknown): boolean { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false; + return Array.isArray((parsed as Record)["rigs"]); +} + +/** Raw-text form of the sniff for router branches that have not parsed yet + * (the extensionless path-form fallback). Unparseable YAML sniffs false — + * it then flows to the existing non-topology handling unchanged. */ +export function yamlTextHasTopLevelRigsList(raw: string): boolean { + try { + return hasTopLevelRigsList(parseYaml(raw)); + } catch { + return false; + } +} diff --git a/packages/daemon/src/domain/types.ts b/packages/daemon/src/domain/types.ts index 70047667c..12183e636 100644 --- a/packages/daemon/src/domain/types.ts +++ b/packages/daemon/src/domain/types.ts @@ -202,10 +202,13 @@ export type RigEvent = // Host-scoped; rigId is left null because items reference seats by string // (`@`) and can cross rigs. | { type: "stream.emitted"; streamItemId: string; sourceSession: string; hintDestination: string | null; hintType: string | null; hintUrgency: string | null; interrupt: boolean } - | { type: "queue.created"; qitemId: string; sourceSession: string; destinationSession: string; priority: string; tier: string | null } - | { type: "queue.handed_off"; qitemId: string; fromSession: string; toSession: string; closureReason: "handed_off_to" } - | { type: "queue.claimed"; qitemId: string; destinationSession: string; claimedAt: string; closureRequiredAt: string | null } - | { type: "queue.unclaimed"; qitemId: string; destinationSession: string; reason: string } + // OPR.0.4.4.19 FR-1: every queue.* payload carries the qitem's summary + // (null for legacy/omitted — always present, never absent, so consumers + // can title feed cards without a second fetch). + | { type: "queue.created"; qitemId: string; sourceSession: string; destinationSession: string; priority: string; tier: string | null; summary: string | null } + | { type: "queue.handed_off"; qitemId: string; fromSession: string; toSession: string; closureReason: "handed_off_to"; summary: string | null } + | { type: "queue.claimed"; qitemId: string; destinationSession: string; claimedAt: string; closureRequiredAt: string | null; summary: string | null } + | { type: "queue.unclaimed"; qitemId: string; destinationSession: string; reason: string; summary: string | null } | { type: "qitem.fallback_routed"; qitemId: string; originalDestination: string; rerouteDestination: string; reason: string } | { type: "qitem.closure_overdue"; qitemId: string; destinationSession: string; closureRequiredAt: string; overdueSince: string } | { type: "inbox.absorbed"; inboxId: string; destinationSession: string; senderSession: string; promotedQitemId: string } @@ -215,7 +218,7 @@ export type RigEvent = // closure transitions, etc.). Lets the view-event-bridge wake SSE // consumers on /api/views/:name/sse when ANY queue state mutation // changes a view result-set, not just create/handoff/claim/unclaim. - | { type: "queue.updated"; qitemId: string; fromState: string; toState: string; closureReason: string | null; closureTarget: string | null; actorSession: string } + | { type: "queue.updated"; qitemId: string; fromState: string; toState: string; closureReason: string | null; closureTarget: string | null; actorSession: string; summary: string | null } // Coordination primitive (PL-004 Phase B) — project (classifier) / view. // project.classified: emitted when a stream item is successfully projected. // classifier.lease_*: lifecycle of the daemon-enforced single-writer lease. diff --git a/packages/daemon/src/domain/up-command-router.ts b/packages/daemon/src/domain/up-command-router.ts index bd32cfe14..4d7207724 100644 --- a/packages/daemon/src/domain/up-command-router.ts +++ b/packages/daemon/src/domain/up-command-router.ts @@ -2,8 +2,9 @@ import { LegacyRigSpecCodec as LegacyCodec } from "./rigspec-codec.js"; import { LegacyRigSpecSchema as LegacySchema } from "./rigspec-schema.js"; import { RigSpecCodec as PodCodec } from "./rigspec-codec.js"; import { RigSpecSchema as PodSchema } from "./rigspec-schema.js"; +import { yamlTextHasTopLevelRigsList } from "./topology/topology-manifest.js"; -export type SourceKind = "rig_spec" | "rig_bundle" | "rig_name"; +export type SourceKind = "rig_spec" | "rig_bundle" | "rig_name" | "topology"; export interface RouteResult { sourceKind: SourceKind; @@ -31,8 +32,12 @@ export class UpCommandRouter { } route(sourceRef: string): RouteResult { - // Rig name detection: no '/' and no file extension → treat as existing rig name - if (!sourceRef.includes("/") && !sourceRef.match(/\.(ya?ml|rigbundle)$/i)) { + // Rig name detection: no '/' and no file extension → treat as existing rig name. + // OPR.0.4.4.11 (guard G-1): `.rigtopology` counts as a file extension here, + // so a bare `factory.rigtopology` reaches topology routing. A no-slash + // EXTENSIONLESS source keeps rig_name precedence byte-for-byte (FR-2 + // negative AC; `./file` is the explicit path escape hatch). + if (!sourceRef.includes("/") && !sourceRef.match(/\.(ya?ml|rigbundle|rigtopology)$/i)) { return { sourceKind: "rig_name", sourceRef }; } @@ -45,7 +50,26 @@ export class UpCommandRouter { if (ext === "rigbundle") { return { sourceKind: "rig_bundle", sourceRef }; } + // OPR.0.4.4.11 (arch ruling 1): the declared extension BINDS — a + // `.rigtopology` file failing manifest validation errors downstream AS a + // topology; it never falls through to rig-spec parsing. + if (ext === "rigtopology") { + return { sourceKind: "topology", sourceRef }; + } if (ext === "yaml" || ext === "yml") { + // OPR.0.4.4.11 (guard yaml fold): a YAML document with a top-level + // `rigs:` LIST is a topology by the FR-1 detection contract — sniff + // BEFORE rig-spec validation so a `factory.yaml` topology never dies + // in a confusing rig-spec error. A rig spec never carries top-level + // `rigs:`, so the sniff is unambiguous. Unreadable files flow to the + // existing rig-spec path, which reports them unchanged. + try { + if (yamlTextHasTopLevelRigsList(this.fs.readFile(sourceRef))) { + return { sourceKind: "topology", sourceRef }; + } + } catch { + // fall through — validateYamlAsRigSpec owns the error surface + } // Validate semantically — reject bundle.yaml, package.yaml, etc. return this.validateYamlAsRigSpec(sourceRef); } @@ -107,6 +131,14 @@ export class UpCommandRouter { try { const content = this.fs.readFile(sourceRef); + // OPR.0.4.4.11 (guard G-1): extensionless PATH-form sources gain the + // top-level `rigs:` list sniff → topology. Only reachable for path + // forms — a no-slash extensionless source classified rig_name above + // and never reaches autoDetect (documented precedence). + if (yamlTextHasTopLevelRigsList(content)) { + return { sourceKind: "topology", sourceRef }; + } + // Try canonical pod-aware const podRaw = PodCodec.parse(content); const podVal = PodSchema.validate(podRaw); diff --git a/packages/daemon/src/domain/user-settings/settings-store.ts b/packages/daemon/src/domain/user-settings/settings-store.ts index b8901471a..5f877343b 100644 --- a/packages/daemon/src/domain/user-settings/settings-store.ts +++ b/packages/daemon/src/domain/user-settings/settings-store.ts @@ -215,6 +215,49 @@ export function isSettingsValidKey(key: string): key is SettingsValidKey { return (SETTINGS_VALID_KEYS as readonly string[]).includes(key); } +// ── OPR.0.4.4.15 (guard G15-P1 fold, arch-endorsed) ───────────────────────── +// ONE registered dynamic key CLASS — NOT a general dynamic-key mechanism: +// `feed.subscriptions..enabled` (boolean; the v1 per-host key set is +// CLOSED to {enabled}). The closed-set discipline applies to the store's key +// GRAMMAR: only this pattern is accepted beyond SETTINGS_VALID_KEYS; every +// other unknown key keeps the existing reject-loud behavior byte-for-byte. +// hostId segment: [A-Za-z0-9_-]+ (a dotted host id is inexpressible in +// dotted keys — the write gate rejects it as unknown; readers warn-and- +// ignore); RESERVED segments (the flat toggle tails + 'enabled') never parse +// as host ids, so the flat keys and the dynamic class cannot collide. +// No env-var mapping for the dynamic class in v1 — file/API only. +// Twin: packages/cli/src/config-store.ts carries the same class (parity test +// pins them — the host-registry twin discipline). +const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/; +// Reserved in BOTH spellings: the key-level snake_case toggle tails AND the +// camelCase FILE-level leaf names (KEY_TO_PATH maps audit_log→auditLog etc.), +// so no host id can ever shadow a flat toggle at either layer. +export const FEED_HOST_RESERVED_SEGMENTS = new Set([ + "action_required", + "actionRequired", + "approvals", + "shipped", + "progress", + "audit_log", + "auditLog", + "enabled", +]); + +export function parseFeedHostSubscriptionKey(key: string): { hostId: string } | null { + const m = key.match(FEED_HOST_KEY_RE); + if (!m) return null; + const hostId = m[1]!; + if (FEED_HOST_RESERVED_SEGMENTS.has(hostId)) return null; + return { hostId }; +} + +function coerceFeedHostSubscriptionValue(key: string, raw: string): boolean { + const v = raw.trim().toLowerCase(); + if (v === "true") return true; + if (v === "false") return false; + throw new Error(`Invalid value for ${key}: expected "true" or "false", got "${raw}"`); +} + function readEnv(primary: string, legacy?: string): string | undefined { const p = process.env[primary]; if (p !== undefined && p !== "") return p; @@ -605,6 +648,18 @@ export class SettingsStore { } set(key: string, value: string): void { + // OPR.0.4.4.15: the ONE registered dynamic class is accepted here; + // every OTHER unknown key keeps the reject-loud behavior below + // byte-for-byte. + const feedHost = parseFeedHostSubscriptionKey(key); + if (feedHost) { + const coercedDyn = coerceFeedHostSubscriptionValue(key, value); + const fcDyn = this.readConfigFile(); + setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn); + mkdirSync(path.dirname(this.configPath), { recursive: true }); + writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8"); + return; + } if (!isSettingsValidKey(key)) { throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`); } @@ -616,11 +671,62 @@ export class SettingsStore { writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8"); } + /** OPR.0.4.4.15 — resolve one dynamic feed-host subscription key. + * File-or-default only (no env mapping for the dynamic class in v1); + * default false = not subscribed. Returns null for keys outside the + * registered class. */ + resolveFeedHostSubscription(key: string): ResolvedSetting | null { + const feedHost = parseFeedHostSubscriptionKey(key); + if (!feedHost) return null; + const fc = this.readConfigFile(); + const fileVal = getNestedValue(fc, ["feed", "subscriptions", feedHost.hostId, "enabled"]); + if (typeof fileVal === "boolean") return { value: fileVal, source: "file", defaultValue: false }; + return { value: false, source: "default", defaultValue: false }; + } + + /** OPR.0.4.4.15 — enumerate persisted per-host subscriptions (the + * aggregator's read). Reserved segments and non-conforming shapes are + * WARNED and IGNORED (the ratified guard: operator error surfaces + * visibly, never misparses, never rejects the whole config). */ + listFeedHostSubscriptions(): Array<{ hostId: string; enabled: boolean }> { + const fc = this.readConfigFile(); + const subs = getNestedValue(fc, ["feed", "subscriptions"]); + if (subs === null || subs === undefined || typeof subs !== "object" || Array.isArray(subs)) return []; + const out: Array<{ hostId: string; enabled: boolean }> = []; + for (const [segment, node] of Object.entries(subs as Record)) { + if (node === null || typeof node !== "object" || Array.isArray(node)) continue; // flat toggle leaves — not host nodes + if (FEED_HOST_RESERVED_SEGMENTS.has(segment) || !/^[A-Za-z0-9_-]+$/.test(segment)) { + process.stderr.write( + `[openrig-settings] feed.subscriptions.${segment} ignored as a host subscription: segment is ${FEED_HOST_RESERVED_SEGMENTS.has(segment) ? "a reserved toggle name" : "not a valid host id segment ([A-Za-z0-9_-]+)"}\n`, + ); + continue; + } + const enabled = (node as Record)["enabled"]; + if (typeof enabled !== "boolean") { + process.stderr.write(`[openrig-settings] feed.subscriptions.${segment}.enabled ignored: expected boolean, got ${JSON.stringify(enabled)}\n`); + continue; + } + out.push({ hostId: segment, enabled }); + } + return out; + } + reset(key?: string): void { if (key === undefined) { try { unlinkSync(this.configPath); } catch { /* missing is fine */ } return; } + // OPR.0.4.4.15: dynamic-class reset removes the whole host node + // (unsubscribe leaves no residue). + const feedHost = parseFeedHostSubscriptionKey(key); + if (feedHost) { + if (!existsSync(this.configPath)) return; + const fcDyn = this.readConfigFile(); + const parent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record | undefined; + if (parent && feedHost.hostId in parent) delete parent[feedHost.hostId]; + writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8"); + return; + } if (!isSettingsValidKey(key)) { throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`); } diff --git a/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts b/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts index 8960d151b..18f782b61 100644 --- a/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts +++ b/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts @@ -70,7 +70,10 @@ export function workspaceScaffoldDirs(): string[] { ...DEFAULT_MISSIONS.flatMap((mission) => [ `missions/${mission.id}`, `missions/${mission.id}/slices`, - ...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`), + ...mission.slices.flatMap((slice) => [ + `missions/${mission.id}/slices/${slice.id}`, + `missions/${mission.id}/slices/${slice.id}/proof`, + ]), ]), ]; } @@ -196,8 +199,18 @@ slice: ${slice.id} # ${slice.title} +## Intent + ${slice.objective} +## Mini-requirements + +1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`. + +## Proof contract + +- [ ] The work is visible in the Project slice — captured (see IMPLEMENTATION-PRD.md). + ## Queue Mapping Queue items for this slice should mention or tag: @@ -241,6 +254,33 @@ slice: ${slice.id} `; } +function sliceProof(slice: DefaultSlice): string { + return `# PROOF — ${slice.dotId} ${slice.title} + +> **WHO/WHEN:** the impl/QA pair that worked the slice, at slice-close — a slice is NOT done until this file exists and every proof-contract item has evidence (mapped 1:1, artifacts under \`proof/\`). See the \`mission-slice-sop\` skill + the conventions SSOT (\`docs/reference/sdlc-conventions.md\`). +> +> **HOW (the drop verb, not hand-placement):** put media files under \`proof/\`, then ATTACH them with \`rig proof add ${slice.dotId} --artifact-type qa --verdict PASS --candidate-sha --money-evidence "" --evidences "1" --media "screenshot-01.png"\` — the drop writes the C1 header the Living Notes DELIVERED pairing joins on. Hand-placing files without a drop leaves the deliverable unpaired and \`unverified\`. + +Closed by: Date: Verdict: + +## What this proves + +<1-3 sentences: the claim the slice made, now demonstrated> + +## Artifacts (media in proof/) + +Dropped via \`rig proof add … --evidences … --media …\` (one drop per verdict; media attached, never only hand-listed): + +- proof/screenshot-01.png — +- proof/capture-behavior.gif — +- proof/command-output.txt — + +## Residue / caveats (if any) + + +`; +} + function slicePrd(mission: DefaultMission, slice: DefaultSlice): string { return `--- title: ${slice.title} Implementation Notes @@ -252,15 +292,21 @@ slice: ${slice.id} # ${slice.title} Implementation Notes -## Goal +## Intent ${slice.objective} -## Acceptance +## Mini-requirements -- [ ] The work is visible in the Project slice. +1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`. + +## Proof contract + +- [ ] The work is visible in the Project slice — captured. - [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`. -- [ ] Any proof artifacts are referenced from the slice before closure. +- [ ] Proof artifacts are referenced from the slice before closure (drop via \`rig proof add\`). + +> Conventions SSOT: \`docs/reference/sdlc-conventions.md\` — section names, proof-contract format, the two locks, C1 headers. For a small slice the mini-requirements may BE the whole PRD. `; } @@ -463,6 +509,7 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri files.push( { relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) }, { relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) }, + { relPath: `missions/${mission.id}/slices/${slice.id}/PROOF.md`, content: sliceProof(slice) }, { relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) }, ); // V0.3.1 slice 21: getting-started slices ship a timeline.md diff --git a/packages/daemon/src/domain/workspace/workspace-doctor.ts b/packages/daemon/src/domain/workspace/workspace-doctor.ts index 5b5dc868f..d0985ec45 100644 --- a/packages/daemon/src/domain/workspace/workspace-doctor.ts +++ b/packages/daemon/src/domain/workspace/workspace-doctor.ts @@ -451,6 +451,95 @@ export function checkOptionalSliceDocs(opts: CheckSliceDocsInput): DoctorCheck { }; } +// OPR.0.4.4.23 — the SDLC convention sections a slice README must carry to +// project in the Living Notes UI. SSOT: docs/reference/sdlc-conventions.md. +const SDLC_CONVENTION_SECTIONS = ["## Intent", "## Mini-requirements", "## Proof contract"] as const; + +interface SliceMissingSections { + mission: string; + slice: string; + path: string; + missing: string[]; +} + +/** + * Check #8 — SDLC convention sections (OPR.0.4.4.23). + * + * Walks each mission's slices and reports slice READMEs missing any of the + * convention sections (`## Intent` / `## Mini-requirements` / + * `## Proof contract`) the Living Notes UI projects. Warn-only (advisory / + * fail-open — the deep per-slice audit is `rig scope audit`; this row is + * the workspace-level pointer). Slices with no README at all are check #6's + * concern, not double-reported here. + */ +export function checkSdlcConventionSections(opts: CheckSliceDocsInput): DoctorCheck { + const { missionsRoot } = opts; + let missions: string[]; + try { + missions = fs.readdirSync(missionsRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + return { + check: "sdlc_convention_sections", + status: "warn", + message: code === "ENOENT" + ? `missions root '${missionsRoot}' does not exist; no slice sections to check` + : `cannot read missions root '${missionsRoot}': ${(err as Error).message}`, + evidence: { missionsRoot, errorCode: code ?? "unknown" }, + }; + } + + const offenders: SliceMissingSections[] = []; + let slicesChecked = 0; + for (const mission of missions) { + const slicesDir = path.join(missionsRoot, mission, "slices"); + let slices: string[]; + try { + slices = fs.readdirSync(slicesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + continue; // no slices subdir is fine — mission may not be slice-organized + } + for (const slice of slices) { + const slicePath = path.join(slicesDir, slice); + const readmePath = path.join(slicePath, "README.md"); + let readme: string; + try { + readme = fs.readFileSync(readmePath, "utf-8"); + } catch { + continue; // no README = check #6's finding, not a section finding + } + slicesChecked++; + const missing = SDLC_CONVENTION_SECTIONS.filter( + (section) => !new RegExp(`^${section.replace("## ", "##\\s+")}\\s*$`, "m").test(readme), + ); + if (missing.length > 0) { + offenders.push({ mission, slice, path: slicePath, missing: [...missing] }); + } + } + } + + if (offenders.length === 0) { + return { + check: "sdlc_convention_sections", + status: "ok", + message: `every slice README under '${missionsRoot}' carries the SDLC convention sections (${slicesChecked} checked)`, + evidence: { missionsRoot, slicesChecked, offenders: [] }, + }; + } + return { + check: "sdlc_convention_sections", + status: "warn", + message: `${offenders.length} slice README${offenders.length === 1 ? " is" : "s are"} missing SDLC convention sections (Intent / Mini-requirements / Proof contract) and will not fully project in the Living Notes UI`, + fixHint: + "add the missing sections per docs/reference/sdlc-conventions.md (rig scope slice create scaffolds them); run rig scope audit for per-slice findings — advisory only, nothing is blocked", + evidence: { missionsRoot, slicesChecked, offenders }, + }; +} + export interface CheckMissionNotesInput { missionsRoot: string; } @@ -583,6 +672,10 @@ export function runWorkspaceDoctor(input: RunDoctorInput): DoctorReport { checkMissionNotesPresence({ missionsRoot: input.slicesRoot, }), + // OPR.0.4.4.23 — check #8: SDLC convention sections (advisory warn). + checkSdlcConventionSections({ + missionsRoot: input.slicesRoot, + }), ]; const summary = { ok: 0, warn: 0, fail: 0 }; for (const c of checks) summary[c.status]++; diff --git a/packages/daemon/src/routes/config.ts b/packages/daemon/src/routes/config.ts index 9c8e0da34..d727d24f2 100644 --- a/packages/daemon/src/routes/config.ts +++ b/packages/daemon/src/routes/config.ts @@ -18,6 +18,7 @@ import { join } from "node:path"; import { SETTINGS_VALID_KEYS, isSettingsValidKey, + parseFeedHostSubscriptionKey, type SettingsStore, } from "../domain/user-settings/settings-store.js"; import { @@ -37,7 +38,9 @@ export function configRoutes(): Hono { router.get("/", (c) => { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); - return c.json({ settings: store.resolveAllWithSource() }); + // OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the + // static settings map (existing consumers of `settings` unaffected). + return c.json({ settings: store.resolveAllWithSource(), feedHostSubscriptions: store.listFeedHostSubscriptions() }); }); router.post("/init-workspace", async (c) => { @@ -96,6 +99,10 @@ export function configRoutes(): Hono { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); + // OPR.0.4.4.15: the ONE registered dynamic class resolves here; every + // other unknown key keeps the 400 below byte-for-byte. + const dynamic = store.resolveFeedHostSubscription(key); + if (dynamic) return c.json(dynamic); if (!isSettingsValidKey(key)) { return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400); } @@ -106,7 +113,8 @@ export function configRoutes(): Hono { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); - if (!isSettingsValidKey(key)) { + const isDynamic = parseFeedHostSubscriptionKey(key) !== null; + if (!isDynamic && !isSettingsValidKey(key)) { return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400); } const body = (await c.req.json<{ value?: string }>().catch(() => ({}))) as { value?: string }; @@ -115,7 +123,7 @@ export function configRoutes(): Hono { } try { store.set(key, body.value); - return c.json({ ok: true, key, resolved: store.resolveOne(key) }); + return c.json({ ok: true, key, resolved: isDynamic ? store.resolveFeedHostSubscription(key) : store.resolveOne(key as never) }); } catch (err) { return c.json({ error: (err as Error).message }, 400); } @@ -125,12 +133,13 @@ export function configRoutes(): Hono { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); - if (!isSettingsValidKey(key)) { + const isDynamic = parseFeedHostSubscriptionKey(key) !== null; + if (!isDynamic && !isSettingsValidKey(key)) { return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400); } try { store.reset(key); - return c.json({ ok: true, key, resolved: store.resolveOne(key) }); + return c.json({ ok: true, key, resolved: isDynamic ? store.resolveFeedHostSubscription(key) : store.resolveOne(key as never) }); } catch (err) { return c.json({ error: (err as Error).message }, 400); } diff --git a/packages/daemon/src/routes/files.ts b/packages/daemon/src/routes/files.ts index a4e7f97e2..f1d4c196c 100644 --- a/packages/daemon/src/routes/files.ts +++ b/packages/daemon/src/routes/files.ts @@ -166,12 +166,57 @@ export function filesRoutes(): Hono { if (!rootName || !relativePath) return c.json({ error: "root_and_path_required" }, 400); try { const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath); + const size = fs.statSync(resolved).size; + let contentType = inferContentType(resolved); + // OPR.0.4.4.20 FR-11: .html renders as text/html ONLY under the explicit + // ?render=1 opt-in (text/plain stays the default for every other read). + // First-party operator mockups open in a new tab; CSP here is a + // documented advisory posture, deliberately NOT a deny-by-default gate. + if (c.req.query("render") === "1" && path.extname(resolved).toLowerCase() === ".html") { + contentType = "text/html; charset=utf-8"; + } + + // OPR.0.4.4.20 FR-5: byte-range support on THIS route only (iOS Safari + // requires 206 + Accept-Ranges for media playback; 200-with-whole-file + // is the documented iOS failure mode). Single-range form only. + const rangeHeader = c.req.header("Range"); + if (rangeHeader) { + const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN; + const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1; + if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) { + return new Response(null, { + status: 416, + headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" }, + }); + } + const boundedEnd = Math.min(end, size - 1); + const length = boundedEnd - start + 1; + const fd = fs.openSync(resolved, "r"); + try { + const buf = Buffer.alloc(length); + fs.readSync(fd, buf, 0, length, start); + return new Response(new Uint8Array(buf), { + status: 206, + headers: { + "Content-Type": contentType, + "Content-Range": `bytes ${start}-${boundedEnd}/${size}`, + "Content-Length": String(length), + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=300", + }, + }); + } finally { + fs.closeSync(fd); + } + } + const data = fs.readFileSync(resolved); - const contentType = inferContentType(resolved); return new Response(new Uint8Array(data), { status: 200, headers: { "Content-Type": contentType, + "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=300", }, }); diff --git a/packages/daemon/src/routes/mission-control.ts b/packages/daemon/src/routes/mission-control.ts index a4a35aec4..f3ce527ce 100644 --- a/packages/daemon/src/routes/mission-control.ts +++ b/packages/daemon/src/routes/mission-control.ts @@ -3,6 +3,14 @@ import { streamSSE } from "hono/streaming"; import type Database from "better-sqlite3"; import type { EventBus } from "../domain/event-bus.js"; import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js"; +import { LOCAL_HOST_ID } from "../domain/hosts/fanout-contract.js"; +import { loadHostRegistry, resolveHost } from "../domain/hosts/hosts-registry-reader.js"; +import { remoteJsonRequest } from "../domain/hosts/remote-daemon-http.js"; + +/** OPR.0.4.4.15 FR-4 — the remote-action deadline class (a transactional + * write, not a bootstrap; named at this call-site per the arch + * required-argument sharpening). */ +const REMOTE_ACTION_TIMEOUT_MS = 10_000; import { MissionControlActionLogError, MISSION_CONTROL_VERBS, @@ -231,6 +239,8 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { : err.code === "qitem_already_terminal" ? 409 : err.code === "destination_required" ? 400 : err.code === "annotation_required" ? 400 + : err.code === "decision_required" ? 400 + : err.code === "qitem_not_leg1_parked" ? 409 : 500; return c.json( { error: err.code, message: err.message, ...(err.details ?? {}) }, @@ -295,6 +305,9 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { app.post("/notifications/test", requireAuth); // POST /action — execute one of 7 verbs through the atomic write contract. + // OPR.0.4.4.15 FR-4: an OPTIONAL hostId routes the SAME verb to the + // ORIGIN host's daemon server-side (the item's verbs execute where the + // qitem lives). hostId absent or 'local' = today's path byte-for-byte. app.post("/action", async (c) => { const body = await c.req .json<{ @@ -305,8 +318,10 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { body?: string; annotation?: string; reason?: string; + decision?: string; notify?: boolean; auditNotes?: Record; + hostId?: string; }>() .catch(() => ({} as never)); if (!body.verb) return c.json({ error: "verb is required" }, 400); @@ -322,6 +337,55 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { } if (!body.qitemId) return c.json({ error: "qitemId is required" }, 400); if (!body.actorSession) return c.json({ error: "actorSession is required" }, 400); + if (typeof body.hostId === "string" && body.hostId !== "" && body.hostId !== LOCAL_HOST_ID) { + // Remote forward (arch ruling 4: ONE write-path, ONE verb allowlist — + // the checks above already ran; the origin daemon re-validates on its + // own route). NOTHING is written to the LOCAL mission_control_actions + // here — the origin host's audit row + this structured passthrough + // are THE record (R15-3; local outbound audit log DROPPED by arch). + const registryLoader = (c.get("hostRegistryLoader" as never) as (() => ReturnType) | undefined) ?? loadHostRegistry; + const fetchImpl = c.get("remoteFetchImpl" as never) as typeof fetch | undefined; + const hostId = body.hostId; + const fail = (detail: string, failureClass: string, remoteStatus?: number) => + c.json({ error: "remote_action_failed", hostId, failureClass, ...(remoteStatus !== undefined ? { remoteStatus } : {}), detail }, 502); + const reg = registryLoader(); + if (!reg.ok) return fail(reg.error, "registry"); + const resolved = resolveHost(reg.registry, hostId); + if (!resolved.ok) return fail(resolved.error, "unknown-host"); + if (resolved.host.transport !== "http") { + return fail(`host '${hostId}' is SSH-declared; remote actions require an http-transport registry entry (url + bearer)`, "unsupported-transport"); + } + const { hostId: _dropped, ...forwardBody } = body as Record; + const res = await remoteJsonRequest(resolved.host, "/api/mission-control/action", { + method: "POST", + body: forwardBody, + timeoutMs: REMOTE_ACTION_TIMEOUT_MS, + fetchImpl, + }); + if (res.ok) { + // The origin's structured success response, verbatim — no + // optimistic local re-shaping, no local audit write. + return c.json(res.payload as Record); + } + switch (res.kind) { + case "bearer": + return fail(res.detail, "auth-failed"); + case "timeout": + return fail( + res.phase === "body" + ? `remote action timed out: response headers arrived (HTTP ${res.status}) but the body never completed` + : `remote action timed out after ${REMOTE_ACTION_TIMEOUT_MS}ms`, + "unreachable", + res.status, + ); + case "network": + return fail(res.detail, "unreachable"); + case "http": + // The origin refused (its own validation/auth/conflict) — its + // structured error rides through; NO fake success. + return fail(res.detail || `HTTP ${res.status}`, res.status === 401 || res.status === 403 ? "auth-failed" : "remote-error", res.status); + } + } try { const result = await getWriteContract(c).act({ verb: body.verb, @@ -331,6 +395,7 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { body: body.body, annotation: body.annotation, reason: body.reason, + decision: body.decision, notify: body.notify, auditNotes: body.auditNotes, }); @@ -353,8 +418,14 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono { const until = c.req.query("until") || undefined; const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined; const beforeId = c.req.query("before_id") || undefined; + // OPR.0.4.4.19 FR-9 — scope-approval target filters (pinned + // audit_notes_json read path). + const scopeTier = c.req.query("scope_tier") || undefined; + const scopeId = c.req.query("scope_id") || undefined; + const scopePath = c.req.query("scope_path") || undefined; + const approvalScope = c.req.query("approval_scope") || undefined; try { - const result = audit.query({ qitemId, actionVerb, actorSession, since, until, limit, beforeId }); + const result = audit.query({ qitemId, actionVerb, actorSession, since, until, limit, beforeId, scopeTier, scopeId, scopePath, approvalScope }); return c.json(result); } catch (err) { return c.json( diff --git a/packages/daemon/src/routes/queue.ts b/packages/daemon/src/routes/queue.ts index b5463559c..e5693fa44 100644 --- a/packages/daemon/src/routes/queue.ts +++ b/packages/daemon/src/routes/queue.ts @@ -7,9 +7,14 @@ import type { QueueState, } from "../domain/queue-repository.js"; import { QueueRepositoryError } from "../domain/queue-repository.js"; +import { isHumanSeatSession } from "../domain/human-route-enforcer.js"; import type { InboxHandler } from "../domain/inbox-handler.js"; import { InboxHandlerError } from "../domain/inbox-handler.js"; import type { OutboxHandler } from "../domain/outbox-handler.js"; +import { aggregateAttention } from "../domain/feed/attention-aggregator.js"; +import type { AttentionItem } from "../domain/feed/attention-aggregator.js"; +import { loadHostRegistry } from "../domain/hosts/hosts-registry-reader.js"; +import type { SettingsStore } from "../domain/user-settings/settings-store.js"; /** * Coordination L3 — Queue HTTP routes (PL-004 Phase A). @@ -26,16 +31,21 @@ import type { OutboxHandler } from "../domain/outbox-handler.js"; * single-pane view: * * - approval class → tier === "human-gate" - * - action-required → destinationSession matches - * /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/ + * - action-required → destinationSession is a human seat + * - parked-on-human → state === "blocked" AND blockedOn is a human + * seat (OPR.0.4.4.19 FR-6, C5 leg 1 — the owner + * keeps the potato; the human owes the decision) * + * Human-seat matching delegates to the single-source + * human-route-enforcer predicate (no drift with the SQL function). * Exported so the predicate is a discrete, testable surface. The route * layer composes this with the open-state default so only unresolved * items appear — closed/done attention items are not surfaced. */ -export function isAttentionItem(q: { tier: string | null; destinationSession: string }): boolean { +export function isAttentionItem(q: { tier: string | null; destinationSession: string; state?: string; blockedOn?: string | null }): boolean { if (q.tier === "human-gate") return true; - return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(q.destinationSession ?? ""); + if (isHumanSeatSession(q.destinationSession)) return true; + return q.state === "blocked" && isHumanSeatSession(q.blockedOn ?? null); } @@ -99,6 +109,7 @@ export function queueRoutes(): Hono { : err.code === "qitem_not_in_progress" ? 409 : err.code === "qitem_already_terminal" ? 409 : err.code === "unknown_destination_rig" ? 400 + : err.code === "human_route_fields_required" ? 400 : 500; return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200); } @@ -130,6 +141,7 @@ export function queueRoutes(): Hono { chainOfRecord?: string[]; targetRepo?: string; summary?: string | null; + evidenceRef?: string | null; }>().catch(() => ({} as never)); if (!body.sourceSession) return c.json({ error: "sourceSession is required" }, 400); @@ -155,6 +167,7 @@ export function queueRoutes(): Hono { chainOfRecord: body.chainOfRecord, targetRepo: body.targetRepo, summary: body.summary, + evidenceRef: body.evidenceRef, nudge: (body as { nudge?: boolean }).nudge, }); return c.json(item, 201); @@ -220,6 +233,9 @@ export function queueRoutes(): Hono { transitionNote?: string; closureReason?: string; closureTarget?: string; + blockedOn?: string; + summary?: string | null; + evidenceRef?: string | null; }>().catch(() => ({} as never)); if (!body.actorSession) return c.json({ error: "actorSession is required" }, 400); if (!body.state) return c.json({ error: "state is required" }, 400); @@ -232,6 +248,11 @@ export function queueRoutes(): Hono { transitionNote: body.transitionNote, closureReason: body.closureReason, closureTarget: body.closureTarget, + // OPR.0.4.4.19 FR-6 — the leg-1 park surface: blockedOn plus the + // park-time summary/evidence_ref persist inputs. + blockedOn: body.blockedOn, + summary: body.summary, + evidenceRef: body.evidenceRef, }); return c.json(item); } catch (err) { @@ -252,6 +273,7 @@ export function queueRoutes(): Hono { tags?: string[]; targetRepo?: string; summary?: string | null; + evidenceRef?: string | null; }>().catch(() => ({} as never)); if (!body.fromSession) return c.json({ error: "fromSession is required" }, 400); if (!body.toSession) return c.json({ error: "toSession is required" }, 400); @@ -273,6 +295,7 @@ export function queueRoutes(): Hono { tags: body.tags, targetRepo: body.targetRepo, summary: body.summary, + evidenceRef: body.evidenceRef, nudge: (body as { nudge?: boolean }).nudge, }); return c.json(result, 201); @@ -297,6 +320,7 @@ export function queueRoutes(): Hono { nudge?: boolean; targetRepo?: string; summary?: string | null; + evidenceRef?: string | null; }>().catch(() => ({} as never)); if (!body.fromSession) return c.json({ error: "fromSession is required" }, 400); if (!body.toSession) return c.json({ error: "toSession is required" }, 400); @@ -318,6 +342,7 @@ export function queueRoutes(): Hono { tags: body.tags, targetRepo: body.targetRepo, summary: body.summary, + evidenceRef: body.evidenceRef, nudge: body.nudge, }); return c.json(result, 201); @@ -362,6 +387,29 @@ export function queueRoutes(): Hono { // to pending|in-progress|blocked (callers can still override via // `state=...`). Composable with destinationSession/sourceSession/ // targetRepo/limit. + // OPR.0.4.4.15 FR-1 — the aggregated attention read: ONE payload in the + // shared P4 fanout contract ({items, hosts}), local always included, + // subscribed remote hosts fanned out DAEMON-SIDE (bearer never in the + // browser). A NEW sibling endpoint by arch ruling 3 — the existing + // /list?attention=1 wire below stays byte-preserved (the strongest form + // of the zero-config negative AC). + app.get("/attention-aggregate", async (c) => { + const repo = getRepo(c); + const store = c.get("settingsStore" as never) as SettingsStore | undefined; + // Same DI style as every other context dep — tests/QA inject a loader; + // production falls back to the shared S11 reader over the operator's + // real hosts.yaml. + const registryLoader = (c.get("hostRegistryLoader" as never) as (() => ReturnType) | undefined) ?? loadHostRegistry; + const payload = await aggregateAttention({ + // The SAME repo query the /list attention path runs, same open-state + // default — invoked, not duplicated. + listLocalAttention: () => repo.listAttention({ state: ["pending", "in-progress", "blocked"] }) as unknown as AttentionItem[], + listSubscriptions: () => (store ? store.listFeedHostSubscriptions() : []), + loadRegistry: registryLoader, + }); + return c.json(payload); + }); + app.get("/list", (c) => { const destinationSession = c.req.query("destinationSession") || undefined; const sourceSession = c.req.query("sourceSession") || undefined; diff --git a/packages/daemon/src/routes/review.ts b/packages/daemon/src/routes/review.ts new file mode 100644 index 000000000..3c5f4a67e --- /dev/null +++ b/packages/daemon/src/routes/review.ts @@ -0,0 +1,171 @@ +// Living Notes Packet 2 — the composed-review route family (OPR.0.4.4.20). +// +// Endpoints (ONE contract, all consumers — slice Review tab, U5 mission-board +// expansion, For-You expansion): +// GET /api/review/slice/:name — ComposedSliceReview +// GET /api/review/mission/:name — ComposedMissionReview +// GET /api/review/agents?scope=... — AgentsBand; scope is the THREE-VALUED +// parameter slice: | mission: | rig +// (never a second endpoint per consumer). +// +// Git lineage facts come from the repo at OPENRIG_REVIEW_GIT_REPO when set; +// otherwise lineage degrades honestly to "unknown" (the composer renders the +// three N1 facts with what it has — never a remembered claim). + +import { Hono } from "hono"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ReviewGatherer } from "../domain/review/gather.js"; +import type { AgentsScope } from "../domain/review/types.js"; +import { FileWriteService, sha256Hex } from "../domain/files/file-write-service.js"; +import type { AllowlistRoot } from "../domain/files/path-safety.js"; +import { freezeSliceExport, resolveAllowlisted } from "../domain/review/freeze.js"; +import { applyBriefSpine } from "../domain/review/brief-spine.js"; + +function getGatherer(c: { get(key: string): unknown }): ReviewGatherer | null { + return (c.get("reviewGatherer") as ReviewGatherer | undefined) ?? null; +} + +function parseScope(raw: string | undefined): AgentsScope | null { + if (!raw) return null; + if (raw === "rig") return raw; + if (raw.startsWith("slice:") && raw.length > "slice:".length) return raw as AgentsScope; + if (raw.startsWith("mission:") && raw.length > "mission:".length) return raw as AgentsScope; + return null; +} + +export function reviewRoutes(): Hono { + const app = new Hono(); + + app.get("/agents", (c) => { + const gatherer = getGatherer(c); + if (!gatherer) return c.json({ error: "review_composer_unavailable" }, 503); + const scope = parseScope(c.req.query("scope")); + if (!scope) { + return c.json( + { + error: "scope_invalid", + hint: "scope must be one of: slice: | mission: | rig", + }, + 400, + ); + } + const band = gatherer.composeAgents(scope); + if (!band) return c.json({ error: "scope_not_found", scope }, 404); + return c.json(band); + }); + + // OPR.0.4.4.22 — the rig-scope standalone altitude root (FR-1..FR-4): + // NEEDS YOU + AGENTS (health line) + SETTLED, same contract family as + // /slice/:name and /mission/:name. Read-only pure projection; the panel's + // standing cost is queue+ps only (drill-in rides the SHIPPED transcript + // routes — zero new routes for reading panes, per FR-6). + app.get("/rig", (c) => { + const gatherer = getGatherer(c); + if (!gatherer) return c.json({ error: "review_composer_unavailable" }, 503); + return c.json(gatherer.composeRig()); + }); + + app.get("/slice/:name", (c) => { + const gatherer = getGatherer(c); + if (!gatherer) return c.json({ error: "review_composer_unavailable" }, 503); + const composed = gatherer.composeSlice(c.req.param("name")); + if (!composed) return c.json({ error: "slice_not_found", name: c.req.param("name") }, 404); + return c.json(composed); + }); + + app.get("/mission/:name", (c) => { + const gatherer = getGatherer(c); + if (!gatherer) return c.json({ error: "review_composer_unavailable" }, 503); + const composed = gatherer.composeMission(c.req.param("name")); + if (!composed) return c.json({ error: "mission_not_found", name: c.req.param("name") }, 404); + return c.json(composed); + }); + + // FR-6 — the ONE synchronous compose-and-freeze endpoint (the P1/P2 + // interface cell). Invoked by the approve flow AFTER the stamp + audit + // row commit; a failed render never un-approves and re-invocation is + // idempotent. No watcher loop, no polling — deliberate, low-frequency. + app.post("/freeze", async (c) => { + const gatherer = getGatherer(c); + if (!gatherer) return c.json({ error: "review_composer_unavailable" }, 503); + const writeService = (c.get("fileWriteService" as never) as FileWriteService | undefined) ?? null; + const allowlist = (c.get("filesAllowlist" as never) as AllowlistRoot[] | undefined) ?? []; + if (!writeService) { + return c.json( + { + error: "file_write_service_unavailable", + hint: "the freeze write path is allowlist-governed; set OPENRIG_FILES_ALLOWLIST=name:/abs/path and restart the daemon", + }, + 503, + ); + } + let body: { scope?: string; name?: string; actor?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "body_invalid", hint: 'POST JSON {"scope":"slice","name":"","actor":""}' }, 400); + } + if (body.scope !== "slice" || !body.name || !body.actor) { + // Mission-tier freeze rides the same path once mission approval ships + // end-to-end (Packet 1 FR-9 mission semantics); slice is the v1 surface. + return c.json({ error: "freeze_request_invalid", hint: 'required: {"scope":"slice","name":"","actor":""}' }, 400); + } + const ctx = gatherer.composeSliceWithContext(body.name); + if (!ctx) return c.json({ error: "slice_not_found", name: body.name }, 404); + const outcome = freezeSliceExport({ + composed: ctx.composed, + sliceDir: ctx.sliceDir, + mediaRefs: ctx.mediaRefs, + allowlist, + writeService, + actor: body.actor, + }); + if (!outcome.ok) { + const status = outcome.error === "stamp_missing" ? 409 : outcome.error === "allowlist_missing" ? 403 : 500; + return c.json({ error: outcome.error, message: outcome.message, hint: outcome.hint }, status); + } + + // FR-8: the freeze IS one of the two deliberate brief-write moments — + // fold the generated status spine into MISSION_BRIEF.md, section-scoped, + // schema-order-preserving. Best-effort: a brief-write failure never + // un-freezes (the export + stamp already stand); it surfaces as a warning. + let briefWrite: string | null = null; + if (!outcome.alreadyFrozen && ctx.composed.missionId) { + try { + const mission = gatherer.composeMission(ctx.composed.missionId); + const target = gatherer.missionBriefTarget(ctx.composed.missionId); + if (mission && target) { + const applied = applyBriefSpine(target.content, mission.briefSpine); + if (applied === null) { + briefWrite = "skipped: MISSION_BRIEF.md does not carry the pinned exact-order schema (generation never guess-rewrites a malformed brief)"; + } else if (applied !== target.content) { + const stat = fs.statSync(target.briefPath); + const mapped = resolveAllowlisted(allowlist, path.dirname(target.briefPath)); + if (!mapped) { + briefWrite = "skipped: mission folder not under OPENRIG_FILES_ALLOWLIST"; + } else { + writeService.writeAtomic({ + rootName: mapped.root, + path: path.join(mapped.rel, "MISSION_BRIEF.md"), + content: applied, + expectedMtime: stat.mtime.toISOString(), + expectedContentHash: sha256Hex(target.content), + actor: body.actor, + }); + briefWrite = "spine updated"; + } + } else { + briefWrite = "spine unchanged"; + } + } + } catch (err) { + briefWrite = `failed (freeze unaffected): ${err instanceof Error ? err.message : String(err)}`; + } + } + + return c.json({ ok: true, path: outcome.path, alreadyFrozen: outcome.alreadyFrozen, briefWrite }); + }); + + return app; +} diff --git a/packages/daemon/src/routes/scope-approve.ts b/packages/daemon/src/routes/scope-approve.ts new file mode 100644 index 000000000..752a4793e --- /dev/null +++ b/packages/daemon/src/routes/scope-approve.ts @@ -0,0 +1,66 @@ +// OPR.0.4.4.19 FR-9 — POST /api/scope/approve: the ONE write path behind +// `rig scope slice|mission approve`. Frontmatter stamp + append-only audit +// row in one daemon operation (scope-approve service owns ordering + the +// no-half-stamp guarantee). + +import { Hono } from "hono"; +import type { SliceIndexer } from "../domain/slices/slice-indexer.js"; +import type { MissionControlActionLog } from "../domain/mission-control/mission-control-action-log.js"; +import { ScopeApproveError, ScopeApproveService, type ApprovalScope, type ScopeTier } from "../domain/scope/scope-approve.js"; + +export function scopeApproveRoutes(): Hono { + const app = new Hono(); + + app.post("/", async (c) => { + const body = await c.req.json<{ + scopeTier?: string; + scopePath?: string; + approvalScope?: string; + actorSession?: string; + onBehalfOf?: string | null; + }>().catch(() => ({} as never)); + + if (body.scopeTier !== "slice" && body.scopeTier !== "mission") { + return c.json({ error: "scope_tier_invalid", message: "scopeTier must be 'slice' or 'mission'" }, 400); + } + if (!body.scopePath) return c.json({ error: "scope_path_required", message: "scopePath is required" }, 400); + if (!body.actorSession) return c.json({ error: "actor_session_required", message: "actorSession is required" }, 400); + // Omitting approvalScope means delivery (back-compatible default). + const approvalScope: ApprovalScope = body.approvalScope === undefined ? "delivery" : (body.approvalScope as ApprovalScope); + if (approvalScope !== "spec" && approvalScope !== "delivery") { + return c.json({ error: "approval_scope_invalid", message: "approvalScope must be 'spec' or 'delivery' (omit for delivery)" }, 400); + } + + const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined; + const actionLog = c.get("missionControlActionLog" as never) as MissionControlActionLog | undefined; + if (!actionLog) return c.json({ error: "action_log_unavailable" }, 503); + + const service = new ScopeApproveService({ + missionsRoot: () => (indexer?.isReady() ? indexer.slicesRoot : null), + actionLog, + }); + + try { + const result = service.approve({ + scopeTier: body.scopeTier as ScopeTier, + scopePath: body.scopePath, + approvalScope, + actorSession: body.actorSession, + onBehalfOf: body.onBehalfOf ?? null, + }); + return c.json(result, 201); + } catch (err) { + if (err instanceof ScopeApproveError) { + const status = err.code === "scope_not_found" ? 404 + : err.code === "already_approved" ? 409 + : err.code === "workspace_not_configured" ? 503 + : err.code === "audit_write_failed" ? 500 + : 400; + return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200); + } + return c.json({ error: "internal", message: err instanceof Error ? err.message : "internal error" }, 500); + } + }); + + return app; +} diff --git a/packages/daemon/src/routes/scope-audit.ts b/packages/daemon/src/routes/scope-audit.ts index 00bd0fa43..60a301b35 100644 --- a/packages/daemon/src/routes/scope-audit.ts +++ b/packages/daemon/src/routes/scope-audit.ts @@ -18,6 +18,23 @@ function directoryHasEntries(dir: string): boolean { } } +// OPR.0.4.4.19 FR-10 (C1 backstop input) — list the slice's proof/ markdown +// artifacts with raw frontmatter. Media files are exempt by construction. +// Undefined when the dir is absent/unreadable so the classifier stays inert. +function listProofArtifactsForAudit(proofDir: string): Array<{ path: string; frontmatterRaw: string | null }> | undefined { + if (!fs.existsSync(proofDir)) return undefined; + try { + return fs.readdirSync(proofDir) + .filter((f) => f.toLowerCase().endsWith(".md")) + .map((f) => { + const artifactPath = path.join(proofDir, f); + return { path: artifactPath, frontmatterRaw: extractFrontmatterRaw(fs.readFileSync(artifactPath, "utf-8")) }; + }); + } catch { + return undefined; + } +} + export function scopeAuditRoutes(): Hono { const app = new Hono(); @@ -125,7 +142,8 @@ export function scopeAuditRoutes(): Hono { continue; } - const sliceFm = extractFrontmatterRaw(fs.readFileSync(sliceReadme, "utf-8")); + const sliceReadmeContent = fs.readFileSync(sliceReadme, "utf-8"); + const sliceFm = extractFrontmatterRaw(sliceReadmeContent); const readmeOnlyMarker = sliceFm !== null && /^progress_rail\s*:\s*readme-only/m.test(sliceFm); const indexedSlice = indexer.get(entry); @@ -144,6 +162,14 @@ export function scopeAuditRoutes(): Hono { proofDirHasEntries: directoryHasEntries(proofDir), hasProofPacket: indexedSlice?.proofPacket !== null && indexedSlice?.proofPacket !== undefined, sliceStatus: indexedSlice?.rawStatus ?? null, + // OPR.0.4.4.19 FR-10 backstop inputs (parity with the CLI builder). + proofArtifacts: listProofArtifactsForAudit(proofDir), + implementationPrdExists: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md")), + // OPR.0.4.4.23 convention-section advisory inputs (parity with the CLI builder). + readmeContent: sliceReadmeContent, + implementationPrdContent: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md")) + ? fs.readFileSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md"), "utf-8") + : null, }); if (!/^\d{2}-/.test(entry)) { diff --git a/packages/daemon/src/routes/slices.ts b/packages/daemon/src/routes/slices.ts index d50020379..392c1fd53 100644 --- a/packages/daemon/src/routes/slices.ts +++ b/packages/daemon/src/routes/slices.ts @@ -133,15 +133,7 @@ export function slicesRoutes(): Hono { if (!abs) return c.json({ error: "proof_asset_not_found" }, 404); const contentType = inferContentType(abs); - const data = fs.readFileSync(abs); - return new Response(new Uint8Array(data), { - status: 200, - headers: { - "Content-Type": contentType, - // Cache aggressively — proof assets are immutable once published. - "Cache-Control": "public, max-age=86400", - }, - }); + return fileAssetResponse(abs, contentType, c.req.header("Range")); }); // 3) Doc serving for the Docs tab — markdown content of a single file @@ -209,3 +201,54 @@ function inferContentType(absPath: string): string { default: return "application/octet-stream"; } } + +function fileAssetResponse(absPath: string, contentType: string, rangeHeader?: string): Response { + const size = fs.statSync(absPath).size; + const cacheControl = "public, max-age=86400"; + + if (rangeHeader) { + const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN; + const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1; + if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) { + return new Response(null, { + status: 416, + headers: { + "Content-Range": `bytes */${size}`, + "Accept-Ranges": "bytes", + }, + }); + } + + const boundedEnd = Math.min(end, size - 1); + const length = boundedEnd - start + 1; + const fd = fs.openSync(absPath, "r"); + try { + const buf = Buffer.alloc(length); + fs.readSync(fd, buf, 0, length, start); + return new Response(new Uint8Array(buf), { + status: 206, + headers: { + "Content-Type": contentType, + "Content-Range": `bytes ${start}-${boundedEnd}/${size}`, + "Content-Length": String(length), + "Accept-Ranges": "bytes", + "Cache-Control": cacheControl, + }, + }); + } finally { + fs.closeSync(fd); + } + } + + const data = fs.readFileSync(absPath); + return new Response(new Uint8Array(data), { + status: 200, + headers: { + "Content-Type": contentType, + "Content-Length": String(size), + "Accept-Ranges": "bytes", + "Cache-Control": cacheControl, + }, + }); +} diff --git a/packages/daemon/src/routes/terminal-ws.ts b/packages/daemon/src/routes/terminal-ws.ts index 325026b27..29fc1d31f 100644 --- a/packages/daemon/src/routes/terminal-ws.ts +++ b/packages/daemon/src/routes/terminal-ws.ts @@ -13,6 +13,9 @@ function constantTimeEqual(a: string, b: string): boolean { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +const MAX_EARLY_TERMINAL_FRAMES = 32; +const MAX_EARLY_TERMINAL_FRAME_BYTES = 256 * 1024; + export function registerTerminalWs( app: Hono, upgradeWebSocket: Parameters[0] extends { app: infer _A } ? never : never, @@ -76,6 +79,33 @@ export function registerTerminalWs( // and skip detach, leaving a phantom subscriber + a leaked pipe once attach // finally resolves. The flag lets onOpen re-detach after the fact. let closed = false; + // A client is allowed to send the moment its ws.onopen fires (the CHAT + // initialText frame does exactly that — OPR.0.4.4.20 delta-C), which can + // land here while onOpen is still awaiting attach. Buffer those frames and + // drain them once the broker resolves; dropping them loses the one + // pre-populated CHAT frame every time attach is slower than the client. + const earlyFrames: string[] = []; + let earlyFrameBytes = 0; + + const handleFrame = async (data: string): Promise => { + if (!broker) return; + try { + const msg = JSON.parse(data) as Record; + if (msg.type === "keys" && Array.isArray(msg.keys)) { + await broker.input({ type: "keys", keys: msg.keys as string[] }); + } else if (msg.type === "text" && typeof msg.text === "string") { + await broker.input({ type: "text", text: msg.text }); + } else if (msg.type === "scroll" && typeof msg.offset === "number") { + // OPR.0.4.0.39: per-subscriber scroll-back (tmux capture-pane window). + // offset = lines above the live bottom; 0 = live. Per-connection so each + // viewer scrolls independently (read-only on the shared pane). + if (subscriber) await broker.scroll(subscriber, msg.offset); + } + // FR-7: there is intentionally NO resize branch. The broker owns the + // fixed canonical geometry; a client-driven resize is ignored so + // multiple viewers cannot shrink the shared pane. + } catch { /* ignore malformed frames */ } + }; return { async onOpen(_evt: unknown, ws: { send(data: string): void; close(code: number, reason: string): void }) { @@ -92,29 +122,34 @@ export function registerTerminalWs( broker = b; // If the socket closed while attach was in flight, detach now so the // broker does not retain a dead subscriber (detach is idempotent). - if (closed) b.detach(sub); + if (closed) { b.detach(sub); return; } + // Drain any frames that arrived while attach was in flight, in order. + while (earlyFrames.length > 0 && !closed) { + const next = earlyFrames.shift()!; + earlyFrameBytes -= Buffer.byteLength(next, "utf8"); + await handleFrame(next); + } }, - async onMessage(evt: { data: unknown }, _ws: unknown) { - if (!broker || closed) return; + async onMessage(evt: { data: unknown }, ws: { close(code: number, reason: string): void }) { + if (closed) return; const data = typeof evt.data === "string" ? evt.data : ""; if (!data) return; - try { - const msg = JSON.parse(data) as Record; - if (msg.type === "keys" && Array.isArray(msg.keys)) { - await broker.input({ type: "keys", keys: msg.keys as string[] }); - } else if (msg.type === "text" && typeof msg.text === "string") { - await broker.input({ type: "text", text: msg.text }); - } else if (msg.type === "scroll" && typeof msg.offset === "number") { - // OPR.0.4.0.39: per-subscriber scroll-back (tmux capture-pane window). - // offset = lines above the live bottom; 0 = live. Per-connection so each - // viewer scrolls independently (read-only on the shared pane). - if (subscriber) await broker.scroll(subscriber, msg.offset); + if (!broker) { + const bytes = Buffer.byteLength(data, "utf8"); + if ( + earlyFrames.length >= MAX_EARLY_TERMINAL_FRAMES + || earlyFrameBytes + bytes > MAX_EARLY_TERMINAL_FRAME_BYTES + ) { + closed = true; + try { ws.close(1009, "terminal input before ready exceeded buffer limit"); } catch { /* already closed */ } + return; } - // FR-7: there is intentionally NO resize branch. The broker owns the - // fixed canonical geometry; a client-driven resize is ignored so - // multiple viewers cannot shrink the shared pane. - } catch { /* ignore malformed frames */ } + earlyFrames.push(data); + earlyFrameBytes += bytes; + return; + } + await handleFrame(data); }, async onClose() { diff --git a/packages/daemon/src/routes/up.ts b/packages/daemon/src/routes/up.ts index cae3aec09..d2cb361ac 100644 --- a/packages/daemon/src/routes/up.ts +++ b/packages/daemon/src/routes/up.ts @@ -10,6 +10,11 @@ import type { SnapshotCapture } from "../domain/snapshot-capture.js"; import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js"; import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js"; import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js"; +import { loadTopologyManifest } from "../domain/topology/topology-manifest.js"; +import { MultiRigLauncher } from "../domain/topology/multi-rig-launcher.js"; +import { remoteUpLeaf } from "../domain/topology/remote-up-leaf.js"; +import { loadHostRegistry } from "../domain/hosts/hosts-registry-reader.js"; +import type { HttpHostEntry } from "../domain/hosts/hosts-registry-reader.js"; export const upRoutes = new Hono(); @@ -216,6 +221,95 @@ upRoutes.post("/", async (c) => { return c.json({ error: (err as Error).message }, 400); } + // ── OPR.0.4.4.11 — topology branch (FR-2..FR-5) ────────────────────────── + if (sourceKind === "topology") { + // R11-2 daemon side (arch ruling 4 — enforcement on the public write + // path so every client inherits): a placement flag combined with a + // topology source is rejected; per-entry `host:` is the ONLY topology + // placement mechanism. + if (typeof body["host"] === "string" && (body["host"] as string).trim() !== "") { + return c.json( + { + error: + "a topology source cannot take a host placement flag: per-entry 'host:' in the manifest is the ONLY placement mechanism for topologies (two placement mechanisms must not coexist). Put 'host: ' on the entries to place remotely.", + code: "host_flag_topology", + }, + 400, + ); + } + if (plan) { + return c.json({ error: "plan mode is not supported for topology sources in v0 — validate the manifest with a dry read or run the up directly", code: "topology_plan_unsupported" }, 400); + } + + const manifestRes = loadTopologyManifest(resolvedSourceRef); + if (!manifestRes.ok) { + return c.json({ error: manifestRes.errors.join("\n"), errors: manifestRes.errors, code: "invalid_topology_manifest" }, 400); + } + + // Path-form entry sources resolve against the MANIFEST's directory — the + // manifest is the portable artifact (FR-1: same file, second + // environment, same topology). Bare names pass through untouched. + const manifestDir = nodePath.dirname(resolvedSourceRef); + const resolveEntrySource = (source: string): string => + source.includes("/") || /\.(ya?ml|rigbundle|rigtopology)$/i.test(source) + ? nodePath.resolve(manifestDir, source) + : source; + + const launcher = new MultiRigLauncher({ + // The SAME public lock pair this route uses for single-rig ups + // (guard G-2): the launcher participates in the route-side lock set. + // resolveLocalRef makes the lock key IDENTICAL to the launch ref + // (guard F1) — launchLocal receives the already-resolved ref. + resolveLocalRef: resolveEntrySource, + tryAcquire: (ref) => bootstrapOrchestrator.tryAcquire(ref), + release: (ref) => bootstrapOrchestrator.release(ref), + launchLocal: async (entryRef) => { + let entryKind: string; + try { + entryKind = upRouter.route(entryRef).sourceKind; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + // Defense-in-depth only: the parse-time v0 source-form boundary in + // the manifest validator (spec paths ONLY) rejects these before the + // walk starts; these guards keep the leaf honest if a future caller + // bypasses validation. + if (entryKind === "topology") { + return { ok: false, error: `entry '${entryRef}' is itself a topology — nested topologies are not supported; entries must be single-rig spec paths` }; + } + if (entryKind === "rig_name") { + return { + ok: false, + error: `entry '${entryRef}' resolves as an existing-rig name; v0 topology entries are spec paths only — restore existing rigs directly with 'rig up ${entryRef}'`, + }; + } + // The EXISTING single-rig leaf: the same public bootstrap() entry + // this route calls — stages/locks/provenance untouched (FR-3). + const result = await bootstrapOrchestrator.bootstrap({ + mode: "apply", + sourceRef: entryRef, + sourceKind: entryKind as "rig_spec" | "rig_bundle", + autoApprove, + }); + if (result.status === "completed") { + eventBus.emit({ type: "bootstrap.completed", runId: result.runId, rigId: result.rigId!, sourceRef: entryRef }); + return { ok: true }; + } + eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef: entryRef, error: result.errors[0] ?? result.status }); + return { ok: false, error: result.errors[0] ?? `bootstrap ${result.status}` }; + }, + // The SHIPPED remote single-rig leaf (POST {host}/api/up). Path-form + // refs resolve on the REMOTE daemon's filesystem — the shipped + // remote-up semantics, unchanged. + launchRemote: (source, host) => remoteUpLeaf({ sourceRef: source, autoApprove }, host as HttpHostEntry), + loadRegistry: () => loadHostRegistry(), + }); + + const aggregate = await launcher.launch(manifestRes.manifest); + // Honest aggregate either way: 200 only when EVERY entry is ok (FR-5). + return c.json({ topology: sourceRef, ...aggregate }, aggregate.ok ? 200 : 500); + } + // Bundle apply requires targetRoot if (sourceKind === "rig_bundle" && !plan && !targetRoot) { return c.json({ error: "targetRoot is required for bundle apply mode" }, 400); diff --git a/packages/daemon/src/routes/workspace.ts b/packages/daemon/src/routes/workspace.ts index 005d17bc2..fdc5b7401 100644 --- a/packages/daemon/src/routes/workspace.ts +++ b/packages/daemon/src/routes/workspace.ts @@ -11,7 +11,7 @@ // POST /api/workspace/doctor (slice-21 FR-5) // body (all optional): { workspaceRoot?: string; // filesAllowlistOverride?: string } -// response: DoctorReport (7-check workspace-readiness report) +// response: DoctorReport (8-check workspace-readiness report) // // No filesystem mutation. Operator picks the root + kind per invocation. // @@ -76,7 +76,7 @@ export function workspaceRoutes(): Hono { // Slice-21 FR-5 — workspace doctor. // // Resolves all inputs from the daemon's SettingsStore (matching - // /api/config), runs the 7-check orchestrator, and returns the + // /api/config), runs the 8-check orchestrator, and returns the // DoctorReport. Caller can override the workspace under check via // body.workspaceRoot — useful for `rig workspace doctor --workspace // ` to probe an alternate workspace without restarting the diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 458cc94a3..38fea53f6 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import fs from "node:fs"; import nodePath from "node:path"; import { fileURLToPath } from "node:url"; +import { stampFields } from "./build-info.js"; import type { RigRepository } from "./domain/rig-repository.js"; import type { SessionRegistry } from "./domain/session-registry.js"; import type { EventBus } from "./domain/event-bus.js"; @@ -84,6 +85,7 @@ import { watchdogRoutes } from "./routes/watchdog.js"; import { workflowRoutes } from "./routes/workflow.js"; import { missionControlRoutes } from "./routes/mission-control.js"; import { slicesRoutes } from "./routes/slices.js"; +import { reviewRoutes } from "./routes/review.js"; import { rigPolicyRoutes } from "./routes/rig-policy.js"; import { missionsRoutes } from "./routes/missions.js"; import { rigCmuxRoutes } from "./routes/rig-cmux.js"; @@ -92,6 +94,7 @@ import { getNodeInventory } from "./domain/node-inventory.js"; import { filesRoutes } from "./routes/files.js"; import { progressRoutes } from "./routes/progress.js"; import { scopeAuditRoutes } from "./routes/scope-audit.js"; +import { scopeApproveRoutes } from "./routes/scope-approve.js"; import { registerTerminalWs } from "./routes/terminal-ws.js"; import { createNodeWebSocket } from "@hono/node-ws"; import { steeringRoutes } from "./routes/steering.js"; @@ -199,6 +202,7 @@ export interface AppDeps { // optional: when slicesRoot is unset, the routes return a clear // "slices_root_not_configured" 503 so the UI can surface a setup hint. sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer; + reviewGatherer?: import("./domain/review/gather.js").ReviewGatherer; sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector; /** User Settings v0 — daemon-side settings store (env > file > default). */ settingsStore?: import("./domain/user-settings/settings-store.js").SettingsStore; @@ -470,6 +474,7 @@ export function createApp(deps: AppDeps): Hono { c.set("missionControlFleetCliCapability" as never, deps.missionControlFleetCliCapability); c.set("sliceIndexer" as never, deps.sliceIndexer); c.set("sliceDetailProjector" as never, deps.sliceDetailProjector); + c.set("reviewGatherer" as never, deps.reviewGatherer); c.set("filesAllowlist" as never, deps.filesAllowlist); c.set("settingsStore" as never, deps.settingsStore); c.set("previewRateLimiter" as never, deps.previewRateLimiter); @@ -519,13 +524,18 @@ export function createApp(deps: AppDeps): Hono { // OPR.0.4.3.21 — enrich the health surface with event-loop wedge evidence // when the monitor is wired. Absent monitor keeps the exact legacy body so // existing probes / tests that assert `{ status: "ok" }` are unchanged. + // OPR.0.4.4.11 FR-7 — a STAMPED build additionally carries + // {semver, commit, dirty, builtAt}; dev runs add NOTHING (stampFields is + // {} without a stamp — no invented identity, legacy bodies preserved). + const stamp = stampFields(); const monitor = deps.eventLoopMonitor; if (!monitor) { - return c.json({ status: "ok" }); + return c.json({ status: "ok", ...stamp }); } const eventLoop = monitor.snapshot(); return c.json({ status: "ok", + ...stamp, eventLoop, routeTimings: deps.routeTimingRecorder?.snapshot() ?? {}, }); @@ -595,6 +605,8 @@ export function createApp(deps: AppDeps): Hono { ); // Slice Story View v0 — slice indexer + per-tab payload routes. app.route("/api/slices", slicesRoutes()); + // Living Notes Packet 2 (OPR.0.4.4.20) — composed-review read contract. + app.route("/api/review", reviewRoutes()); // V0.3.1 slice 12 walk-item 1 — mission scope data layer // (aggregated mission metadata + slices filter; pairs with // useScopeMarkdown for README / PROGRESS content via /api/files/read). @@ -603,6 +615,8 @@ export function createApp(deps: AppDeps): Hono { app.route("/api/files", filesRoutes()); app.route("/api/progress", progressRoutes()); app.route("/api/scope/audit", scopeAuditRoutes()); + // OPR.0.4.4.19 FR-9 — scope approve: frontmatter stamp + audit row. + app.route("/api/scope/approve", scopeApproveRoutes()); // Operator Surface Reconciliation v0 — steering composition + health summary. app.route("/api/steering", steeringRoutes()); app.route("/api/health-summary", healthSummaryRoutes()); diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 110398c5f..7abb42913 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -140,6 +140,7 @@ import { queueItemSummarySchema } from "./db/migrations/044_queue_item_summary.j import { resumeVerificationSchema } from "./db/migrations/045_resume_verification.js"; import { seatIdentityVerdictsSchema } from "./db/migrations/046_seat_identity_verdicts.js"; import { eventsNodeTypeIndexSchema } from "./db/migrations/047_events_node_type_index.js"; +import { queueItemEvidenceRefSchema } from "./db/migrations/048_queue_item_evidence_ref.js"; import { RigPolicyStore } from "./domain/rig-policy/rig-policy-store.js"; import { MissionControlActionLog } from "./domain/mission-control/mission-control-action-log.js"; import { MissionControlWriteContract } from "./domain/mission-control/mission-control-write-contract.js"; @@ -226,7 +227,7 @@ export function collectAllowlistedProviderAuthEnv( export async function createDaemon(opts?: DaemonOptions): Promise { const dbPath = opts?.dbPath ?? ":memory:"; const db = createDb(dbPath); - migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema, workflowSpecsDiagnosticSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, queueItemSummarySchema, resumeVerificationSchema, seatIdentityVerdictsSchema, eventsNodeTypeIndexSchema]); + migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema, workflowSpecsDiagnosticSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, queueItemSummarySchema, resumeVerificationSchema, seatIdentityVerdictsSchema, eventsNodeTypeIndexSchema, queueItemEvidenceRefSchema]); const rigRepo = new RigRepository(db); const sessionRegistry = new SessionRegistry(db); @@ -782,7 +783,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise // queue/assignment state (non-inference contract; see slice 15 IMPL-PRD §2.3). seatActivityService, seatIdentityReconciler, - psProjectionService: new PsProjectionService({ db, seatActivity: seatActivityService }), + // OPR.0.4.4.21 — agentActivity feeds the rig-rollup attention + // predicate's needs_input signal (synchronous events lookup only). + psProjectionService: new PsProjectionService({ db, seatActivity: seatActivityService, agentActivity: agentActivityStore }), upRouter: new UpCommandRouter({ fsOps: { exists: (p: string) => fs.existsSync(p), @@ -833,7 +836,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise watchdogJobsRepo: watchdogJobsRepoInstance, watchdogHistoryLog: watchdogHistoryLogInstance, askService: (() => { - const psProjectionService = new PsProjectionService({ db }); + const psProjectionService = new PsProjectionService({ db, agentActivity: agentActivityStore }); const execDep = (cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> => new Promise((resolve) => { execFile(cmd, args, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { @@ -1225,6 +1228,18 @@ export async function createDaemon(opts?: DaemonOptions): Promise }); deps.sliceIndexer = sliceIndexer; deps.sliceDetailProjector = sliceDetailProjector; + // Living Notes Packet 2 (OPR.0.4.4.20): the composed-review gatherer. + // Git lineage facts come from OPENRIG_REVIEW_GIT_REPO when set; else + // lineage degrades honestly to unknown. + const { ReviewGatherer } = await import("./domain/review/gather.js"); + deps.reviewGatherer = new ReviewGatherer({ + db, + indexer: sliceIndexer, + gitRepoPath: process.env["OPENRIG_REVIEW_GIT_REPO"] ?? null, + // OPR.0.4.4.22 FR-2: the agent state glyph reads recorded hook + // activity (honest-unknown when absent) - synchronous, never polls. + activityStore: agentActivityStore, + }); } // UI Enhancement Pack v0: diff --git a/packages/daemon/test/attention-aggregate-route.test.ts b/packages/daemon/test/attention-aggregate-route.test.ts new file mode 100644 index 000000000..34eb4c990 --- /dev/null +++ b/packages/daemon/test/attention-aggregate-route.test.ts @@ -0,0 +1,80 @@ +// OPR.0.4.4.15 S15-3 — GET /api/queue/attention-aggregate (FR-1 route leg). +// +// The zero-config negative is the load-bearing pin here (arch ruling 3): +// the aggregate lives on a NEW sibling endpoint and the existing +// /list?attention=1 wire is byte-preserved — same repo query, same bare +// array, no hostId, no hosts[]. Fan-out depth is owned by the aggregator +// unit tests; this file pins the route wiring + payload contract shape. + +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { queueRoutes } from "../src/routes/queue.js"; +import { LOCAL_HOST_ID } from "../src/domain/hosts/fanout-contract.js"; +import type { HostRegistry } from "../src/domain/hosts/hosts-registry-reader.js"; + +const REGISTRY: HostRegistry = { + hosts: [{ id: "vps-b", transport: "ssh", target: "b.local" }], // ssh → unsupported-transport, no network needed +}; + +const LOCAL_ATTENTION_ROW = { qitemId: "q-1", priority: "urgent", tier: "human-gate" }; + +function makeApp(opts: { subscriptions?: Array<{ hostId: string; enabled: boolean }>; withStore?: boolean } = {}) { + const attentionCalls: unknown[] = []; + const repo = { + listAttention: (query: unknown) => { + attentionCalls.push(query); + return [LOCAL_ATTENTION_ROW]; + }, + }; + const store = { + listFeedHostSubscriptions: () => opts.subscriptions ?? [], + }; + const app = new Hono(); + app.use("*", async (c, next) => { + const set = c.set.bind(c) as (key: string, value: unknown) => void; + set("queueRepo", repo); + if (opts.withStore !== false) set("settingsStore", store); + set("hostRegistryLoader", () => ({ ok: true, registry: REGISTRY })); + await next(); + }); + app.route("/api/queue", queueRoutes()); + return { app, attentionCalls }; +} + +describe("GET /api/queue/attention-aggregate", () => { + it("zero-config: local items stamped + [local ok] hosts row; the SAME repo query + open-state default as /list", async () => { + const { app, attentionCalls } = makeApp({ subscriptions: [] }); + const res = await app.request("/api/queue/attention-aggregate"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + items: [{ ...LOCAL_ATTENTION_ROW, hostId: LOCAL_HOST_ID }], + hosts: [{ hostId: LOCAL_HOST_ID, status: "ok" }], + }); + expect(attentionCalls).toEqual([{ state: ["pending", "in-progress", "blocked"] }]); + }); + + it("missing settings store degrades to zero-config (no throw, local-only payload)", async () => { + const { app } = makeApp({ withStore: false }); + const res = await app.request("/api/queue/attention-aggregate"); + expect(res.status).toBe(200); + const data = (await res.json()) as { hosts: unknown[] }; + expect(data.hosts).toEqual([{ hostId: LOCAL_HOST_ID, status: "ok" }]); + }); + + it("a subscribed host flows through the aggregator (ssh entry → structured unsupported-transport, local intact)", async () => { + const { app } = makeApp({ subscriptions: [{ hostId: "vps-b", enabled: true }] }); + const res = await app.request("/api/queue/attention-aggregate"); + const data = (await res.json()) as { items: Array>; hosts: Array> }; + expect(data.items.map((i) => i["qitemId"])).toEqual(["q-1"]); + expect(data.hosts).toHaveLength(2); + expect(data.hosts[1]).toMatchObject({ hostId: "vps-b", status: "unsupported-transport" }); + }); + + it("ZERO-CONFIG WIRE PARITY: /list?attention=1 stays the bare array — no hostId, no hosts[] (byte-preserved route)", async () => { + const { app } = makeApp({ subscriptions: [] }); + const res = await app.request("/api/queue/list?attention=1"); + expect(res.status).toBe(200); + const data = (await res.json()) as unknown; + expect(data).toEqual([LOCAL_ATTENTION_ROW]); // exactly the repo rows, unwrapped, unstamped + }); +}); diff --git a/packages/daemon/test/attention-aggregator.test.ts b/packages/daemon/test/attention-aggregator.test.ts new file mode 100644 index 000000000..5400d3f6e --- /dev/null +++ b/packages/daemon/test/attention-aggregator.test.ts @@ -0,0 +1,184 @@ +// OPR.0.4.4.15 FR-1 — the daemon-side attention aggregator. +// +// The honesty legs are load-bearing: every subscribed host appears in +// hosts[] every call (asserted through the CONTRACT's hostsCovered — arch +// pin B), failures are per-host structured statuses (never all-or-nothing, +// never silent thinning), the registry is lazy, and the local leg INVOKES +// the injected query (reuse, not reimplementation). + +import { describe, it, expect } from "vitest"; +import { aggregateAttention, ATTENTION_READ_TIMEOUT_MS } from "../src/domain/feed/attention-aggregator.js"; +import type { AttentionAggregatorDeps } from "../src/domain/feed/attention-aggregator.js"; +import { LOCAL_HOST_ID, hostsCovered } from "../src/domain/hosts/fanout-contract.js"; +import type { HostRegistry } from "../src/domain/hosts/hosts-registry-reader.js"; + +const REGISTRY: HostRegistry = { + hosts: [ + { id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "B" }, + { id: "vps-c", transport: "http", url: "http://vps-c:7433", bearer_env: "C" }, + { id: "ssh-1", transport: "ssh", target: "x.local" }, + ], +}; + +function attentionResponse(items: unknown[]): Response { + return new Response(JSON.stringify(items), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function deps(overrides: Partial = {}): AttentionAggregatorDeps { + return { + listLocalAttention: () => [{ qitemId: "local-1" }], + listSubscriptions: () => [], + loadRegistry: () => ({ ok: true, registry: REGISTRY }), + env: { B: "tb", C: "tc" }, + ...overrides, + }; +} + +describe("aggregateAttention — zero-config + local leg", () => { + it("no enabled subscriptions: local items stamped LOCAL_HOST_ID, registry NEVER read", async () => { + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [{ hostId: "vps-b", enabled: false }], + loadRegistry: () => { + throw new Error("registry must not be read without an enabled remote subscription"); + }, + }), + ); + expect(res.items).toEqual([{ qitemId: "local-1", hostId: LOCAL_HOST_ID }]); + expect(res.hosts).toEqual([{ hostId: LOCAL_HOST_ID, status: "ok" }]); + }); + + it("local items come from the INJECTED query (the same repo query the route runs)", async () => { + let invoked = 0; + await aggregateAttention( + deps({ + listLocalAttention: () => { + invoked += 1; + return []; + }, + }), + ); + expect(invoked).toBe(1); + }); +}); + +describe("aggregateAttention — fan-out + per-host honesty (FR-1/R15-2)", () => { + it("merges multi-host items with origin stamping; hosts[] complete per the CONTRACT predicate", async () => { + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [ + { hostId: "vps-b", enabled: true }, + { hostId: "vps-c", enabled: true }, + ], + fetchImpl: (async (url: string | URL | Request) => + String(url).includes("vps-b") + ? attentionResponse([{ qitemId: "b-1" }, { qitemId: "b-2" }]) + : attentionResponse([{ qitemId: "c-1" }])) as typeof fetch, + }), + ); + expect(res.items).toEqual([ + { qitemId: "local-1", hostId: LOCAL_HOST_ID }, + { qitemId: "b-1", hostId: "vps-b" }, + { qitemId: "b-2", hostId: "vps-b" }, + { qitemId: "c-1", hostId: "vps-c" }, + ]); + expect(hostsCovered(res, [LOCAL_HOST_ID, "vps-b", "vps-c"])).toBe(true); // arch pin B — at the contract + }); + + it("one unreachable host degrades to a structured status; the other hosts' items still return (never all-or-nothing)", async () => { + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [ + { hostId: "vps-b", enabled: true }, + { hostId: "vps-c", enabled: true }, + ], + fetchImpl: (async (url: string | URL | Request) => { + if (String(url).includes("vps-b")) throw new Error("ECONNREFUSED"); + return attentionResponse([{ qitemId: "c-1" }]); + }) as typeof fetch, + }), + ); + expect(res.hosts).toEqual([ + { hostId: LOCAL_HOST_ID, status: "ok" }, + { hostId: "vps-b", status: "unreachable", error: "ECONNREFUSED", failedStep: "remote-daemon-unreachable" }, + { hostId: "vps-c", status: "ok" }, + ]); + expect(res.items.map((i) => i["qitemId"])).toEqual(["local-1", "c-1"]); + }); + + it("SSH-declared host → unsupported-transport (R15-2); unknown host id and registry failure → per-host unreachable with the reader's error", async () => { + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [ + { hostId: "ssh-1", enabled: true }, + { hostId: "ghost", enabled: true }, + ], + fetchImpl: (async () => attentionResponse([])) as typeof fetch, + }), + ); + expect(res.hosts[1]).toMatchObject({ hostId: "ssh-1", status: "unsupported-transport" }); + expect(res.hosts[1]!.error).toContain("http-transport"); + expect(res.hosts[2]!.status).toBe("unreachable"); + expect(res.hosts[2]!.error).toContain("unknown host id 'ghost'"); + expect(hostsCovered(res, [LOCAL_HOST_ID, "ssh-1", "ghost"])).toBe(true); + }); + + it("auth failures classify auth-failed (bearer missing AND remote 401), with the FailedStep detail riding additively", async () => { + const res = await aggregateAttention( + deps({ + env: { C: "tc" }, // B missing → bearer failure for vps-b + listSubscriptions: () => [ + { hostId: "vps-b", enabled: true }, + { hostId: "vps-c", enabled: true }, + ], + fetchImpl: (async () => new Response("{}", { status: 401 })) as typeof fetch, // vps-c reaches the wire → 401 + }), + ); + expect(res.hosts[1]).toMatchObject({ hostId: "vps-b", status: "auth-failed", failedStep: "permission-gate" }); + expect(res.hosts[1]!.error).toContain("bearer env var B"); + expect(res.hosts[2]).toMatchObject({ hostId: "vps-c", status: "auth-failed", failedStep: "permission-gate" }); + }); + + it("a stalled remote read times out within the READ deadline class and reports unreachable (the walk never hangs)", async () => { + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [{ hostId: "vps-b", enabled: true }], + timeoutMs: 20, + fetchImpl: ((_u: string | URL | Request, init?: RequestInit) => + new Promise((_r, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + })) as typeof fetch, + }), + ); + expect(res.hosts[1]).toMatchObject({ hostId: "vps-b", status: "unreachable", failedStep: "remote-daemon-unreachable" }); + expect(res.hosts[1]!.error).toContain("timed out"); + }); + + it("the read deadline class is the 5s poll bound, not the up-leaf budget", () => { + expect(ATTENTION_READ_TIMEOUT_MS).toBe(5_000); + }); + + it("fan-out cap: at most `concurrency` remote reads in flight; subscription order preserved in the payload", async () => { + let inFlight = 0; + let maxInFlight = 0; + const res = await aggregateAttention( + deps({ + listSubscriptions: () => [ + { hostId: "vps-b", enabled: true }, + { hostId: "vps-c", enabled: true }, + ], + concurrency: 1, + fetchImpl: (async (url: string | URL | Request) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + return attentionResponse([{ qitemId: String(url).includes("vps-b") ? "b-1" : "c-1" }]); + }) as typeof fetch, + }), + ); + expect(maxInFlight).toBe(1); + expect(res.hosts.map((h) => h.hostId)).toEqual([LOCAL_HOST_ID, "vps-b", "vps-c"]); + expect(res.items.map((i) => i["qitemId"])).toEqual(["local-1", "b-1", "c-1"]); + }); +}); diff --git a/packages/daemon/test/build-info.test.ts b/packages/daemon/test/build-info.test.ts new file mode 100644 index 000000000..f980183d9 --- /dev/null +++ b/packages/daemon/test/build-info.test.ts @@ -0,0 +1,26 @@ +// OPR.0.4.4.11 FR-6/7 — deploy-identity, daemon side. +// +// The COMMITTED module is the honest dev stub: no stamp → stampFields adds +// NOTHING to /healthz (no invented identity; legacy bodies preserved). The +// stamped shape (written by build-package.sh at package time) carries the +// exact four fields. The real end-to-end stamp is a VM-gate proof +// (build:package → /healthz shows the SHA). + +import { describe, it, expect } from "vitest"; +import { BUILD_INFO, stampFields } from "../src/build-info.js"; + +describe("build-info (daemon)", () => { + it("the committed module IS the honest dev stub: all identity fields null", () => { + expect(BUILD_INFO).toEqual({ semver: null, commit: null, dirty: null, builtAt: null }); + }); + + it("dev stub contributes NOTHING to /healthz (negative AC: no fake identity, exact legacy bodies preserved)", () => { + expect(stampFields(BUILD_INFO)).toEqual({}); + expect({ status: "ok", ...stampFields(BUILD_INFO) }).toEqual({ status: "ok" }); + }); + + it("a stamped build contributes exactly {semver, commit, dirty, builtAt}", () => { + const stamped = { semver: "0.4.3", commit: "a".repeat(40), dirty: false, builtAt: "2026-07-04T23:00:00Z" }; + expect(stampFields(stamped)).toEqual(stamped); + }); +}); diff --git a/packages/daemon/test/bundle-routes.test.ts b/packages/daemon/test/bundle-routes.test.ts index 458e42838..44d43fc48 100644 --- a/packages/daemon/test/bundle-routes.test.ts +++ b/packages/daemon/test/bundle-routes.test.ts @@ -18,13 +18,14 @@ import { journalSeqSchema } from "../src/db/migrations/010_journal_seq.js"; import { bootstrapSchema } from "../src/db/migrations/011_bootstrap.js"; import { discoverySchema } from "../src/db/migrations/012_discovery.js"; import { discoveryFkFix } from "../src/db/migrations/013_discovery_fk_fix.js"; +import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js"; import { createTestApp } from "./helpers/test-app.js"; const ALL_MIGRATIONS = [ coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, - discoverySchema, discoveryFkFix, + discoverySchema, discoveryFkFix, rigArchiveSchema, ]; const VALID_SPEC = ` diff --git a/packages/daemon/test/feed-host-subscriptions-config.test.ts b/packages/daemon/test/feed-host-subscriptions-config.test.ts new file mode 100644 index 000000000..7d447c6e5 --- /dev/null +++ b/packages/daemon/test/feed-host-subscriptions-config.test.ts @@ -0,0 +1,116 @@ +// OPR.0.4.4.15 (guard G15-P1 fold) — the ONE registered dynamic key class: +// feed.subscriptions..enabled. These tests pin the fold's whole +// contract: round-trip persistence, delimiter + reserved-segment guards +// (warn-and-ignore on the read side, reject-loud on the write side), +// unknown NON-matching keys keeping the existing 400/throw behavior +// byte-for-byte, and CLI/daemon twin parity (the host-registry twin +// discipline: shared fixtures through BOTH implementations). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SettingsStore, + parseFeedHostSubscriptionKey as daemonParse, +} from "../src/domain/user-settings/settings-store.js"; +import { parseFeedHostSubscriptionKey as cliParse } from "../../cli/src/config-store.js"; + +let dir: string; +let configPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "feed-host-cfg-")); + configPath = join(dir, "config.json"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function store(): SettingsStore { + return new SettingsStore(configPath); +} + +describe("dynamic feed-host subscription keys — daemon store", () => { + it("set/resolve/list round-trip; reset removes the whole host node", () => { + const s = store(); + s.set("feed.subscriptions.vps-b.enabled", "true"); + s.set("feed.subscriptions.mac_mini2.enabled", "false"); + expect(s.resolveFeedHostSubscription("feed.subscriptions.vps-b.enabled")).toEqual({ value: true, source: "file", defaultValue: false }); + expect(s.resolveFeedHostSubscription("feed.subscriptions.never-set.enabled")).toEqual({ value: false, source: "default", defaultValue: false }); + expect(s.listFeedHostSubscriptions()).toEqual([ + { hostId: "vps-b", enabled: true }, + { hostId: "mac_mini2", enabled: false }, + ]); + s.reset("feed.subscriptions.vps-b.enabled"); + expect(s.listFeedHostSubscriptions()).toEqual([{ hostId: "mac_mini2", enabled: false }]); + // The file layout nests under feed.subscriptions..enabled. + const fc = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + expect(fc).toEqual({ feed: { subscriptions: { mac_mini2: { enabled: false } } } }); + }); + + it("write side rejects loudly: non-boolean values, reserved segments, dotted host ids, and every non-matching unknown key (existing behavior byte-preserved)", () => { + const s = store(); + expect(() => s.set("feed.subscriptions.vps-b.enabled", "yes")).toThrow(/expected "true" or "false"/); + // Reserved segments never parse as host ids → the flat-key/unknown path owns them. + expect(() => s.set("feed.subscriptions.approvals.enabled", "true")).toThrow(/Unknown config key/); + expect(() => s.set("feed.subscriptions.auditLog.enabled", "true")).toThrow(/Unknown config key/); + // Dotted host ids are inexpressible in dotted keys (delimiter case). + expect(() => s.set("feed.subscriptions.vps.b.enabled", "true")).toThrow(/Unknown config key/); + // Per-host key set is CLOSED to {enabled} in v1. + expect(() => s.set("feed.subscriptions.vps-b.altitude", "high")).toThrow(/Unknown config key/); + // The pre-existing unknown-key negative, untouched. + expect(() => s.set("totally.unknown.key", "x")).toThrow(/Unknown config key/); + }); + + it("read side warn-and-ignores malformed persisted nodes without dropping valid ones (the ratified guard)", () => { + writeFileSync( + configPath, + JSON.stringify({ + feed: { + subscriptions: { + approvals: true, // flat toggle leaf — silently fine, not a host + "vps-b": { enabled: true }, + auditLog: { enabled: true }, // reserved OBJECT node — warned + ignored + "bad seg!": { enabled: true }, // invalid segment — warned + ignored + "half-baked": { enabled: "yes" }, // non-boolean — warned + ignored + }, + }, + }), + ); + const warnings: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => { + warnings.push(String(chunk)); + return true; + }) as never); + expect(store().listFeedHostSubscriptions()).toEqual([{ hostId: "vps-b", enabled: true }]); + expect(warnings.join("")).toContain("auditLog"); + expect(warnings.join("")).toContain("bad seg!"); + expect(warnings.join("")).toContain("half-baked"); + }); +}); + +describe("dynamic feed-host subscription keys — CLI/daemon twin parity", () => { + const PARSE_FIXTURES: Array<{ key: string; hostId: string | null }> = [ + { key: "feed.subscriptions.vps-b.enabled", hostId: "vps-b" }, + { key: "feed.subscriptions.mac_mini2.enabled", hostId: "mac_mini2" }, + { key: "feed.subscriptions.approvals.enabled", hostId: null }, // reserved (key-level) + { key: "feed.subscriptions.auditLog.enabled", hostId: null }, // reserved (file-level) + { key: "feed.subscriptions.enabled.enabled", hostId: null }, // reserved + { key: "feed.subscriptions.vps.b.enabled", hostId: null }, // delimiter + { key: "feed.subscriptions.vps-b.altitude", hostId: null }, // closed per-host set + { key: "feed.subscriptions..enabled", hostId: null }, // empty segment + { key: "workspace.root", hostId: null }, // unrelated key + ]; + + it("both parsers agree fixture-for-fixture (a divergence = one twin drifted)", () => { + for (const f of PARSE_FIXTURES) { + const d = daemonParse(f.key); + const c = cliParse(f.key); + expect(d?.hostId ?? null).toBe(f.hostId); + expect(c?.hostId ?? null).toBe(f.hostId); + } + }); +}); diff --git a/packages/daemon/test/files-asset-range.test.ts b/packages/daemon/test/files-asset-range.test.ts new file mode 100644 index 000000000..02d5cc976 --- /dev/null +++ b/packages/daemon/test/files-asset-range.test.ts @@ -0,0 +1,99 @@ +// OPR.0.4.4.20 FR-5 + FR-11 — /api/files/asset Range support + .html ?render=1. +// +// FR-5: iOS Safari requires byte-range support (206 + Accept-Ranges) for +// media playback; the curl-probe AC is "a 100-byte range request returns +// 100 bytes, not the whole file". Range lands on THIS route only. +// FR-11: .html serves text/html ONLY under the explicit ?render=1 opt-in. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { filesRoutes } from "../src/routes/files.js"; + +describe("GET /api/files/asset — Range + render opt-in", () => { + let root: string; + let app: Hono; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "asset-range-")); + mkdirSync(join(root, "media"), { recursive: true }); + // 1000 deterministic bytes standing in for a video file. + writeFileSync(join(root, "media", "clip.mp4"), Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251))); + writeFileSync(join(root, "media", "mock.html"), "

mock

"); + const allowlist = [{ name: "ws", canonicalPath: realpathSync(root) }]; + app = new Hono(); + app.use("*", async (c, next) => { + c.set("filesAllowlist" as never, allowlist); + c.set("fileWriteService" as never, null); + await next(); + }); + app.route("/api/files", filesRoutes()); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const url = (p: string, extra = "") => `/api/files/asset?root=ws&path=${encodeURIComponent(p)}${extra}`; + + it("serves 206 + Accept-Ranges + exactly the requested 100 bytes (the curl-probe AC)", async () => { + const res = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=0-99" } }); + expect(res.status).toBe(206); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + expect(res.headers.get("Content-Range")).toBe("bytes 0-99/1000"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body.length).toBe(100); // 100 bytes, not the whole file + expect(body[0]).toBe(0); + expect(body[99]).toBe(99); + }); + + it("serves interior and open-ended ranges with the correct byte slice", async () => { + const mid = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=500-509" } }); + expect(mid.headers.get("Content-Range")).toBe("bytes 500-509/1000"); + const midBody = new Uint8Array(await mid.arrayBuffer()); + expect(midBody[0]).toBe(500 % 251); + + const tail = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=990-" } }); + expect(tail.status).toBe(206); + expect(tail.headers.get("Content-Range")).toBe("bytes 990-999/1000"); + expect((await tail.arrayBuffer()).byteLength).toBe(10); + + const suffix = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=-50" } }); + expect(suffix.status).toBe(206); + expect(suffix.headers.get("Content-Range")).toBe("bytes 950-999/1000"); + }); + + it("clamps an over-long end and rejects unsatisfiable ranges with 416", async () => { + const clamped = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=900-5000" } }); + expect(clamped.status).toBe(206); + expect(clamped.headers.get("Content-Range")).toBe("bytes 900-999/1000"); + + const past = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=1000-" } }); + expect(past.status).toBe(416); + expect(past.headers.get("Content-Range")).toBe("bytes */1000"); + + const garbage = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=zz" } }); + expect(garbage.status).toBe(416); + }); + + it("no-Range requests keep the 200 whole-file shape and now advertise Accept-Ranges", async () => { + const res = await app.request(url("media/clip.mp4")); + expect(res.status).toBe(200); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + expect((await res.arrayBuffer()).byteLength).toBe(1000); + expect(res.headers.get("Content-Type")).toBe("video/mp4"); + }); + + it(".html stays text/plain by default and renders text/html ONLY under ?render=1", async () => { + const plain = await app.request(url("media/mock.html")); + expect(plain.headers.get("Content-Type")).toContain("text/plain"); + + const rendered = await app.request(url("media/mock.html", "&render=1")); + expect(rendered.headers.get("Content-Type")).toContain("text/html"); + expect(await rendered.text()).toBe("

mock

"); + + // The opt-in is .html-scoped: render=1 on a non-html asset changes nothing. + const video = await app.request(url("media/clip.mp4", "&render=1")); + expect(video.headers.get("Content-Type")).toBe("video/mp4"); + }); +}); diff --git a/packages/daemon/test/getting-started-narrative-parity.test.ts b/packages/daemon/test/getting-started-narrative-parity.test.ts index d4439f79f..dffa8b56f 100644 --- a/packages/daemon/test/getting-started-narrative-parity.test.ts +++ b/packages/daemon/test/getting-started-narrative-parity.test.ts @@ -13,7 +13,13 @@ import { describe, it, expect } from "vitest"; import { GETTING_STARTED_NARRATIVE } from "../src/domain/workspace/getting-started-narrative.js"; import { workspaceScaffoldFiles as cliScaffold } from "../../cli/src/commands/config-init-workspace.js"; -import { workspaceScaffoldFiles as daemonScaffold } from "../src/domain/workspace/default-workspace-scaffold.js"; +import { + workspaceScaffoldDirs as daemonScaffoldDirs, + workspaceScaffoldFiles as daemonScaffold, +} from "../src/domain/workspace/default-workspace-scaffold.js"; +import { + workspaceScaffoldDirs as cliScaffoldDirs, +} from "../../cli/src/commands/config-init-workspace.js"; function findContent( files: Array<{ relPath: string; content: string }>, @@ -58,6 +64,23 @@ describe("getting-started narrative parity — slice 21", () => { expect(cliContent).toBe(daemonContent); } }); + + it("CLI and daemon emit byte-identical root PROOF.md plus sibling proof/ dir", () => { + const relProof = `missions/getting-started/slices/${sliceId}/PROOF.md`; + const relProofDir = `missions/getting-started/slices/${sliceId}/proof`; + const cliFiles = cliScaffold(); + const daemonFiles = daemonScaffold(); + const cliDirs = cliScaffoldDirs(); + const daemonDirs = daemonScaffoldDirs(); + + const cliProof = findContent(cliFiles, relProof); + const daemonProof = findContent(daemonFiles, relProof); + expect(cliProof, `CLI scaffold missing ${relProof}`).toBeDefined(); + expect(daemonProof, `daemon scaffold missing ${relProof}`).toBeDefined(); + expect(daemonProof).toBe(cliProof); + expect(cliDirs).toContain(relProofDir); + expect(daemonDirs).toContain(relProofDir); + }); }); } diff --git a/packages/daemon/test/hosts-registry-parity.test.ts b/packages/daemon/test/hosts-registry-parity.test.ts new file mode 100644 index 000000000..84b1d2d3a --- /dev/null +++ b/packages/daemon/test/hosts-registry-parity.test.ts @@ -0,0 +1,99 @@ +// OPR.0.4.4.11 — hosts-registry reader (the shared P3/P4 interface cell). +// +// PARITY DISCIPLINE (the scope-audit twin pattern, and the exact drift class +// the slice-20 MISSION_BRIEF_HEADERS miss demonstrated): the daemon reader +// deliberately mirrors packages/cli/src/host-registry.ts (arch ruling 3 — +// CLI copy untouched, no unification this slice). These shared fixtures run +// through BOTH validators and must agree verdict-for-verdict; a divergence +// here means one twin drifted. + +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadHostRegistry, + validateHostRegistry as daemonValidate, + resolveHost, + resolvePlacementHost, +} from "../src/domain/hosts/hosts-registry-reader.js"; +import { validateHostRegistry as cliValidate } from "../../cli/src/host-registry.js"; + +const SRC = "/fixture/hosts.yaml"; + +const SHARED_FIXTURES: Array<{ label: string; parsed: unknown; ok: boolean }> = [ + { + label: "valid: one ssh + one http (bearer_env)", + parsed: { hosts: [ + { id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin" }, + { id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "VPS_B_TOKEN" }, + ] }, + ok: true, + }, + { label: "valid: http with bearer_file", parsed: { hosts: [{ id: "h", transport: "http", url: "http://h:7433", bearer_file: "/tmp/tok" }] }, ok: true }, + { label: "valid: empty hosts array", parsed: { hosts: [] }, ok: true }, + { label: "invalid: not an object", parsed: "nope", ok: false }, + { label: "invalid: hosts not an array", parsed: { hosts: {} }, ok: false }, + { label: "invalid: duplicate id", parsed: { hosts: [{ id: "x", transport: "ssh", target: "a" }, { id: "x", transport: "ssh", target: "b" }] }, ok: false }, + { label: "invalid: unknown transport", parsed: { hosts: [{ id: "x", transport: "carrier-pigeon", target: "a" }] }, ok: false }, + { label: "invalid: ssh missing target", parsed: { hosts: [{ id: "x", transport: "ssh" }] }, ok: false }, + { label: "invalid: http missing url", parsed: { hosts: [{ id: "x", transport: "http", bearer_env: "T" }] }, ok: false }, + { label: "invalid: http with NO bearer (exactly-one rule)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x" }] }, ok: false }, + { label: "invalid: http with BOTH bearers (exactly-one rule)", parsed: { hosts: [{ id: "x", transport: "http", url: "http://x", bearer_env: "T", bearer_file: "/f" }] }, ok: false }, + { label: "invalid: empty id", parsed: { hosts: [{ id: " ", transport: "ssh", target: "a" }] }, ok: false }, +]; + +describe("hosts-registry reader — CLI/daemon validator parity (shared fixtures)", () => { + for (const f of SHARED_FIXTURES) { + it(`agrees with the CLI validator: ${f.label}`, () => { + const d = daemonValidate(f.parsed, SRC); + const c = cliValidate(f.parsed, SRC); + expect(d.ok).toBe(f.ok); + expect(c.ok).toBe(f.ok); // both twins, same verdict + if (d.ok && c.ok) { + expect(d.registry).toEqual(c.registry); // and the same normalized shape + } + }); + } +}); + +describe("hosts-registry reader — daemon behaviors", () => { + it("missing file returns the canonical what/why/fix error, never a throw", () => { + const res = loadHostRegistry("/nonexistent/hosts.yaml"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toContain("host registry not found at /nonexistent/hosts.yaml"); + }); + + it("loads + validates a real file from disk", () => { + const dir = mkdtempSync(join(tmpdir(), "hosts-reg-")); + const p = join(dir, "hosts.yaml"); + writeFileSync(p, "hosts:\n - id: vps-1\n transport: http\n url: http://vps-1:7433\n bearer_env: VPS1_TOKEN\n"); + const res = loadHostRegistry(p); + expect(res.ok).toBe(true); + if (res.ok) expect(res.registry.hosts[0]).toMatchObject({ id: "vps-1", transport: "http" }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("unknown host id names the id and lists known ids (the FR-4 per-entry message)", () => { + const reg = { hosts: [{ id: "a", transport: "ssh" as const, target: "a.local" }] }; + const res = resolveHost(reg, "nope"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toContain("unknown host id 'nope'"); + expect(res.error).toContain("Known host ids: a"); + } + }); + + it("placement resolution rejects ssh-transport hosts with the remote-up fix message (mirrors runRemoteHttpOp's shipped rejection, surfaced at validation time)", () => { + const reg = { + hosts: [ + { id: "ssh-host", transport: "ssh" as const, target: "x.local" }, + { id: "http-host", transport: "http" as const, url: "http://y:7433", bearer_env: "T" }, + ], + }; + const sshRes = resolvePlacementHost(reg, "ssh-host"); + expect(sshRes.ok).toBe(false); + if (!sshRes.ok) expect(sshRes.error).toContain("cannot carry remote rig-up"); + expect(resolvePlacementHost(reg, "http-host").ok).toBe(true); + }); +}); diff --git a/packages/daemon/test/human-route-enforcer.test.ts b/packages/daemon/test/human-route-enforcer.test.ts new file mode 100644 index 000000000..6a3a25e77 --- /dev/null +++ b/packages/daemon/test/human-route-enforcer.test.ts @@ -0,0 +1,323 @@ +// OPR.0.4.4.19 FR-4/FR-5 — human-route enforcement (conventions C6 + C3). +// +// The §5 scoping predicate is the COMPLETE trigger list (BR-1): tier +// human-gate OR human-seat destination (the park leg is validated at the +// blocked-transition path, FR-6). Everything off the predicate is untouched. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type Database from "better-sqlite3"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { coreSchema } from "../src/db/migrations/001_core_schema.js"; +import { eventsSchema } from "../src/db/migrations/003_events.js"; +import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; +import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; +import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js"; +import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js"; +import { + isHumanSeatSession, + validateHumanRoute, +} from "../src/domain/human-route-enforcer.js"; + +describe("human-route-enforcer (pure validator)", () => { + it("§5 predicate leg 1: tier=human-gate is human-routed", () => { + const r = validateHumanRoute({ tier: "human-gate", destinationSession: "b@rig", summary: "s", evidenceRef: "e" }); + expect(r.ok).toBe(true); + expect(r.ok && r.humanRouted).toBe(true); + }); + + it("§5 predicate leg 2: human-seat destination is human-routed (strict regex)", () => { + expect(isHumanSeatSession("human@kernel")).toBe(true); + expect(isHumanSeatSession("human@host")).toBe(true); + expect(isHumanSeatSession("human-review@kernel")).toBe(true); + // malformed / near-miss sessions are NOT human seats (no LIKE superset): + expect(isHumanSeatSession("human-@kernel")).toBe(false); + expect(isHumanSeatSession("human-ish@other-rig")).toBe(false); + expect(isHumanSeatSession("superhuman@kernel")).toBe(false); + expect(isHumanSeatSession(null)).toBe(false); + }); + + it("human-routed with both fields missing → error naming BOTH fields + why", () => { + const r = validateHumanRoute({ tier: "human-gate", destinationSession: "b@rig", summary: null, evidenceRef: null }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.code).toBe("human_route_fields_required"); + expect(r.missingFields).toEqual(["summary", "evidence_ref"]); + expect(r.message).toContain("plain language"); + expect(r.message).toContain("judge"); + } + }); + + it("whitespace-only values count as missing", () => { + const r = validateHumanRoute({ tier: null, destinationSession: "human@kernel", summary: " ", evidenceRef: "\t" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.missingFields).toEqual(["summary", "evidence_ref"]); + }); + + it("BR-1: non-human-routed request validates NOTHING (ok regardless of fields)", () => { + const r = validateHumanRoute({ tier: "critical", destinationSession: "guard@rig", summary: null, evidenceRef: null }); + expect(r).toEqual({ ok: true, humanRouted: false }); + }); +}); + +describe("QueueRepository human-route enforcement wiring (FR-4/FR-5)", () => { + let db: Database.Database; + let repo: QueueRepository; + + beforeEach(() => { + db = createDb(); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]); + repo = new QueueRepository(db, new EventBus(db)); + }); + + afterEach(() => db.close()); + + const ordinary = { sourceSession: "a@rig", destinationSession: "b@rig", body: "work", nudge: false as const }; + + it("create to a human seat without summary/evidence_ref is REJECTED with the structured error", async () => { + await expect( + repo.create({ sourceSession: "pm@rig", destinationSession: "human-review@kernel", body: "judge", nudge: false }) + ).rejects.toMatchObject({ code: "human_route_fields_required" }); + }); + + it("create with tier=human-gate without the fields is REJECTED", async () => { + await expect( + repo.create({ ...ordinary, tier: "human-gate" }) + ).rejects.toThrow(QueueRepositoryError); + }); + + it("human-routed create WITH both fields is accepted and persists them", async () => { + const item = await repo.create({ + sourceSession: "pm@rig", + destinationSession: "human-review@kernel", + body: "judge", + summary: "Approve the 0.4.4 cut", + evidenceRef: "missions/x/PROOF.md", + nudge: false, + }); + expect(item.summary).toBe("Approve the 0.4.4 cut"); + expect(item.evidenceRef).toBe("missions/x/PROOF.md"); + }); + + it("handoff to a human seat requires the NEW item's own fields (never inherited)", async () => { + const src = await repo.create({ + ...ordinary, + summary: "source summary", + evidenceRef: "proof/source.md", + }); + // Source HAS both fields; the handoff omits them → still rejected. + await expect( + repo.handoff({ qitemId: src.qitemId, fromSession: "b@rig", toSession: "human-review@kernel", nudge: false }) + ).rejects.toMatchObject({ code: "human_route_fields_required" }); + // Providing them on the handoff itself succeeds. + const result = await repo.handoff({ + qitemId: src.qitemId, + fromSession: "b@rig", + toSession: "human-review@kernel", + summary: "Please ratify", + evidenceRef: "proof/final.md", + nudge: false, + }); + expect(result.created.summary).toBe("Please ratify"); + expect(result.created.evidenceRef).toBe("proof/final.md"); + }); + + it("handoff inheriting human-gate TIER from the source also triggers enforcement", async () => { + const src = await repo.create({ + ...ordinary, + tier: "human-gate", + summary: "s", + evidenceRef: "e.md", + }); + // No tier override on the handoff → new item inherits human-gate → enforcement. + await expect( + repo.handoff({ qitemId: src.qitemId, fromSession: "b@rig", toSession: "c@rig", nudge: false }) + ).rejects.toMatchObject({ code: "human_route_fields_required" }); + }); + + it("handoffAndComplete to a human seat is enforced identically", async () => { + const src = await repo.create(ordinary); + await expect( + repo.handoffAndComplete({ qitemId: src.qitemId, fromSession: "b@rig", toSession: "human@kernel", nudge: false }) + ).rejects.toMatchObject({ code: "human_route_fields_required" }); + }); + + // ——— BR-1 zero-friction negatives (the guard's verification targets) ——— + + it("NEGATIVE: ordinary create without summary/evidence_ref is accepted exactly as before", async () => { + const item = await repo.create(ordinary); + expect(item.state).toBe("pending"); + expect(item.summary).toBeNull(); + expect(item.evidenceRef).toBeNull(); + }); + + it("NEGATIVE: ordinary handoff without the fields is accepted; no new rejection path", async () => { + const src = await repo.create(ordinary); + const result = await repo.handoff({ qitemId: src.qitemId, fromSession: "b@rig", toSession: "c@rig", nudge: false }); + expect(result.created.state).toBe("pending"); + expect(result.created.summary).toBeNull(); + expect(result.created.evidenceRef).toBeNull(); + }); + + it("NEGATIVE: ordinary update/close paths gain no new requirement (closure contract unchanged)", async () => { + const item = await repo.create(ordinary); + repo.claim({ qitemId: item.qitemId, destinationSession: "b@rig" }); + const done = repo.update({ + qitemId: item.qitemId, + actorSession: "b@rig", + state: "done", + closureReason: "no-follow-on", + }); + expect(done.state).toBe("done"); + }); + + it("NEGATIVE: near-miss destinations (regex non-matches) are NOT enforced", async () => { + const item = await repo.create({ ...ordinary, destinationSession: "human-ish@other-rig" }); + expect(item.state).toBe("pending"); + const item2 = await repo.create({ ...ordinary, destinationSession: "human-@kernel" }); + expect(item2.state).toBe("pending"); + }); +}); + +describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention", () => { + let db: Database.Database; + let repo: QueueRepository; + + beforeEach(() => { + db = createDb(); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]); + repo = new QueueRepository(db, new EventBus(db)); + }); + + afterEach(() => db.close()); + + async function inProgressItem() { + const item = await repo.create({ + sourceSession: "orch@rig", + destinationSession: "driver@rig", + body: "build the thing", + nudge: false, + }); + repo.claim({ qitemId: item.qitemId, destinationSession: "driver@rig" }); + return item; + } + + it("park on a human seat WITH park-time summary + evidence_ref succeeds, keeps ownership, requires no closure_reason, and PERSISTS both fields", async () => { + const item = await inProgressItem(); + const parked = repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: "human-review@kernel", + summary: "Which hook-trust timing rule should ship?", + evidenceRef: "missions/x/slices/y/OPTIONS.md", + }); + expect(parked.state).toBe("blocked"); + expect(parked.destinationSession).toBe("driver@rig"); // owner keeps the potato + expect(parked.closureReason).toBeNull(); // non-terminal + const read = repo.getById(item.qitemId)!; + expect(read.summary).toBe("Which hook-trust timing rule should ship?"); + expect(read.evidenceRef).toBe("missions/x/slices/y/OPTIONS.md"); + }); + + it("park on a human seat WITHOUT summary/evidence_ref is rejected naming both fields", async () => { + const item = await inProgressItem(); + expect(() => + repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: "human-review@kernel", + }) + ).toThrow(/summary \+ evidence_ref|summary/); + try { + repo.update({ qitemId: item.qitemId, actorSession: "driver@rig", state: "blocked", blockedOn: "human-review@kernel" }); + } catch (err) { + expect((err as QueueRepositoryError).code).toBe("human_route_fields_required"); + expect((err as QueueRepositoryError).meta?.missingFields).toEqual(["summary", "evidence_ref"]); + } + }); + + it("an item already carrying both fields parks without re-entry (effective-value rule)", async () => { + const item = await repo.create({ + sourceSession: "orch@rig", + destinationSession: "driver@rig", + body: "b", + summary: "carried from create", + evidenceRef: "proof/carried.md", + nudge: false, + }); + repo.claim({ qitemId: item.qitemId, destinationSession: "driver@rig" }); + const parked = repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: "human@kernel", + }); + expect(parked.state).toBe("blocked"); + }); + + it("parked-on-human items are returned by the attention query (3rd predicate leg)", async () => { + const item = await inProgressItem(); + repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: "human-review@kernel", + summary: "decision owed", + evidenceRef: "proof/x.md", + }); + const attention = repo.listAttention(); + expect(attention.map((q) => q.qitemId)).toContain(item.qitemId); + }); + + it("NEGATIVE: blocked on another QITEM requires nothing new and does NOT appear in attention", async () => { + const blocker = await repo.create({ sourceSession: "a@rig", destinationSession: "b@rig", body: "blocker", nudge: false }); + const item = await inProgressItem(); + const parked = repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: blocker.qitemId, + }); + expect(parked.state).toBe("blocked"); + const attention = repo.listAttention(); + expect(attention.map((q) => q.qitemId)).not.toContain(item.qitemId); + }); + + it("queue.updated event carries the park-time summary (FR-1 x FR-6, the P2 refresh contract)", async () => { + const bus = new EventBus(db); + const repo2 = new QueueRepository(db, bus); + const captured: Array> = []; + bus.subscribe((e) => captured.push(e as unknown as Record)); + const item = await repo2.create({ sourceSession: "a@rig", destinationSession: "d@rig", body: "b", nudge: false }); + repo2.update({ + qitemId: item.qitemId, + actorSession: "d@rig", + state: "blocked", + blockedOn: "human@kernel", + summary: "park-time summary", + evidenceRef: "proof/p.md", + }); + const ev = captured.find((e) => e.type === "queue.updated"); + expect(ev).toBeDefined(); + expect(ev!.summary).toBe("park-time summary"); + expect(ev!.toState).toBe("blocked"); + }); + + it("NEGATIVE: non-park updates never persist summary/evidence_ref inputs (surface stays tight)", async () => { + const item = await inProgressItem(); + repo.update({ + qitemId: item.qitemId, + actorSession: "driver@rig", + state: "in-progress", + summary: "should NOT persist", + evidenceRef: "should-not-persist.md", + }); + const read = repo.getById(item.qitemId)!; + expect(read.summary).toBeNull(); + expect(read.evidenceRef).toBeNull(); + }); +}); diff --git a/packages/daemon/test/mission-control-action-log.test.ts b/packages/daemon/test/mission-control-action-log.test.ts index 599a8d940..b03e480b0 100644 --- a/packages/daemon/test/mission-control-action-log.test.ts +++ b/packages/daemon/test/mission-control-action-log.test.ts @@ -27,7 +27,7 @@ describe("MissionControlActionLog (PL-005 Phase A; append-only)", () => { afterEach(() => db.close()); - it("record persists all 7 verbs", () => { + it("record persists all Mission Control verbs", () => { for (const verb of MISSION_CONTROL_VERBS) { const e = log.record({ actionVerb: verb, @@ -40,7 +40,7 @@ describe("MissionControlActionLog (PL-005 Phase A; append-only)", () => { expect(e.actionVerb).toBe(verb); expect(e.actionId).toMatch(/^[0-9A-Z]{26}$/); } - expect(log.countAll()).toBe(7); + expect(log.countAll()).toBe(MISSION_CONTROL_VERBS.length); }); it("record(annotate) requires annotation", () => { diff --git a/packages/daemon/test/mission-control-fleet-cli-capability.test.ts b/packages/daemon/test/mission-control-fleet-cli-capability.test.ts index 6a3f14d65..21cc7c3c0 100644 --- a/packages/daemon/test/mission-control-fleet-cli-capability.test.ts +++ b/packages/daemon/test/mission-control-fleet-cli-capability.test.ts @@ -5,6 +5,7 @@ import { migrate } from "../src/db/migrate.js"; import { coreSchema } from "../src/db/migrations/001_core_schema.js"; import { eventsSchema } from "../src/db/migrations/003_events.js"; import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; +import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js"; import { EventBus } from "../src/domain/event-bus.js"; import { RigRepository } from "../src/domain/rig-repository.js"; import { @@ -22,7 +23,7 @@ describe("MissionControlFleetCliCapability (PL-005 Phase A; 4 sub-clauses of gra beforeEach(() => { db = createDb(); - migrate(db, [coreSchema, eventsSchema, queueItemsSchema]); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, rigArchiveSchema]); db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig-alpha')`).run(); db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-2', 'rig-beta')`).run(); bus = new EventBus(db); diff --git a/packages/daemon/test/mission-control-read-layer.test.ts b/packages/daemon/test/mission-control-read-layer.test.ts index dee06822b..1bd046d52 100644 --- a/packages/daemon/test/mission-control-read-layer.test.ts +++ b/packages/daemon/test/mission-control-read-layer.test.ts @@ -8,6 +8,7 @@ import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js"; import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js"; +import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js"; import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { StreamStore } from "../src/domain/stream-store.js"; @@ -32,7 +33,7 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => { db = createDb(); migrate(db, [ coreSchema, eventsSchema, streamItemsSchema, - queueItemsSchema, queueTransitionsSchema, viewsCustomSchema, + queueItemsSchema, queueTransitionsSchema, viewsCustomSchema, rigArchiveSchema, ]); db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run(); bus = new EventBus(db); @@ -76,6 +77,8 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => { destinationSession: "human-operator@kernel", body: "needs human approval", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await queueRepo.create({ sourceSession: "src@rig", @@ -117,6 +120,8 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => { destinationSession: "human-operator@kernel", body: "for the operator", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await queueRepo.create({ sourceSession: "src@rig", @@ -126,6 +131,8 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => { await queueRepo.create({ sourceSession: "src@rig", destinationSession: "human-operator@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "non-human-gate from operator", }); const result = await readLayer.readView("my-queue"); @@ -139,6 +146,8 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => { destinationSession: "human-operator@kernel", body: "Approve the release candidate after checking the phone notification path.", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); const result = await readLayer.readView("my-queue"); diff --git a/packages/daemon/test/mission-control-remote-action.test.ts b/packages/daemon/test/mission-control-remote-action.test.ts new file mode 100644 index 000000000..ad6798703 --- /dev/null +++ b/packages/daemon/test/mission-control-remote-action.test.ts @@ -0,0 +1,148 @@ +// OPR.0.4.4.15 FR-4 — remote action forwarding on POST /api/mission-control/action. +// +// The load-bearing pins: hostId absent/local = the existing path +// byte-for-byte (write contract invoked); remote = server-side forward with +// the origin's structured response passed through VERBATIM (success AND +// failure — the fake-success negative); NOTHING written to the local write +// contract on the forwarded path (arch ruling 4 addition riding R15-3: +// origin's audit row is THE record); only the one verb allowlist gates. + +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { missionControlRoutes } from "../src/routes/mission-control.js"; +import type { HostRegistry } from "../src/domain/hosts/hosts-registry-reader.js"; + +const REGISTRY: HostRegistry = { + hosts: [ + { id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "B" }, + { id: "ssh-1", transport: "ssh", target: "x.local" }, + ], +}; + +function makeApp(opts: { fetchImpl?: typeof fetch } = {}) { + const localActs: unknown[] = []; + const writeContract = { + act: async (req: unknown) => { + localActs.push(req); + return { ok: true, actionId: "local-act-1" }; + }, + }; + const app = new Hono(); + app.use("*", async (c, next) => { + const set = c.set.bind(c) as (key: string, value: unknown) => void; + set("missionControlWriteContract", writeContract); + set("hostRegistryLoader", () => ({ ok: true, registry: REGISTRY })); + if (opts.fetchImpl) set("remoteFetchImpl", opts.fetchImpl); + set("eventBus", { emit: () => {} }); + await next(); + }); + app.route("/api/mission-control", missionControlRoutes({ bearerToken: null })); + return { app, localActs }; +} + +const BASE_BODY = { verb: "resolve", qitemId: "qitem-1", actorSession: "human@host" }; + +function post(app: Hono, body: Record) { + return app.request("/api/mission-control/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// Bearer env for the forward legs. +process.env["B"] = "remote-token"; + +describe("POST /action — FR-4 remote forwarding", () => { + it("hostId ABSENT: the existing local write path runs byte-for-byte", async () => { + const { app, localActs } = makeApp(); + const res = await post(app, BASE_BODY); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, actionId: "local-act-1" }); + expect(localActs).toHaveLength(1); + }); + + it("hostId 'local': same local path (the contract's literal is not a remote)", async () => { + const { app, localActs } = makeApp(); + const res = await post(app, { ...BASE_BODY, hostId: "local" }); + expect(res.status).toBe(200); + expect(localActs).toHaveLength(1); + }); + + it("remote hostId: forwards the SAME body (minus hostId) with the bearer; origin's SUCCESS response passes through verbatim; LOCAL write contract untouched", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const { app, localActs } = makeApp({ + fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { + capture.url = String(url); + capture.init = init; + return new Response(JSON.stringify({ ok: true, actionId: "origin-act-9", audited: "on-origin" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch, + }); + const res = await post(app, { ...BASE_BODY, hostId: "vps-b", annotation: "from the merged feed" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, actionId: "origin-act-9", audited: "on-origin" }); + expect(capture.url).toBe("http://vps-b:7433/api/mission-control/action"); + expect((capture.init?.headers as Record)["Authorization"]).toBe("Bearer remote-token"); + const forwarded = JSON.parse(String(capture.init?.body)) as Record; + expect(forwarded).toEqual({ ...BASE_BODY, annotation: "from the merged feed" }); // hostId stripped, rest verbatim + expect(localActs).toEqual([]); // arch pin: local mission_control_actions CLEAN after forward + }); + + it("origin REFUSAL passes through as structured failure — no fake success, local contract untouched", async () => { + const { app, localActs } = makeApp({ + fetchImpl: (async () => + new Response(JSON.stringify({ error: "qitem qitem-1 not found on this host" }), { status: 404, headers: { "Content-Type": "application/json" } })) as typeof fetch, + }); + const res = await post(app, { ...BASE_BODY, hostId: "vps-b" }); + expect(res.status).toBe(502); + const data = (await res.json()) as Record; + expect(data).toMatchObject({ error: "remote_action_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 404 }); + expect(String(data["detail"])).toContain("not found on this host"); + expect(localActs).toEqual([]); + }); + + it("origin unreachable at action time: structured per-host error (never optimistic), never hangs (deadline through body)", async () => { + const { app, localActs } = makeApp({ + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch, + }); + const res = await post(app, { ...BASE_BODY, hostId: "vps-b" }); + expect(res.status).toBe(502); + expect(await res.json()).toMatchObject({ error: "remote_action_failed", failureClass: "unreachable", detail: "ECONNREFUSED" }); + expect(localActs).toEqual([]); + }); + + it("SSH-declared and unknown hosts fail structurally BEFORE any wire attempt", async () => { + let wireTouched = false; + const { app } = makeApp({ + fetchImpl: (async () => { + wireTouched = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch, + }); + const ssh = await post(app, { ...BASE_BODY, hostId: "ssh-1" }); + expect(ssh.status).toBe(502); + expect(await ssh.json()).toMatchObject({ failureClass: "unsupported-transport" }); + const ghost = await post(app, { ...BASE_BODY, hostId: "ghost" }); + expect(await ghost.json()).toMatchObject({ failureClass: "unknown-host" }); + expect(wireTouched).toBe(false); + }); + + it("the verb allowlist gates BEFORE any forward (one allowlist, no duplicated validation)", async () => { + let wireTouched = false; + const { app } = makeApp({ + fetchImpl: (async () => { + wireTouched = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch, + }); + const res = await post(app, { verb: "reboot-host", qitemId: "q", actorSession: "a", hostId: "vps-b" }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: "verb_unknown" }); + expect(wireTouched).toBe(false); + }); +}); diff --git a/packages/daemon/test/mission-control-routes-phase-b.test.ts b/packages/daemon/test/mission-control-routes-phase-b.test.ts index 3052f30f2..34defe520 100644 --- a/packages/daemon/test/mission-control-routes-phase-b.test.ts +++ b/packages/daemon/test/mission-control-routes-phase-b.test.ts @@ -156,6 +156,44 @@ describe("mission-control routes Phase B (PL-005)", () => { }); }); + describe("POST /action client-error mapping", () => { + it("resolve without decision returns 400 decision_required", async () => { + const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false }); + const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" }); + const res = await app.request("/api/mission-control/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + verb: "resolve", + qitemId: created.qitemId, + actorSession: "human@kernel", + decision: " ", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("decision_required"); + }); + + it("resolve against a non-leg-1-parked qitem returns 409 qitem_not_leg1_parked", async () => { + const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false }); + const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" }); + const res = await app.request("/api/mission-control/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + verb: "resolve", + qitemId: created.qitemId, + actorSession: "human@kernel", + decision: "approved", + }), + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("qitem_not_leg1_parked"); + }); + }); + describe("GET /audit", () => { it("returns rows + pagination metadata", async () => { const { app, actionLog } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false }); @@ -253,6 +291,8 @@ describe("mission-control routes Phase B (PL-005)", () => { destinationSession: "human-operator@kernel", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); const res = await app.request("/api/mission-control/destinations"); diff --git a/packages/daemon/test/mission-control-routes.test.ts b/packages/daemon/test/mission-control-routes.test.ts index df2f407c1..f62cccbed 100644 --- a/packages/daemon/test/mission-control-routes.test.ts +++ b/packages/daemon/test/mission-control-routes.test.ts @@ -10,6 +10,7 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js"; import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js"; +import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js"; import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { StreamStore } from "../src/domain/stream-store.js"; @@ -54,7 +55,7 @@ describe("mission-control routes (PL-005 Phase A)", () => { migrate(db, [ coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, viewsCustomSchema, - missionControlActionsSchema, + missionControlActionsSchema, rigArchiveSchema, ]); db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run(); bus = new EventBus(db); diff --git a/packages/daemon/test/mission-control-write-contract.test.ts b/packages/daemon/test/mission-control-write-contract.test.ts index ecc716e91..c408df3f6 100644 --- a/packages/daemon/test/mission-control-write-contract.test.ts +++ b/packages/daemon/test/mission-control-write-contract.test.ts @@ -7,6 +7,8 @@ import { eventsSchema } from "../src/db/migrations/003_events.js"; import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js"; +import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js"; +import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js"; import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; @@ -24,7 +26,15 @@ describe("MissionControlWriteContract (PL-005 Phase A; atomic 7-verb)", () => { beforeEach(() => { db = createDb(); - migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema]); + migrate(db, [ + coreSchema, + eventsSchema, + queueItemsSchema, + queueTransitionsSchema, + missionControlActionsSchema, + queueItemSummarySchema, + queueItemEvidenceRefSchema, + ]); db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run(); bus = new EventBus(db); queueRepo = new QueueRepository(db, bus, { validateRig: () => true }); @@ -40,6 +50,8 @@ describe("MissionControlWriteContract (PL-005 Phase A; atomic 7-verb)", () => { destinationSession: "dst@rig", body: "test work", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); return created.qitemId; } @@ -237,3 +249,151 @@ describe("MissionControlWriteContract (PL-005 Phase A; atomic 7-verb)", () => { expect(queueCountAfter.n).toBe(queueCountBefore.n); }); }); + +// OPR.0.4.4.19 FR-7 — resolve + unpark (the packet's one genuine design cell). +describe("resolve verb (OPR.0.4.4.19 FR-7)", () => { + let db: Database.Database; + let bus: EventBus; + let queueRepo: QueueRepository; + let actionLog: MissionControlActionLog; + let writeContract: MissionControlWriteContract; + let sentNudges: Array<{ session: string; text: string }>; + let failTransport: boolean; + + beforeEach(() => { + db = createDb(); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema]); + db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run(); + bus = new EventBus(db); + sentNudges = []; + failTransport = false; + queueRepo = new QueueRepository(db, bus, { + validateRig: () => true, + transport: { + send: async (session: string, text: string) => { + if (failTransport) throw new Error("transport down"); + sentNudges.push({ session, text }); + return { ok: true, verified: true }; + }, + }, + }); + actionLog = new MissionControlActionLog(db); + writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog }); + }); + + afterEach(() => db.close()); + + async function parkedQitem(): Promise { + const created = await queueRepo.create({ + sourceSession: "orch@rig", + destinationSession: "driver@rig", + body: "build the thing", + nudge: false, + }); + queueRepo.claim({ qitemId: created.qitemId, destinationSession: "driver@rig" }); + queueRepo.update({ + qitemId: created.qitemId, + actorSession: "driver@rig", + state: "blocked", + blockedOn: "human-review@kernel", + summary: "Which timing rule ships?", + evidenceRef: "missions/x/OPTIONS.md", + }); + return created.qitemId; + } + + it("happy path: one transaction — blocked->in-progress, decision in transition_note + actor, audit row, events, owner nudge with decision text; NON-CLOSURE", async () => { + const events: Array<{ type: string }> = []; + bus.subscribe((e) => events.push(e)); + const qitemId = await parkedQitem(); + const decision = "Ship it with the 2-regime rule; timing per option B"; + + const result = await writeContract.act({ + verb: "resolve", + qitemId, + actorSession: "human-review@kernel", + decision, + }); + + const item = queueRepo.getById(qitemId)!; + // Rail 4: back to the parked owner; no ownership change, no closure. + expect(item.state).toBe("in-progress"); + expect(item.destinationSession).toBe("driver@rig"); + expect(item.closureReason).toBeNull(); + // Rail 2: decision text queryable forever in the transition log. + const transitions = queueRepo.transitionLog.listForQitem(qitemId); + const resolveTransition = transitions.find((t) => t.transitionNote === decision); + expect(resolveTransition).toBeDefined(); + expect(resolveTransition!.actorSession).toBe("human-review@kernel"); + // Audit row (append-only) records the resolve. + const audits = actionLog.listForQitem(qitemId); + expect(audits.some((a) => a.actionVerb === "resolve" && a.reason === decision)).toBe(true); + // F-pre/P2 refresh contract: queue.updated emitted for the unpark. + expect(events.some((e) => e.type === "queue.updated")).toBe(true); + expect(events.some((e) => e.type === "mission_control.action_executed")).toBe(true); + // Owner nudge carries the decision text. + expect(sentNudges).toHaveLength(1); + expect(sentNudges[0]!.session).toBe("driver@rig"); + expect(sentNudges[0]!.text).toContain(decision); + expect(result.createdQitemId).toBeNull(); // never a new potato + }); + + it("empty/whitespace decision text is rejected (enforcement symmetry)", async () => { + const qitemId = await parkedQitem(); + await expect( + writeContract.act({ verb: "resolve", qitemId, actorSession: "human@kernel", decision: " " }) + ).rejects.toMatchObject({ code: "decision_required" }); + await expect( + writeContract.act({ verb: "resolve", qitemId, actorSession: "human@kernel" }) + ).rejects.toMatchObject({ code: "decision_required" }); + }); + + it("resolve against a non-parked qitem is rejected naming the expected park shape", async () => { + const created = await queueRepo.create({ + sourceSession: "a@rig", destinationSession: "b@rig", body: "x", nudge: false, + }); + await expect( + writeContract.act({ verb: "resolve", qitemId: created.qitemId, actorSession: "human@kernel", decision: "d" }) + ).rejects.toMatchObject({ code: "qitem_not_leg1_parked" }); + }); + + it("resolve against a qitem blocked on ANOTHER QITEM is rejected (leg-1 shape only)", async () => { + const blocker = await queueRepo.create({ sourceSession: "a@rig", destinationSession: "b@rig", body: "blocker", nudge: false }); + const item = await queueRepo.create({ sourceSession: "a@rig", destinationSession: "b@rig", body: "x", nudge: false }); + queueRepo.update({ qitemId: item.qitemId, actorSession: "b@rig", state: "blocked", blockedOn: blocker.qitemId }); + await expect( + writeContract.act({ verb: "resolve", qitemId: item.qitemId, actorSession: "human@kernel", decision: "d" }) + ).rejects.toMatchObject({ code: "qitem_not_leg1_parked" }); + }); + + it("idempotence: re-resolving an already-resolved item is a no-op error, not a double transition", async () => { + const qitemId = await parkedQitem(); + await writeContract.act({ verb: "resolve", qitemId, actorSession: "human@kernel", decision: "first" }); + const transitionsAfterFirst = queueRepo.transitionLog.listForQitem(qitemId).length; + await expect( + writeContract.act({ verb: "resolve", qitemId, actorSession: "human@kernel", decision: "second" }) + ).rejects.toMatchObject({ code: "qitem_not_leg1_parked" }); + expect(queueRepo.transitionLog.listForQitem(qitemId)).toHaveLength(transitionsAfterFirst); + }); + + it("BR-8: the resolve commits even when the owner nudge transport fails", async () => { + const qitemId = await parkedQitem(); + failTransport = true; + const result = await writeContract.act({ + verb: "resolve", qitemId, actorSession: "human@kernel", decision: "commit anyway", + }); + expect(queueRepo.getById(qitemId)!.state).toBe("in-progress"); + // Nudge outcome recorded via the existing last_nudge_* mechanics. + expect(queueRepo.getById(qitemId)!.lastNudgeResult).toMatch(/^failed:/); + expect(result.notifyAttempted).toBe(true); + }); + + it("the write contract has NO closure mapping for resolve: closure_reason stays null after resolve", async () => { + const qitemId = await parkedQitem(); + await writeContract.act({ verb: "resolve", qitemId, actorSession: "human@kernel", decision: "d" }); + const item = queueRepo.getById(qitemId)!; + expect(item.state).toBe("in-progress"); + expect(item.closureReason).toBeNull(); + expect(item.closureTarget).toBeNull(); + }); +}); diff --git a/packages/daemon/test/mission-slice-sop-plugin-parity.test.ts b/packages/daemon/test/mission-slice-sop-plugin-parity.test.ts new file mode 100644 index 000000000..1e11c2cac --- /dev/null +++ b/packages/daemon/test/mission-slice-sop-plugin-parity.test.ts @@ -0,0 +1,40 @@ +// OPR.0.4.4.23 (arch S2) — the bundled openrig-core plugin's copy of +// mission-slice-sop must be MECHANICALLY pinned to the canonical product +// skill source: hand-sync without a guard is banned. This is the +// scope-audit-copies pattern (byte-parity CI test) applied to the skill — +// the mirror-skills script covers specs → skills/_canonical, and this test +// covers specs → the bundled plugin. Any edit to one copy fails here until +// the other copy is refreshed (cp, byte-for-byte). + +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", "..", ".."); + +const CANONICAL_SOURCE = path.join( + repoRoot, + "packages/daemon/specs/agents/shared/skills/core/mission-slice-sop/SKILL.md", +); +const PLUGIN_COPY = path.join( + repoRoot, + "packages/daemon/assets/plugins/openrig-core/skills/mission-slice-sop/SKILL.md", +); + +describe("OPR.0.4.4.23 mission-slice-sop plugin byte-parity (S2 drift guard)", () => { + it("both copies exist", () => { + expect(fs.existsSync(CANONICAL_SOURCE), `missing ${CANONICAL_SOURCE}`).toBe(true); + expect(fs.existsSync(PLUGIN_COPY), `missing ${PLUGIN_COPY}`).toBe(true); + }); + + it("the bundled plugin copy is byte-identical to the canonical skill source", () => { + const source = fs.readFileSync(CANONICAL_SOURCE); + const plugin = fs.readFileSync(PLUGIN_COPY); + expect( + source.equals(plugin), + "mission-slice-sop drifted between the canonical skill source and the bundled plugin — refresh the plugin copy byte-for-byte (cp source → plugin); hand-edited divergence is banned (OPR.0.4.4.23 S2)", + ).toBe(true); + }); +}); diff --git a/packages/daemon/test/notification-dispatcher.test.ts b/packages/daemon/test/notification-dispatcher.test.ts index 4362820b2..196e3e4ad 100644 --- a/packages/daemon/test/notification-dispatcher.test.ts +++ b/packages/daemon/test/notification-dispatcher.test.ts @@ -58,6 +58,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human-operator@kernel", body: "needs human approval", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); // Yield event loop so dispatcher's async handler completes. await new Promise((r) => setTimeout(r, 10)); @@ -81,6 +83,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human-operator@kernel", body: "needs human approval", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); @@ -105,6 +109,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human@rig", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); adapter.calls.length = 0; @@ -129,6 +135,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human@rig", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); adapter.calls.length = 0; @@ -149,6 +157,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human@rig", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); expect(events.find((e) => e.type === "mission_control.notification_sent")).toBeDefined(); @@ -163,6 +173,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human@rig", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); expect(events.find((e) => e.type === "mission_control.notification_failed")).toBeDefined(); @@ -176,6 +188,8 @@ describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => { destinationSession: "human@rig", body: "x", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }); await new Promise((r) => setTimeout(r, 10)); // Re-emit a synthetic queue.created event for same qitem (defensive dedup). diff --git a/packages/daemon/test/ps-projection.test.ts b/packages/daemon/test/ps-projection.test.ts index df19947f4..95a9cebab 100644 --- a/packages/daemon/test/ps-projection.test.ts +++ b/packages/daemon/test/ps-projection.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type Database from "better-sqlite3"; import { createFullTestDb } from "./helpers/test-app.js"; -import { PsProjectionService, deriveRigLifecycleState } from "../src/domain/ps-projection.js"; +import { PsProjectionService, deriveRigLifecycleState, seatNeedsAttention } from "../src/domain/ps-projection.js"; +import { AgentActivityStore } from "../src/domain/agent-activity-store.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import type { AgentActivity, NodeInventoryEntry } from "../src/domain/types.js"; describe("PsProjectionService", () => { let db: Database.Database; @@ -446,4 +449,110 @@ describe("PsProjectionService", () => { expect(entries[0]!.hasWorkCount).toBe(1); // only the pending one counts }); }); + + // OPR.0.4.4.21 — the rig-rollup attention predicate + fold (FR-1). + describe("OPR.0.4.4.21 — attentionCount (one predicate, one count per seat)", () => { + const baseEntry = (over: Partial = {}): NodeInventoryEntry => ({ + rigId: "r", rigName: "r", nodeId: "n", logicalId: "dev", + canonicalSessionName: "dev@r", + sessionStatus: "running", + startupStatus: "ready", + lifecycleState: "running", + latestError: null, + heldReason: null, + ...over, + } as NodeInventoryEntry); + + const idleActivity = (state: AgentActivity["state"]): AgentActivity => ({ + state, reason: "test", evidenceSource: "runtime_hook", + sampledAt: new Date().toISOString(), fallback: false, stale: false, + } as AgentActivity); + + it("counts each signal alone: lifecycle attention", () => { + expect(seatNeedsAttention(baseEntry({ lifecycleState: "attention_required" }), null)).toBe(true); + }); + it("counts startup attention_required DIRECTLY from startupStatus", () => { + expect(seatNeedsAttention(baseEntry({ startupStatus: "attention_required" }), null)).toBe(true); + }); + it("counts startup failed even when latestError is NULL (never a prerequisite)", () => { + expect(seatNeedsAttention(baseEntry({ startupStatus: "failed", latestError: null }), null)).toBe(true); + }); + it("counts a live needs_input hook", () => { + expect(seatNeedsAttention(baseEntry(), idleActivity("needs_input"))).toBe(true); + }); + it("counts a held seat", () => { + expect(seatNeedsAttention(baseEntry({ heldReason: "held: staged launch" }), null)).toBe(true); + }); + it("counts a recorded startup error as an ADDITIONAL signal", () => { + expect(seatNeedsAttention(baseEntry({ latestError: "boom" }), null)).toBe(true); + }); + it("healthy seat with running/idle/unknown activity does NOT count (unknown is not attention)", () => { + expect(seatNeedsAttention(baseEntry(), null)).toBe(false); + expect(seatNeedsAttention(baseEntry(), idleActivity("running"))).toBe(false); + expect(seatNeedsAttention(baseEntry(), idleActivity("idle"))).toBe(false); + expect(seatNeedsAttention(baseEntry(), idleActivity("unknown"))).toBe(false); + }); + + it("getEntries: multi-signal seat counts ONCE; healthy peers count zero", () => { + const rigId = seedRig("attn-once"); + const bad = seedNode(rigId, "bad"); + const ok = seedNode(rigId, "ok"); + // bad: failed startup AND a startup error AND held (three signals, one seat) + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))") + .run("s-bad", bad, "bad@attn-once", "exited", "failed"); + db.prepare("INSERT INTO events (rig_id, node_id, type, payload, created_at) VALUES (?, ?, 'node.startup_failed', ?, datetime('now'))") + .run(rigId, bad, JSON.stringify({ error: "launch exploded" })); + db.prepare("INSERT INTO events (rig_id, node_id, type, payload, created_at) VALUES (?, ?, 'node.held', ?, datetime('now'))") + .run(rigId, bad, JSON.stringify({ reason: "held" })); + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))") + .run("s-ok", ok, "ok@attn-once", "running", "ready"); + + const entries = ps.getEntries(); + expect(entries.find((e) => e.rigId === rigId)!.attentionCount).toBe(1); + }); + + it("getEntries: fresh needs_input hook counts via the store; stale hook degrades to unknown and does NOT", () => { + const rigId = seedRig("attn-hook"); + const n = seedNode(rigId, "dev"); + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))") + .run("s-hook", n, "dev@attn-hook", "running", "ready"); + const eventBus = new EventBus(db); + const store = new AgentActivityStore({ db, eventBus }); + const emit = (eventAt: string) => eventBus.emit({ + type: "agent.activity", rigId, nodeId: n, sessionName: "dev@attn-hook", runtime: "claude-code", + activity: { state: "needs_input", reason: "permission_prompt", evidenceSource: "runtime_hook", + sampledAt: eventAt, eventAt, evidence: "permission_prompt", fallback: false, stale: false }, + } as never); + + emit(new Date().toISOString()); // fresh + const withStore = new PsProjectionService({ db, agentActivity: store }); + expect(withStore.getEntries().find((e) => e.rigId === rigId)!.attentionCount).toBe(1); + + emit(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); // stale (latest row now old) + expect(withStore.getEntries().find((e) => e.rigId === rigId)!.attentionCount).toBe(0); + }); + + it("getEntries: store absent -> needs_input contributes false (honest degrade), other signals still count", () => { + const rigId = seedRig("attn-nostore"); + const n = seedNode(rigId, "dev"); + db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))") + .run("s-ns", n, "dev@attn-nostore", "exited", "failed"); + const entries = ps.getEntries(); // `ps` has no agentActivity store + expect(entries.find((e) => e.rigId === rigId)!.attentionCount).toBe(1); + }); + + it("attentionCount is ADDITIVE: every pre-existing PsEntry key is preserved (RPS-2)", () => { + const rigId = seedRig("attn-keys"); + const n = seedNode(rigId, "dev"); + seedSession(n, "running"); + const entry = ps.getEntries().find((e) => e.rigId === rigId)!; + for (const key of ["rigId", "name", "rigName", "nodeCount", "runningCount", "activeCount", "hasWorkCount", + "status", "lifecycleState", "uptime", "latestSnapshot", "archivedAt", "isArchived", + "periodicSnapshotActive", "periodicSnapshotIntervalSeconds", "autoPeriodicSnapshotCount"]) { + expect(Object.prototype.hasOwnProperty.call(entry, key), key).toBe(true); + } + expect(typeof entry.attentionCount).toBe("number"); + expect(JSON.stringify(entry)).not.toContain("resumeToken"); + }); + }); }); diff --git a/packages/daemon/test/queue-list-compact.test.ts b/packages/daemon/test/queue-list-compact.test.ts index 75b5ed148..9fef6d402 100644 --- a/packages/daemon/test/queue-list-compact.test.ts +++ b/packages/daemon/test/queue-list-compact.test.ts @@ -71,7 +71,7 @@ describe("OPR.0.4.0.28 — queue list compact + scope-default", () => { seedItem(db, `q-${i}`, { source: `seat-${i % 3}@rig-a`, destination: `seat-${(i + 1) % 3}@rig-a`, - body: `Long body for item ${i}. `.repeat(50), + body: `Long body for item ${i}. `.repeat(100), tags: ["tag-a", "tag-b", "tag-c"], }); } diff --git a/packages/daemon/test/queue-repository.test.ts b/packages/daemon/test/queue-repository.test.ts index 40d72aa95..66443dada 100644 --- a/packages/daemon/test/queue-repository.test.ts +++ b/packages/daemon/test/queue-repository.test.ts @@ -7,6 +7,7 @@ import { eventsSchema } from "../src/db/migrations/003_events.js"; import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js"; +import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js"; import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository, @@ -581,3 +582,206 @@ describe("QueueRepository summary column (OPR.0.4.1.18)", () => { expect(result2.created.summary).toBeNull(); }); }); + +describe("queue.* event payloads carry summary (OPR.0.4.4.19 FR-1)", () => { + let db: Database.Database; + let bus: EventBus; + let repo: QueueRepository; + let captured: PersistedEvent[]; + + beforeEach(() => { + db = createDb(); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema]); + bus = new EventBus(db); + repo = new QueueRepository(db, bus); + captured = []; + bus.subscribe((e) => captured.push(e)); + }); + + afterEach(() => db.close()); + + const eventOf = (type: string) => + captured.find((e) => e.type === type) as Record | undefined; + + it("queue.created carries the provided summary", async () => { + await repo.create({ + sourceSession: "alice@rig", + destinationSession: "bob@rig", + body: "body", + summary: "Approve the 0.4.4 cut", + nudge: false, + }); + const ev = eventOf("queue.created"); + expect(ev).toBeDefined(); + expect(ev!.summary).toBe("Approve the 0.4.4 cut"); + }); + + it("every queue.* event carries summary: null when omitted — present, never absent", async () => { + const item = await repo.create({ + sourceSession: "alice@rig", + destinationSession: "bob@rig", + body: "no summary", + nudge: false, + }); + repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" }); + repo.unclaim(item.qitemId, "bob@rig", "requeue"); + repo.update({ + qitemId: item.qitemId, + actorSession: "bob@rig", + state: "in-progress", + }); + await repo.handoff({ + qitemId: item.qitemId, + fromSession: "bob@rig", + toSession: "carol@rig", + nudge: false, + }); + for (const type of [ + "queue.created", + "queue.claimed", + "queue.unclaimed", + "queue.updated", + "queue.handed_off", + ]) { + const ev = eventOf(type); + expect(ev, `${type} emitted`).toBeDefined(); + expect("summary" in ev!, `${type} payload has summary key`).toBe(true); + expect(ev!.summary, `${type} summary is null`).toBeNull(); + } + }); + + it("claim/unclaim/updated events carry the row's persisted summary; handed_off carries the SOURCE summary and the handoff's queue.created carries the NEW summary", async () => { + const item = await repo.create({ + sourceSession: "alice@rig", + destinationSession: "bob@rig", + body: "body", + summary: "Source summary.", + nudge: false, + }); + repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" }); + expect(eventOf("queue.claimed")!.summary).toBe("Source summary."); + repo.unclaim(item.qitemId, "bob@rig", "requeue"); + expect(eventOf("queue.unclaimed")!.summary).toBe("Source summary."); + repo.update({ qitemId: item.qitemId, actorSession: "bob@rig", state: "in-progress" }); + expect(eventOf("queue.updated")!.summary).toBe("Source summary."); + captured.length = 0; + await repo.handoff({ + qitemId: item.qitemId, + fromSession: "bob@rig", + toSession: "carol@rig", + summary: "New owner summary.", + nudge: false, + }); + expect(eventOf("queue.handed_off")!.summary).toBe("Source summary."); + expect(eventOf("queue.created")!.summary).toBe("New owner summary."); + }); + + it("legacy pre-044 schema (no summary column): events still carry summary key with null", async () => { + const legacyDb = createDb(); + migrate(legacyDb, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema]); + const legacyBus = new EventBus(legacyDb); + const legacyRepo = new QueueRepository(legacyDb, legacyBus); + const legacyCaptured: PersistedEvent[] = []; + legacyBus.subscribe((e) => legacyCaptured.push(e)); + await legacyRepo.create({ + sourceSession: "alice@rig", + destinationSession: "bob@rig", + body: "legacy", + nudge: false, + }); + const ev = legacyCaptured.find((e) => e.type === "queue.created") as Record; + expect("summary" in ev).toBe(true); + expect(ev.summary).toBeNull(); + legacyDb.close(); + }); +}); + +describe("queue_items.evidence_ref column (OPR.0.4.4.19 FR-5 storage)", () => { + let db: Database.Database; + let repo: QueueRepository; + + beforeEach(() => { + db = createDb(); + migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]); + repo = new QueueRepository(db, new EventBus(db)); + }); + + afterEach(() => db.close()); + + it("persists --evidence-ref on create and round-trips it through getById", async () => { + const item = await repo.create({ + sourceSession: "pm@rig", + destinationSession: "human-review@kernel", + body: "please judge", + summary: "Approve the 0.4.4 cut", + evidenceRef: "missions/release-0.4.4/slices/19/PROOF.md", + nudge: false, + }); + expect(item.evidenceRef).toBe("missions/release-0.4.4/slices/19/PROOF.md"); + expect(repo.getById(item.qitemId)?.evidenceRef).toBe("missions/release-0.4.4/slices/19/PROOF.md"); + }); + + it("evidence_ref is null when omitted (BR-1: ordinary items never require it)", async () => { + const item = await repo.create({ + sourceSession: "a@rig", + destinationSession: "b@rig", + body: "ordinary agent-to-agent work", + nudge: false, + }); + expect(item.evidenceRef).toBeNull(); + }); + + it("handoff authors its OWN evidence_ref; not inherited from source (summary semantics)", async () => { + const src = await repo.create({ + sourceSession: "a@rig", + destinationSession: "b@rig", + body: "src", + evidenceRef: "proof/source.md", + nudge: false, + }); + const result = await repo.handoff({ + qitemId: src.qitemId, + fromSession: "b@rig", + toSession: "c@rig", + nudge: false, + }); + expect(result.created.evidenceRef).toBeNull(); + const result2 = await repo.handoff({ + qitemId: result.created.qitemId, + fromSession: "c@rig", + toSession: "d@rig", + evidenceRef: "proof/new.md", + nudge: false, + }); + expect(result2.created.evidenceRef).toBe("proof/new.md"); + }); + + it("listAttention JSON carries evidence_ref for human-routed items (FR-5 read-path AC)", async () => { + await repo.create({ + sourceSession: "pm@rig", + destinationSession: "human-review@kernel", + body: "judge me", + summary: "s", + evidenceRef: "proof/PROOF.md", + nudge: false, + }); + const attention = repo.listAttention(); + expect(attention).toHaveLength(1); + expect(attention[0]!.evidenceRef).toBe("proof/PROOF.md"); + }); + + it("legacy pre-048 schema: evidenceRef input degrades silently; reads are null", async () => { + const legacyDb = createDb(); + migrate(legacyDb, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema]); + const legacyRepo = new QueueRepository(legacyDb, new EventBus(legacyDb)); + const item = await legacyRepo.create({ + sourceSession: "a@rig", + destinationSession: "b@rig", + body: "legacy", + evidenceRef: "proof/x.md", + nudge: false, + }); + expect(item.evidenceRef).toBeNull(); + legacyDb.close(); + }); +}); diff --git a/packages/daemon/test/queue-routes.test.ts b/packages/daemon/test/queue-routes.test.ts index d2a6e2ee4..3ea196318 100644 --- a/packages/daemon/test/queue-routes.test.ts +++ b/packages/daemon/test/queue-routes.test.ts @@ -256,6 +256,8 @@ describe("queue routes", () => { destinationSession: "b@r", body: "approve please", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }), }); const res = await app.request("/api/queue/list?attention=1"); @@ -272,6 +274,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-bob@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "needs human", }), }); @@ -288,6 +292,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human@host", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "needs human attention", }), }); @@ -318,6 +324,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-x@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "done already", }), }); @@ -364,6 +372,8 @@ describe("queue routes", () => { destinationSession: "b@r", body: "approve me", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }), }); // Then create >100 routine qitems (no attention markers); these @@ -403,6 +413,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "advisor@r1", destinationSession: "human-alice@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "for alice", }), }); @@ -414,6 +426,8 @@ describe("queue routes", () => { destinationSession: "human-bob@kernel", body: "for bob", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }), }); // Also create a non-attention routine qitem destined to alice; @@ -424,6 +438,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "advisor@r1", destinationSession: "human-alice@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", state: "pending", body: "another for alice", }), @@ -446,6 +462,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "advisor-a@r", destinationSession: "human-x@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "from advisor-a", }), }); @@ -455,6 +473,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "advisor-b@r", destinationSession: "human-y@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "from advisor-b", }), }); @@ -473,6 +493,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-x@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "global x", }), }); @@ -482,6 +504,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "b@r", destinationSession: "human-y@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "global y", }), }); @@ -515,6 +539,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "advisor@r", destinationSession: "human-alice@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "valid attention", }), }); @@ -570,6 +596,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-bob@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "for repo-a", targetRepo: "repo-a", }), @@ -580,6 +608,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-carol@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "for repo-b", targetRepo: "repo-b", }), @@ -600,6 +630,8 @@ describe("queue routes", () => { body: JSON.stringify({ sourceSession: "a@r", destinationSession: "human-z@kernel", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", body: "repo-X attention", targetRepo: "repo-X", }), @@ -635,6 +667,8 @@ describe("queue routes", () => { destinationSession: "b@r", body: "approve me — oldest", tier: "human-gate", + summary: "test summary (FR-4 human-routed fixture)", + evidenceRef: "proof/test-evidence.md", }), }); // 1100 routine OPEN qitems land AFTER the attention item. They diff --git a/packages/daemon/test/remote-daemon-http.test.ts b/packages/daemon/test/remote-daemon-http.test.ts new file mode 100644 index 000000000..56d828a30 --- /dev/null +++ b/packages/daemon/test/remote-daemon-http.test.ts @@ -0,0 +1,132 @@ +// OPR.0.4.4.15 — the shared daemon→daemon transport core (arch cell 1). +// +// The bounded-abort discipline lives HERE now (one deadline through request +// AND body — the G-R2B1-1 class closed structurally for every consumer); +// remote-up-leaf's own tests keep pinning its shipped error strings, which +// proves the consumer formatting; these tests pin the structured result. + +import { describe, it, expect } from "vitest"; +import { remoteJsonRequest } from "../src/domain/hosts/remote-daemon-http.js"; +import { LOCAL_HOST_ID, hostsCovered } from "../src/domain/hosts/fanout-contract.js"; +import type { AggregatedPayload } from "../src/domain/hosts/fanout-contract.js"; +import type { HttpHostEntry } from "../src/domain/hosts/hosts-registry-reader.js"; + +const HOST: HttpHostEntry = { id: "vps-b", transport: "http", url: "http://vps-b:7433/", bearer_env: "T" }; +const ENV = { T: "tok-1" }; + +function fetchStub(status: number, payload?: unknown, capture?: { url?: string; init?: RequestInit }) { + return (async (url: string | URL | Request, init?: RequestInit) => { + if (capture) { + capture.url = String(url); + capture.init = init; + } + return new Response(payload === undefined ? "not-json" : JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +describe("remoteJsonRequest — structured outcomes (never hangs, never throws)", () => { + it("bearer failure short-circuits with kind=bearer; no request is sent", async () => { + let called = false; + const res = await remoteJsonRequest(HOST, "/api/x", { + method: "GET", + timeoutMs: 1000, + env: {}, + fetchImpl: (async () => { + called = true; + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + expect(res).toMatchObject({ ok: false, kind: "bearer" }); + if (!res.ok) expect(res.detail).toContain("bearer env var T"); + expect(called).toBe(false); + }); + + it("GET carries the bearer, no Content-Type/body; trailing-slash url joins cleanly; 2xx returns the parsed payload", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const res = await remoteJsonRequest(HOST, "/api/queue/list?attention=1", { + method: "GET", + timeoutMs: 1000, + env: ENV, + fetchImpl: fetchStub(200, { items: [1, 2] }, capture), + }); + expect(res).toEqual({ ok: true, status: 200, payload: { items: [1, 2] } }); + expect(capture.url).toBe("http://vps-b:7433/api/queue/list?attention=1"); + expect(capture.init?.method).toBe("GET"); + const headers = capture.init?.headers as Record; + expect(headers["Authorization"]).toBe("Bearer tok-1"); + expect(headers["Content-Type"]).toBeUndefined(); + expect(capture.init?.body).toBeUndefined(); + expect(capture.init?.signal).toBeInstanceOf(AbortSignal); // deadline wired to the request + }); + + it("POST serializes the body with Content-Type", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + await remoteJsonRequest(HOST, "/api/up", { + method: "POST", + body: { sourceRef: "r" }, + timeoutMs: 1000, + env: ENV, + fetchImpl: fetchStub(201, {}, capture), + }); + expect((capture.init?.headers as Record)["Content-Type"]).toBe("application/json"); + expect(JSON.parse(String(capture.init?.body))).toEqual({ sourceRef: "r" }); + }); + + it("never-settling request → kind=timeout phase=request within the caller's deadline", async () => { + const neverSettling = ((_u: string | URL | Request, init?: RequestInit) => + new Promise((_res, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + })) as typeof fetch; + const res = await remoteJsonRequest(HOST, "/api/x", { method: "GET", timeoutMs: 20, env: ENV, fetchImpl: neverSettling }); + expect(res).toMatchObject({ ok: false, kind: "timeout", phase: "request" }); + }); + + it("headers-then-stalled-body → kind=timeout phase=body carrying the status (the G-R2B1-1 class, now structural)", async () => { + const stalled = (async () => + new Response(new ReadableStream({ start() {} }), { status: 500, headers: { "Content-Type": "application/json" } })) as typeof fetch; + const res = await remoteJsonRequest(HOST, "/api/x", { method: "GET", timeoutMs: 20, env: ENV, fetchImpl: stalled }); + expect(res).toMatchObject({ ok: false, kind: "timeout", phase: "body", status: 500 }); + }); + + it("non-2xx with a JSON error body → kind=http with the remote text; non-JSON body → empty detail (status is the honest fact)", async () => { + const withText = await remoteJsonRequest(HOST, "/api/x", { method: "GET", timeoutMs: 1000, env: ENV, fetchImpl: fetchStub(409, { error: "conflict!" }) }); + expect(withText).toMatchObject({ ok: false, kind: "http", status: 409, detail: "conflict!" }); + const nonJson = await remoteJsonRequest(HOST, "/api/x", { method: "GET", timeoutMs: 1000, env: ENV, fetchImpl: fetchStub(500) }); + expect(nonJson).toMatchObject({ ok: false, kind: "http", status: 500, detail: "" }); + }); + + it("network failure → kind=network with the error message", async () => { + const res = await remoteJsonRequest(HOST, "/api/x", { + method: "GET", + timeoutMs: 1000, + env: ENV, + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch, + }); + expect(res).toMatchObject({ ok: false, kind: "network", detail: "ECONNREFUSED" }); + }); +}); + +describe("fanout-contract — the shared intra-P4 payload (arch adjudication: 15 defines, 21 imports)", () => { + it("LOCAL_HOST_ID is the S11-matching literal, defined once", () => { + expect(LOCAL_HOST_ID).toBe("local"); + }); + + it("hostsCovered: true only when EVERY expected host appears exactly once (omission-proof, near the contract per arch pin B)", () => { + const payload: AggregatedPayload = { + items: [1], + hosts: [ + { hostId: LOCAL_HOST_ID, status: "ok" }, + { hostId: "vps-b", status: "unreachable", error: "timeout", failedStep: "remote-daemon-unreachable" }, + { hostId: "ssh-1", status: "unsupported-transport", error: "ssh transport cannot carry the read" }, + ], + }; + expect(hostsCovered(payload, [LOCAL_HOST_ID, "vps-b", "ssh-1"])).toBe(true); + expect(hostsCovered(payload, [LOCAL_HOST_ID, "vps-b", "ssh-1", "missing"])).toBe(false); // silent thinning caught + expect(hostsCovered({ items: [], hosts: [...payload.hosts, { hostId: "vps-b", status: "ok" }] }, [LOCAL_HOST_ID, "vps-b", "ssh-1"])).toBe(false); // duplicates caught + }); +}); diff --git a/packages/daemon/test/remote-up-leaf.test.ts b/packages/daemon/test/remote-up-leaf.test.ts new file mode 100644 index 000000000..55ad0f0cd --- /dev/null +++ b/packages/daemon/test/remote-up-leaf.test.ts @@ -0,0 +1,160 @@ +// OPR.0.4.4.11 — daemon-side remote single-rig leaf (FR-4). +// +// The leaf mirrors the CLI transport shape (bearer resolution + status +// classification vocabulary) — these tests pin the mirrored semantics and +// that failures surface the EXISTING classes, never a new taxonomy. + +import { describe, it, expect } from "vitest"; +import { remoteUpLeaf } from "../src/domain/topology/remote-up-leaf.js"; +import type { HttpHostEntry } from "../src/domain/hosts/hosts-registry-reader.js"; + +const HOST_ENV: HttpHostEntry = { id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "VPS_B_TOKEN" }; +const HOST_FILE: HttpHostEntry = { id: "vps-f", transport: "http", url: "http://vps-f:7433/", bearer_file: "/tok/f" }; + +function fetchStub(status: number, payload?: unknown, capture?: { url?: string; init?: RequestInit }) { + return (async (url: string | URL | Request, init?: RequestInit) => { + if (capture) { + capture.url = String(url); + capture.init = init; + } + return new Response(payload === undefined ? null : JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +describe("remoteUpLeaf — bearer resolution (mirrors CLI resolveRemoteBearer)", () => { + it("bearer_env: missing/empty env is a permission-gate failure, no request sent", async () => { + let called = false; + const res = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: {}, + fetchImpl: (async () => { + called = true; + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("[permission-gate]"); + expect(res.error).toContain("VPS_B_TOKEN"); + expect(called).toBe(false); + }); + + it("bearer_file: unreadable and empty files are permission-gate failures", async () => { + const unreadable = await remoteUpLeaf({ sourceRef: "r" }, HOST_FILE, { + readFile: () => { + throw new Error("ENOENT"); + }, + }); + expect(unreadable.error).toContain("[permission-gate]"); + expect(unreadable.error).toContain("not readable"); + + const empty = await remoteUpLeaf({ sourceRef: "r" }, HOST_FILE, { readFile: () => " " }); + expect(empty.error).toContain("[permission-gate]"); + expect(empty.error).toContain("is empty"); + }); +}); + +describe("remoteUpLeaf — the shipped POST /api/up transport", () => { + it("POSTs the body to {url}/api/up with the resolved bearer; 2xx is ok", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const res = await remoteUpLeaf( + { sourceRef: "specs/factory.yaml", autoApprove: true }, + HOST_FILE, // trailing-slash url — normalized + { readFile: () => "tok-123\n", fetchImpl: fetchStub(200, { status: "completed" }, capture) }, + ); + expect(res).toEqual({ ok: true }); + expect(capture.url).toBe("http://vps-f:7433/api/up"); + expect(capture.init?.method).toBe("POST"); + expect((capture.init?.headers as Record)["Authorization"]).toBe("Bearer tok-123"); + expect(JSON.parse(String(capture.init?.body))).toEqual({ sourceRef: "specs/factory.yaml", autoApprove: true }); + }); + + it("401/403 classifies permission-gate; 4xx/5xx classifies remote-command-failed with the remote error text verbatim", async () => { + const auth = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: fetchStub(401), + }); + expect(auth.error).toContain("[permission-gate]"); + + const failed = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: fetchStub(409, { error: "Already in progress for this source" }), + }); + expect(failed.ok).toBe(false); + expect(failed.error).toContain("[remote-command-failed]"); + expect(failed.error).toContain("HTTP 409: Already in progress for this source"); + }); + + it("R2-B1: a remote /api/up that NEVER settles returns a structured timeout failure instead of hanging the walk", async () => { + // fetch stub honors AbortSignal exactly as real fetch does: never + // resolves on its own, rejects with AbortError when the signal fires. + let sawSignal = false; + const neverSettling = ((url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + sawSignal = init?.signal instanceof AbortSignal; + init?.signal?.addEventListener("abort", () => reject(new DOMException("This operation was aborted", "AbortError"))); + })) as typeof fetch; + const res = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: neverSettling, + timeoutMs: 25, + }); + expect(sawSignal).toBe(true); // the abort path is actually wired to the request + expect(res.ok).toBe(false); + expect(res.error).toContain("[remote-daemon-unreachable]"); + expect(res.error).toContain("timed out after 25ms"); + expect(res.error).toContain("vps-b"); + }); + + it("G-R2B1-1: failure HEADERS followed by a NEVER-COMPLETING error body still returns the structured timeout (deadline stays armed through body parse)", async () => { + // Guard's exact recipe: the response resolves with 500 headers and a + // body stream that never produces bytes — Response.json() stays pending + // forever unless the leaf bounds the parse itself. + const stalledBody = ((_url: string | URL | Request, _init?: RequestInit) => + Promise.resolve( + new Response(new ReadableStream({ start() {} }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + )) as typeof fetch; + const res = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: stalledBody, + timeoutMs: 25, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("[remote-daemon-unreachable]"); + expect(res.error).toContain("timed out after 25ms"); + expect(res.error).toContain("response headers arrived (HTTP 500)"); + expect(res.error).toContain("vps-b"); + }); + + it("G-R2B1-1: a COMPLETE error body within the deadline still yields the classified failure with the remote text verbatim (no regression)", async () => { + const res = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: fetchStub(500, { error: "boom from remote" }), + timeoutMs: 5_000, + }); + expect(res.error).toContain("[remote-command-failed]"); + expect(res.error).toContain("HTTP 500: boom from remote"); + }); + + it("R2-B1: the default deadline is the long-running rig-up budget, not the generic metadata default", async () => { + const { REMOTE_UP_TIMEOUT_MS } = await import("../src/domain/topology/remote-up-leaf.js"); + expect(REMOTE_UP_TIMEOUT_MS).toBe(120_000); + }); + + it("network failure classifies remote-daemon-unreachable naming host + url", async () => { + const res = await remoteUpLeaf({ sourceRef: "r" }, HOST_ENV, { + env: { VPS_B_TOKEN: "t" }, + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("[remote-daemon-unreachable]"); + expect(res.error).toContain("vps-b"); + expect(res.error).toContain("ECONNREFUSED"); + }); +}); diff --git a/packages/daemon/test/resume-metadata-refresher.test.ts b/packages/daemon/test/resume-metadata-refresher.test.ts index 7e5572506..f27881612 100644 --- a/packages/daemon/test/resume-metadata-refresher.test.ts +++ b/packages/daemon/test/resume-metadata-refresher.test.ts @@ -83,7 +83,8 @@ describe("ResumeMetadataRefresher", () => { expect(sessionRegistry.updateResumeToken).toHaveBeenCalledWith( "sess-1", "codex_id", - "019d45c3-e909-7152-b52e-34edab4070ed" + "019d45c3-e909-7152-b52e-34edab4070ed", + "scrape" ); }); @@ -119,7 +120,8 @@ describe("ResumeMetadataRefresher", () => { expect(sessionRegistry.updateResumeToken).toHaveBeenCalledWith( "sess-1", "codex_id", - "019d45c3-e909-7152-b52e-34edab4070ed" + "019d45c3-e909-7152-b52e-34edab4070ed", + "scrape" ); }); @@ -159,7 +161,8 @@ describe("ResumeMetadataRefresher", () => { expect(sessionRegistry.updateResumeToken).toHaveBeenCalledWith( "sess-1", "codex_id", - "019d45c3-e909-7152-b52e-34edab4070ed" + "019d45c3-e909-7152-b52e-34edab4070ed", + "scrape" ); }); @@ -199,7 +202,8 @@ describe("ResumeMetadataRefresher", () => { expect(sessionRegistry.updateResumeToken).toHaveBeenCalledWith( "sess-1", "codex_id", - "019d45c3-e909-7152-b52e-34edab4070ed" + "019d45c3-e909-7152-b52e-34edab4070ed", + "scrape" ); }); diff --git a/packages/daemon/test/review-brief-spine.test.ts b/packages/daemon/test/review-brief-spine.test.ts new file mode 100644 index 000000000..250bd6a26 --- /dev/null +++ b/packages/daemon/test/review-brief-spine.test.ts @@ -0,0 +1,106 @@ +// OPR.0.4.4.20 FR-8 — MISSION_BRIEF spine generation ACs. +// (Authored by the driver; executed by QA's gate run per the dev44 VM posture.) + +import { describe, it, expect } from "vitest"; +import { applyBriefSpine, renderBriefSpine } from "../src/domain/review/brief-spine.js"; +import { MISSION_BRIEF_HEADERS } from "../src/domain/scope/scope-audit.js"; +import { composeMissionReview, composeSliceReview } from "../src/domain/review/compose.js"; +import type { SliceComposeInputs } from "../src/domain/review/compose.js"; + +const NOW = "2026-07-04T12:00:00.000Z"; + +function sliceInputs(name: string): SliceComposeInputs { + return { + slice: { name, id: null, title: name, missionId: "m" }, + readme: "## Intent\n\ni\n", + prd: "## Mini-requirements\n\n1. m\n", + proofMd: null, + artifacts: [], + lockedArtifacts: [], + mediaRefs: [], + proofDirExists: false, + attention: [], + agents: [], + activeQitemPresent: false, + git: { mainTip: "tip", mergeSha: null, mergeIsAncestorOfTip: null, candidateBehindTip: 0 }, + approval: { spec: null, delivery: null }, + nowIso: NOW, + }; +} + +function mission() { + return composeMissionReview({ + mission: { name: "m", id: null, title: "m", intent: "The founder's why." }, + slices: [{ review: composeSliceReview(sliceInputs("s1")), green: false }], + missionAttention: [], + agents: [], + nowIso: NOW, + }); +} + +const BRIEF = `--- +id: X +--- +# m — Brief + +## What & why + +Hand-authored prose the generator must never touch. + +## Building + +- stale hand line + +## Progress + +- stale hand line + +## Proven + +- stale hand line + +## Needs you + +- stale hand line + +## Pointers + +- hand-authored pointer, untouched +`; + +describe("FR-8 brief spine", () => { + it("derives the spine from the same composer queries (no second computation path) and carries intent verbatim", () => { + const m = mission(); + expect(m.intent).toBe("The founder's why."); + expect(m.briefSpine.progress).toContain("PLAN: 1"); + expect(m.briefSpine.needsYou).toContain("0 attention items"); + expect(renderBriefSpine(m)).toEqual(m.briefSpine); // same function, same strings + }); + + it("applies section-scoped: spine sections replaced, hand-authored sections byte-untouched, schema order preserved", () => { + const m = mission(); + const applied = applyBriefSpine(BRIEF, m.briefSpine); + expect(applied).not.toBeNull(); + expect(applied!).toContain("Hand-authored prose the generator must never touch."); + expect(applied!).toContain("hand-authored pointer, untouched"); + expect(applied!).not.toContain("stale hand line"); + // The pinned exact-order H2 schema survives (the scope-audit contract). + const headers = [...applied!.matchAll(/^##\s+(.+?)\s*$/gm)].map((x) => x[1]); + expect(headers).toEqual(MISSION_BRIEF_HEADERS); + }); + + it("refuses to guess-rewrite a brief that does not carry the pinned schema", () => { + const m = mission(); + expect(applyBriefSpine("# not a brief\n\n## Random\n\nx\n", m.briefSpine)).toBeNull(); + // Wrong order is also refused — order is part of the schema. + const wrongOrder = BRIEF.replace("## Building", "## TEMP").replace("## Progress", "## Building").replace("## TEMP", "## Progress"); + expect(applyBriefSpine(wrongOrder, m.briefSpine)).toBeNull(); + }); + + it("is idempotent: applying the same spine twice yields identical bytes", () => { + const m = mission(); + const once = applyBriefSpine(BRIEF, m.briefSpine)!; + const twice = applyBriefSpine(once, m.briefSpine)!; + expect(twice).toBe(once); + }); +}); diff --git a/packages/daemon/test/review-composer.test.ts b/packages/daemon/test/review-composer.test.ts new file mode 100644 index 000000000..dff5c0dd9 --- /dev/null +++ b/packages/daemon/test/review-composer.test.ts @@ -0,0 +1,503 @@ +// Living Notes — composer core ACs (OPR.0.4.4.20, rebuilt per the +// CORRECTIVE REDESIGN of 2026-07-05). +// +// Every test here is a spec acceptance criterion, driven against the PURE +// composer with hand-built inputs (idempotence is byte-equality on the +// serialized output — same inputs, same bytes). The one-structure contract +// is itself under test: the superseded structures (sections / acceptance / +// compare / join / green) must be ABSENT from the composed output. + +import { describe, it, expect } from "vitest"; +import { + composeDelivered, + composeLineage, + composeMissionReview, + composeRecordedGreenForSlice, + composeSliceReview, + computeRecordedGreen, + deriveCandidateSha, + deriveGateCells, + derivePhase, + extractMediaRefs, + extractMiniReqs, + extractProofContract, + extractSection, + isPassing, + parseC1Header, + proofClaimsPass, + sliceRelativeMediaPath, + type MissionSliceEntry, + type SliceComposeInputs, +} from "../src/domain/review/compose.js"; +import type { ProofArtifact } from "../src/domain/review/types.js"; + +const NOW = "2026-07-04T10:00:00.000Z"; + +function artifact(over: Partial & { artifactType: ProofArtifact["artifactType"] }): ProofArtifact { + return { + relPath: `proof/${over.artifactType}.md`, + slice: "s-20", + candidateSha: "cand1234", + verdict: null, + moneyEvidence: null, + evidences: [], + selfCheck: null, + mediaRefs: [], + droppedAt: "2026-07-04T09:00:00.000Z", + ...over, + }; +} + +function fullGate(overrides: Partial> = {}): ProofArtifact[] { + const base = { guard: "CLEAR", qa: "PASS", "rev1-r1": "CLEAR", "rev1-r2": "CLEAR" } as const; + return (Object.keys(base) as Array) + .filter((r) => overrides[r] !== undefined ? overrides[r] !== null : true) + .map((r) => artifact({ artifactType: r, verdict: (r in overrides ? overrides[r] : base[r]) as ProofArtifact["verdict"] })); +} + +function baseInputs(over: Partial = {}): SliceComposeInputs { + return { + slice: { name: "20-fixture", id: "OPR.T.20", title: "Fixture slice", missionId: "release-t" }, + readme: "---\ntitle: t\n---\n\n# Fixture\n\n## Intent\n\nThe founder's exact words.\n", + prd: "---\ntitle: t\n---\n\n## Mini-requirements\n\n1. One surface.\n2. Verified from recorded QA comparisons only.\n\n# Spec\n\nBody.\n\n## Proof contract\n\n- [ ] phone journey video\n- [ ] range probe 206\n", + proofMd: null, + artifacts: [], + lockedArtifacts: [], + mediaRefs: [], + proofDirExists: true, + attention: [], + agents: [], + activeQitemPresent: false, + git: { mainTip: "tip99999", mergeSha: null, mergeIsAncestorOfTip: null, candidateBehindTip: 0 }, + approval: { spec: null, delivery: null }, + nowIso: NOW, + ...over, + }; +} + +const DELIVERY_STAMP = { by: "human@host", at: NOW, auditRowPresent: true }; + +describe("the ONE structure (§3.1) — no superseded structure survives", () => { + it("composes exactly intent/plan/delivered — sections/acceptance/compare/join/green are ABSENT keys", () => { + const r = composeSliceReview(baseInputs({ artifacts: fullGate() })) as unknown as Record; + for (const dead of ["sections", "acceptance", "compare", "join", "green", "locked"]) { + expect(r, `superseded structure '${dead}' must not survive on the contract`).not.toHaveProperty(dead); + } + for (const alive of ["intent", "plan", "delivered", "needsYou", "agents", "lineage", "defects", "composedAt"]) { + expect(r).toHaveProperty(alive); + } + }); + + it("every section always composes, degrading honestly when its source is absent", () => { + const r = composeSliceReview(baseInputs({ readme: null, prd: null })); + expect(r.intent.text).toBeNull(); + expect(r.intent.degrade).toBe("no intent recorded"); + expect(r.intent.ssotPath).toBeNull(); + expect(r.plan.concise.text).toBeNull(); + expect(r.plan.ssotPath).toBeNull(); + expect(r.delivered.items).toHaveLength(0); + }); +}); + +describe("C1 header parsing", () => { + it("parses a full header and treats out-of-set verdicts as null (present != verdict)", () => { + const good = parseC1Header( + "---\nslice: s-20\ncandidate_sha: abc123\nartifact_type: qa\nverdict: PASS\nmoney_evidence: one line\nevidences:\n - 1\nself_check: looked at it\n---\nbody", + "proof/qa.md", + NOW, + ); + expect(good.artifactType).toBe("qa"); + expect(good.verdict).toBe("PASS"); + expect(good.evidences).toEqual(["1"]); + const bad = parseC1Header( + "---\nslice: s\ncandidate_sha: abc\nartifact_type: qa\nverdict: PASSED\n---\n", + "proof/qa.md", + NOW, + ); + expect(bad.verdict).toBeNull(); + const invalid = parseC1Header("no frontmatter at all", "proof/x.md", NOW); + expect(invalid.artifactType).toBeNull(); + }); + + it("captures body media refs (markdown images + video tags), excluding http", () => { + const a = parseC1Header( + '---\nartifact_type: qa\nverdict: PASS\n---\n\n![shot](drawer-right.png)\n\n![ext](https://x/y.png)\n', + "proof/qa.md", + NOW, + ); + expect(a.mediaRefs).toEqual(["drawer-right.png", "playing.webm"]); + }); +}); + +describe("FR-2 — pass-mapping + recorded verdicts", () => { + it("pins the verdict pass-mapping exactly", () => { + expect(isPassing("guard", "CLEAR")).toBe(true); + expect(isPassing("guard", "PASS")).toBe(false); + expect(isPassing("qa", "PASS")).toBe(true); + expect(isPassing("qa", "CLEAR")).toBe(false); + expect(isPassing("adjudication", "CLEAR")).toBe(true); + expect(isPassing("adjudication", "PASS")).toBe(true); + for (const t of ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const) { + expect(isPassing(t, "BLOCKING")).toBe(false); + expect(isPassing(t, "CONCERNING")).toBe(false); + expect(isPassing(t, "NOT-CLEAR")).toBe(false); + expect(isPassing(t, null)).toBe(false); + } + }); + + it("recorded green (mission-ledger fact): regime 1 from four passing verdicts, regime 2 from adjudication", () => { + expect(composeRecordedGreenForSlice(fullGate())).toEqual({ green: true, regime: 1 }); + const adj = artifact({ artifactType: "adjudication", verdict: "CLEAR", relPath: "proof/adjudication.md" }); + expect(composeRecordedGreenForSlice([adj])).toEqual({ green: true, regime: 2 }); + expect(composeRecordedGreenForSlice(fullGate({ "rev1-r2": "CONCERNING" }))).toEqual({ green: false, regime: null }); + expect(composeRecordedGreenForSlice([])).toEqual({ green: false, regime: null }); + }); + + it("one gate artifact absent -> the specific cell is missing", () => { + const arts = fullGate().filter((a) => a.artifactType !== "rev1-r2"); + const r = composeSliceReview(baseInputs({ artifacts: arts })); + const cell = r.lineage.gateCells.find((c) => c.role === "rev1-r2")!; + expect(cell.state).toBe("missing"); + expect(cell.recordedToken).toBeNull(); + }); + + it("a NON-PASSING verdict keeps its verbatim token; approval stamps never recolor it", () => { + const arts = fullGate({ "rev1-r2": "CONCERNING" }); + const r = composeSliceReview(baseInputs({ artifacts: arts, approval: { spec: null, delivery: DELIVERY_STAMP } })); + const cell = r.lineage.gateCells.find((c) => c.role === "rev1-r2")!; + expect(cell.recordedToken).toBe("CONCERNING"); // verbatim — never collapsed to FAIL + expect(cell.tone).toBe("fail"); // tone derived SEPARATELY + expect(r.delivered.lock).not.toBeNull(); // the stamp renders... + expect(cell.state).toBe("non-passing"); // ...but the gate cell keeps its real state + expect(computeRecordedGreen(r.lineage.gateCells, arts, "cand1234").green).toBe(false); // BR-6 + }); + + it("latest-wins supersession within the same (candidate, artifact_type) tuple only", () => { + const early = artifact({ artifactType: "qa", verdict: "NOT-CLEAR", droppedAt: "2026-07-04T08:00:00.000Z", relPath: "proof/qa-1.md" }); + const later = artifact({ artifactType: "qa", verdict: "PASS", droppedAt: "2026-07-04T09:30:00.000Z", relPath: "proof/qa-2.md" }); + const cells = deriveGateCells([early, later, ...fullGate({ qa: undefined as never }).filter((a) => a.artifactType !== "qa")], "cand1234"); + expect(cells.find((c) => c.role === "qa")!.recordedToken).toBe("PASS"); + // A later artifact for a DIFFERENT candidate does not supersede. + const otherSha = artifact({ artifactType: "qa", verdict: "NOT-CLEAR", candidateSha: "other999", droppedAt: "2026-07-04T09:45:00.000Z", relPath: "proof/qa-3.md" }); + const cells2 = deriveGateCells([later, otherSha], "cand1234"); + expect(cells2.find((c) => c.role === "qa")!.recordedToken).toBe("PASS"); + }); + + it("a PROOF.md self-claimed PASS with zero gate artifacts raises confirm-faithful, never verified anything", () => { + const r = composeSliceReview(baseInputs({ proofMd: "# Proof\n\nResult: PASS\n" })); + const cf = r.needsYou.items.find((i) => i.leg === "confirm-faithful"); + expect(cf).toBeDefined(); + expect(cf!.summary).toBe("confirm this proof is faithful"); + expect(r.delivered.items.every((it) => it.verified === "missing")).toBe(true); + }); + + it("incidental PASS words in a pending PROOF.md do not count as a self-claim", () => { + const pending = [ + "# Proof", + "", + "Closed by: OPEN — build handed to QA Date: 2026-07-04 Verdict: PENDING", + "", + "- walk the claimed-PASS fixture", + "- rig scope audit PASS", + ].join("\n"); + expect(proofClaimsPass(pending)).toBe(false); + const r = composeSliceReview(baseInputs({ proofMd: pending })); + expect(r.needsYou.items.find((i) => i.leg === "confirm-faithful")).toBeUndefined(); + }); + + it("explicit Closed by Verdict: PASS counts as a PROOF.md self-claim", () => { + expect(proofClaimsPass("Closed by: qa@rig Date: 2026-07-04 Verdict: PASS")).toBe(true); + }); + + it("an adjudication CLEAR clears confirm-faithful on recompose (regime 2)", () => { + const adj = artifact({ artifactType: "adjudication", verdict: "CLEAR", relPath: "proof/adjudication.md" }); + const r = composeSliceReview(baseInputs({ proofMd: "Result: PASS\n", artifacts: [adj] })); + expect(r.needsYou.items.find((i) => i.leg === "confirm-faithful")).toBeUndefined(); + }); + + it("N1 lineage: three facts always present; label derives from merge state", () => { + const cells = deriveGateCells(fullGate(), "cand1234"); + const unmergedFresh = composeLineage("cand1234", { mainTip: "tip", mergeSha: null, mergeIsAncestorOfTip: null, candidateBehindTip: 1 }, cells); + expect(unmergedFresh).toMatchObject({ candidateSha: "cand1234", mergeSha: null, mainTip: "tip", freshness: "fresh" }); + const unmergedStale = composeLineage("cand1234", { mainTip: "tip", mergeSha: null, mergeIsAncestorOfTip: null, candidateBehindTip: 12 }, cells); + expect(unmergedStale.freshness).toBe("stale"); + expect(unmergedStale.staleBehind).toBe(12); + const mergedFresh = composeLineage("cand1234", { mainTip: "tip", mergeSha: "merge55", mergeIsAncestorOfTip: true, candidateBehindTip: null }, cells); + expect(mergedFresh.freshness).toBe("fresh"); + expect(mergedFresh.mergeSha).toBe("merge55"); + }); +}); + +describe("§4 — the two locks (the SHIPPED staged-approval stamps)", () => { + it("plan.lock = the spec-scope stamp; delivered.lock = the delivery-scope stamp; independent", () => { + const specOnly = composeSliceReview( + baseInputs({ approval: { spec: { by: "planner@rig", at: "2026-07-03T00:00:00.000Z", auditRowPresent: true }, delivery: null } }), + ); + expect(specOnly.plan.lock).toEqual({ by: "planner@rig", at: "2026-07-03T00:00:00.000Z", auditVerified: true }); + expect(specOnly.delivered.lock).toBeNull(); + expect(specOnly.phase).not.toBe("locked"); // only the delivery stamp locks + + const both = composeSliceReview( + baseInputs({ approval: { spec: { by: "planner@rig", at: "2026-07-03T00:00:00.000Z", auditRowPresent: true }, delivery: DELIVERY_STAMP } }), + ); + expect(both.delivered.lock).toEqual({ by: "human@host", at: NOW, auditVerified: true }); + expect(both.phase).toBe("locked"); + }); + + it("UNVERIFIED stamp: a stamp with no matching audit row carries auditVerified false — visible, never a block", () => { + const r = composeSliceReview( + baseInputs({ approval: { spec: null, delivery: { by: "s", at: NOW, auditRowPresent: false } } }), + ); + expect(r.delivered.lock).toEqual({ by: "s", at: NOW, auditVerified: false }); + expect(r.phase).toBe("locked"); // fail-open: the stamp still stands; the render flags it + }); + + it("lockedArtifacts pass through as the pinned plan set; media-kind entries surface in plan media", () => { + const r = composeSliceReview( + baseInputs({ + lockedArtifacts: [ + { name: "the PRD", path: "IMPLEMENTATION-PRD.md", kind: "spec" }, + { name: "drawer mockup", path: "mockups/drawer-right.png", kind: "mockup" }, + ], + }), + ); + expect(r.plan.lockedArtifacts).toHaveLength(2); + expect(r.plan.concise.media).toContainEqual({ kind: "image", src: "mockups/drawer-right.png", caption: "mockups/drawer-right.png" }); + }); +}); + +describe("FR-1 — verbatim intent, section media, idempotence", () => { + it("composes twice byte-identically (pure core, view-time facts are inputs)", () => { + const inputs = baseInputs({ artifacts: fullGate(), proofMd: "PASS" }); + const a = JSON.stringify(composeSliceReview(inputs)); + const b = JSON.stringify(composeSliceReview(inputs)); + expect(a).toBe(b); + }); + + it("projects INTENT character-identical to the source section, with its media", () => { + const r = composeSliceReview( + baseInputs({ readme: "---\nt: x\n---\n\n## Intent\n\nThe founder's exact words.\n\n![sketch](sketch.png)\n" }), + ); + expect(r.intent.text).toContain("The founder's exact words."); + expect(r.intent.media).toEqual([{ kind: "image", src: "sketch.png", caption: "sketch.png" }]); + expect(r.intent.ssotPath).toBe("release-t/slices/20-fixture/README.md"); + }); + + it("no PRD at the C7 pinned name -> plan degrades to null text, nothing synthesized", () => { + const r = composeSliceReview(baseInputs({ prd: null })); + expect(r.plan.concise.text).toBeNull(); + expect(r.plan.ssotPath).toBeNull(); + }); + + it("absolute media paths surface as defect findings", () => { + const r = composeSliceReview(baseInputs({ mediaRefs: ["/abs/path/shot.png", "proof/ok.png"] })); + expect(r.defects).toHaveLength(1); + expect(r.defects[0]).toContain("/abs/path/shot.png"); + }); + + it("traversal media refs surface as escape-defect findings (rev1 fixback)", () => { + const r = composeSliceReview( + baseInputs({ mediaRefs: ["../sibling/shot.png", "proof/sub/../../../x.png", "proof/ok..png", "proof/fine.png"] }), + ); + expect(r.defects).toHaveLength(2); // ".." as a PATH SEGMENT only — "ok..png" is a filename, not traversal + expect(r.defects[0]).toContain("../sibling/shot.png"); + expect(r.defects[1]).toContain("proof/sub/../../../x.png"); + }); + + it("artifact media escaping the slice dir is a defect finding, never silently curated", () => { + const qa = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["1"], selfCheck: "looked", mediaRefs: ["../../outside.png", "in-proof.png"] }); + const r = composeSliceReview(baseInputs({ artifacts: [qa] })); + expect(r.defects.some((d) => d.includes("../../outside.png"))).toBe(true); + const item = r.delivered.items[0]!; + expect(item.proof).toEqual([{ kind: "image", src: "proof/in-proof.png", caption: "in-proof.png" }]); + }); +}); + +describe("FR-3 — five-way derived phase, top-down precedence", () => { + it("derives each phase from layer completeness alone", () => { + expect(derivePhase({ prdPresent: false, proofArtifactPresent: false, activeQitemPresent: false, verdictOrEvidenceSetPresent: false, approved: false })).toBe("intent"); + expect(derivePhase({ prdPresent: true, proofArtifactPresent: false, activeQitemPresent: false, verdictOrEvidenceSetPresent: false, approved: false })).toBe("spec"); + expect(derivePhase({ prdPresent: true, proofArtifactPresent: false, activeQitemPresent: true, verdictOrEvidenceSetPresent: false, approved: false })).toBe("building"); + expect(derivePhase({ prdPresent: true, proofArtifactPresent: true, activeQitemPresent: false, verdictOrEvidenceSetPresent: true, approved: false })).toBe("review"); + expect(derivePhase({ prdPresent: true, proofArtifactPresent: true, activeQitemPresent: false, verdictOrEvidenceSetPresent: true, approved: true })).toBe("locked"); + }); + + it("one signal satisfying two lanes resolves by precedence, deterministically", () => { + const s = { prdPresent: true, proofArtifactPresent: true, activeQitemPresent: false, verdictOrEvidenceSetPresent: true, approved: false }; + expect(derivePhase(s)).toBe("review"); + expect(derivePhase(s)).toBe(derivePhase({ ...s })); + }); + + it("maps phases to the SS14 lane vocabulary", () => { + const r = composeSliceReview(baseInputs({ prd: null, readme: null })); + expect(r.phase).toBe("intent"); + expect(r.laneLabel).toBe("INTENT"); + const spec = composeSliceReview(baseInputs()); + expect(spec.laneLabel).toBe("PLAN"); + }); +}); + +describe("§3.1 DELIVERED — the redesigned join (planned ↔ curated proof ↔ verified)", () => { + const promised = extractProofContract(baseInputs().prd); + + it("verified REQUIRES a passing recorded QA comparison (self_check + passing verdict), never presence", () => { + const qa = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["1"], selfCheck: "watched it play", mediaRefs: ["playing.webm"] }); + const d = composeDelivered(promised, [qa]); + expect(d.items[0]).toMatchObject({ verified: "verified", note: "watched it play" }); + expect(d.items[0]!.proof).toEqual([{ kind: "video", src: "proof/playing.webm", caption: "playing.webm" }]); + expect(d.items[1]).toMatchObject({ verified: "missing" }); + expect(d.missingCount).toBe(1); + }); + + it("a covering artifact WITHOUT a recorded comparison leaves the item unverified — visible, not blocked", () => { + const guardArt = artifact({ artifactType: "guard", verdict: "CLEAR", evidences: ["range probe 206"] }); + const d = composeDelivered(promised, [guardArt]); + expect(d.items[1]!.verified).toBe("unverified"); // non-QA artifact type can never verify + const qaNoSelfCheck = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["1"] }); + expect(composeDelivered(promised, [qaNoSelfCheck]).items[0]!.verified).toBe("unverified"); + }); + + it("QA's why-kicked-back note surfaces while a NON-PASSING comparison stays unverified", () => { + const kicked = artifact({ artifactType: "qa", verdict: "BLOCKING", evidences: ["1"], selfCheck: "mockup shows right drawer; build opens left — kicked back" }); + const d = composeDelivered(promised, [kicked]); + expect(d.items[0]!.verified).toBe("unverified"); + expect(d.items[0]!.note).toContain("kicked back"); + }); + + it("matches evidences refs by exact text as well as 1-based index", () => { + const byText = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["range probe 206"], selfCheck: "saw the 206" }); + const d = composeDelivered(promised, [byText]); + expect(d.items[1]!.verified).toBe("verified"); + expect(d.items[0]!.verified).toBe("missing"); + }); + + it("no contract declared -> zero items, zero missing (never trivially-sufficient, never an invented row)", () => { + const d = composeDelivered([], [artifact({ artifactType: "qa", verdict: "PASS", mediaRefs: ["x.png"] })]); + expect(d.items).toHaveLength(0); + expect(d.missingCount).toBe(0); + expect(d.extraProof).toEqual([{ kind: "image", src: "proof/x.png", caption: "x.png" }]); // §6: visible under its own label + }); + + it("unmapped-artifact media renders bounded as extraProof — shown, never a primary-view pile", () => { + const stray = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["something else entirely"], mediaRefs: ["stray.png"] }); + const d = composeDelivered(promised, [stray]); + expect(d.extraProof).toEqual([{ kind: "image", src: "proof/stray.png", caption: "stray.png" }]); + expect(d.items.every((it) => it.proof.length === 0)).toBe(true); + }); + + it("plannedRef: a markdown image on the contract line pairs the mockup with the deliverable", () => { + const prd = "## Proof contract\n\n- [ ] drawer opens right ![mockup](mockups/drawer.png)\n- [ ] range probe 206\n"; + const items = extractProofContract(prd); + expect(items[0]).toEqual({ text: "drawer opens right", plannedRef: "mockups/drawer.png" }); + const d = composeDelivered(items, []); + expect(d.items[0]!.promised.plannedRef).toEqual({ kind: "image", src: "mockups/drawer.png", caption: "mockups/drawer.png" }); + expect(d.items[1]!.promised.plannedRef).toBeUndefined(); + }); + + it("curated proof set is deduped and ordered latest-artifact-first", () => { + const older = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["1"], selfCheck: "v1", mediaRefs: ["a.png", "b.png"], droppedAt: "2026-07-04T08:00:00.000Z", relPath: "proof/qa-1.md" }); + const newer = artifact({ artifactType: "qa", verdict: "PASS", evidences: ["1"], selfCheck: "v2 — the canonical set", mediaRefs: ["b.png", "c.png"], droppedAt: "2026-07-04T09:30:00.000Z", relPath: "proof/qa-2.md" }); + const d = composeDelivered(promised, [older, newer]); + expect(d.items[0]!.proof.map((p) => p.src)).toEqual(["proof/b.png", "proof/c.png", "proof/a.png"]); + expect(d.items[0]!.note).toBe("v2 — the canonical set"); // latest recorded comparison wins the note + }); + + it("feeds the ▲ insufficient-proof signal from the delivered.items MISSING count (§11 re-bind)", () => { + const r = composeSliceReview(baseInputs()); // contract declared, nothing delivered + const insufficient = r.needsYou.items.find((i) => i.derived?.kind === "insufficient-proof"); + expect(insufficient).toBeDefined(); + expect(insufficient!.derived!.evidence).toContain("2 of 2"); + expect(insufficient!.derived!.threshold).toBe("delivered.items MISSING count > 0"); + }); + + it("proofDirPath is the drill-in door when proof/ exists, null when it does not", () => { + expect(composeSliceReview(baseInputs()).delivered.proofDirPath).toBe("release-t/slices/20-fixture/proof"); + expect(composeSliceReview(baseInputs({ proofDirExists: false })).delivered.proofDirPath).toBeNull(); + }); +}); + +describe("markdown structure + media extraction", () => { + it("extracts sections, mini-reqs, proof contracts, and media refs", () => { + const prd = baseInputs().prd!; + expect(extractMiniReqs(prd)).toContain("1. One surface."); + expect(extractProofContract(prd).map((p) => p.text)).toEqual(["phone journey video", "range probe 206"]); + expect(extractSection("## A\n\nbody a\n\n## B\n\nbody b", "B")).toBe("body b"); + expect(extractSection(null, "A")).toBeNull(); + expect(extractMediaRefs('x ![a](a.png) y