mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Compare commits
338 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350faa9ff5 | ||
|
|
dfae7911cc | ||
|
|
3851fea93e | ||
|
|
871101addc | ||
|
|
e04abf96a6 | ||
|
|
eee4dd9a2a | ||
|
|
1cdcd6ec60 | ||
|
|
6ab3feb35f | ||
|
|
0d9118ada2 | ||
|
|
7abe012f45 | ||
|
|
93ea9577d2 | ||
|
|
7cc8f253c7 | ||
|
|
49e25838e1 | ||
|
|
415f4077b9 | ||
|
|
c9db917422 | ||
|
|
d5428baec0 | ||
|
|
25be48454b | ||
|
|
2fa9b2ea89 | ||
|
|
3a022ddc44 | ||
|
|
dcdcb0f65d | ||
|
|
5493c022cd | ||
|
|
6e3b8bfe79 | ||
|
|
619d1bae16 | ||
|
|
87d203d2f5 | ||
|
|
ce9bc5c750 | ||
|
|
72ace38440 | ||
|
|
97ac04b968 | ||
|
|
2cbfd3b215 | ||
|
|
8ca792ad87 | ||
|
|
8a294be775 | ||
|
|
392e65d551 | ||
|
|
a38d42c005 | ||
|
|
36e079e83c | ||
|
|
c6fca49cf7 | ||
|
|
3bcf48fb5f | ||
|
|
454850db9e | ||
|
|
7fd9fd1360 | ||
|
|
f5e361ab8f | ||
|
|
06e1343958 | ||
|
|
0c1f5b35f1 | ||
|
|
0c0764c449 | ||
|
|
5a1e0d0d1a | ||
|
|
06d7fa61f8 | ||
|
|
14b6d591a5 | ||
|
|
2b28b51a73 | ||
|
|
2912e116c0 | ||
|
|
91623dba84 | ||
|
|
542d6dd760 | ||
|
|
2a581a451a | ||
|
|
93e946693d | ||
|
|
bd03e2e17c | ||
|
|
b1367aa33c | ||
|
|
9c533c4345 | ||
|
|
f3abf3642e | ||
|
|
61bf2163b5 | ||
|
|
e24b75cf3b | ||
|
|
1faff783ff | ||
|
|
eb12b67921 | ||
|
|
e7ae87f6d2 | ||
|
|
77a67f3e2d | ||
|
|
2cfb89396d | ||
|
|
ab61456774 | ||
|
|
6e84033f2e | ||
|
|
3917456bf6 | ||
|
|
da78f22fd6 | ||
|
|
e5aba49e61 | ||
|
|
0267be8dbe | ||
|
|
5c0c124860 | ||
|
|
d8b1872ba8 | ||
|
|
4401a88025 | ||
|
|
dc51cb629d | ||
|
|
53743869fc | ||
|
|
38845fab6e | ||
|
|
67915a5cee | ||
|
|
bfa8d9a686 | ||
|
|
b5483bea18 | ||
|
|
f1bbfffd11 | ||
|
|
f217f3fad8 | ||
|
|
9e46e0d10b | ||
|
|
baf599e007 | ||
|
|
2a1c987cc0 | ||
|
|
292652b0f4 | ||
|
|
b0fbecfe7d | ||
|
|
ab635669a4 | ||
|
|
276eeaac0e | ||
|
|
e858daf777 | ||
|
|
3e7ddaf758 | ||
|
|
407ffa5b9f | ||
|
|
2578fde055 | ||
|
|
7b61b9a778 | ||
|
|
7150ecc739 | ||
|
|
d8f1e0abf3 | ||
|
|
0338570cde | ||
|
|
71bfd482b7 | ||
|
|
aeff2aa278 | ||
|
|
e4d81b5cda | ||
|
|
528b348c59 | ||
|
|
886d349966 | ||
|
|
c38c94a43c | ||
|
|
d1625de22d | ||
|
|
c177b45cff | ||
|
|
472d1fc5a3 | ||
|
|
8f4955cb53 | ||
|
|
0876dea7eb | ||
|
|
e3fd3163df | ||
|
|
b6a77ec226 | ||
|
|
1e6869613b | ||
|
|
aa17223a06 | ||
|
|
8219fefa8d | ||
|
|
43d1b74c7f | ||
|
|
b19d161a03 | ||
|
|
ac7d574a23 | ||
|
|
17055b2cd3 | ||
|
|
7139ad73bd | ||
|
|
76dbfe8e2f | ||
|
|
3450f6034d | ||
|
|
c814bf7390 | ||
|
|
a7bae41805 | ||
|
|
b7ae9ecda9 | ||
|
|
4bf1b13299 | ||
|
|
e69bfe8c14 | ||
|
|
e15bd320e8 | ||
|
|
eb83f92be8 | ||
|
|
75943a0af7 | ||
|
|
bdca535027 | ||
|
|
ddde62e969 | ||
|
|
57dbd930bb | ||
|
|
8580e6202a | ||
|
|
49b03e373e | ||
|
|
baec6e8137 | ||
|
|
f860184f62 | ||
|
|
8b4a5d0e19 | ||
|
|
4a2ecc5bd1 | ||
|
|
b74c4db259 | ||
|
|
9cf0ccb96c | ||
|
|
461c1fbbbf | ||
|
|
34e4c8d13b | ||
|
|
085c492bba | ||
|
|
5bb58b1bbc | ||
|
|
abf407a8ac | ||
|
|
fae9ad32b7 | ||
|
|
8dc6bf32c9 | ||
|
|
880914b97b | ||
|
|
3ee290c0dc | ||
|
|
3c9426b831 | ||
|
|
c59a400406 | ||
|
|
4f8e5dc14f | ||
|
|
0d5ebb3a7a | ||
|
|
c91d7c5de3 | ||
|
|
1c5d44a2af | ||
|
|
dcd4e61f66 | ||
|
|
4aba9229c9 | ||
|
|
a7a59ae494 | ||
|
|
4ccec233ca | ||
|
|
9f03c0bc06 | ||
|
|
450f2df4a2 | ||
|
|
6e7bb4ffee | ||
|
|
5da7e7db82 | ||
|
|
3fffd5f81d | ||
|
|
c0c15a4497 | ||
|
|
f7aea9a4d4 | ||
|
|
74647256bc | ||
|
|
6de0ded9c6 | ||
|
|
c3da853006 | ||
|
|
508c45fcd3 | ||
|
|
bf60aabc4a | ||
|
|
5fc3fbdb5e | ||
|
|
3a73662b9c | ||
|
|
2f18229ff2 | ||
|
|
edcd33c9f5 | ||
|
|
b8590bbcb2 | ||
|
|
fb8a0b1a55 | ||
|
|
4ed08b32c1 | ||
|
|
25516df934 | ||
|
|
866afdfc57 | ||
|
|
32943b9b08 | ||
|
|
bebc6aa3d0 | ||
|
|
faeec2752a | ||
|
|
1c86d90386 | ||
|
|
19e95f0d80 | ||
|
|
d261ee605b | ||
|
|
311879be50 | ||
|
|
55b166f3bc | ||
|
|
2f9375a540 | ||
|
|
0df8ae8a32 | ||
|
|
989ae8c81e | ||
|
|
f5d1c2010c | ||
|
|
0dd8d57ac5 | ||
|
|
26eb23f784 | ||
|
|
0e9cfa75a2 | ||
|
|
f0f5af3f6f | ||
|
|
53c5ba2df3 | ||
|
|
20e56aeb59 | ||
|
|
02732bdc62 | ||
|
|
1bd43f9279 | ||
|
|
398fe7f67e | ||
|
|
c444ac009b | ||
|
|
3e55d130b0 | ||
|
|
6c1ecd04ba | ||
|
|
3a9b06caf8 | ||
|
|
37d256a3a6 | ||
|
|
b6967d8954 | ||
|
|
70e712975a | ||
|
|
4cc5891f82 | ||
|
|
9c15d348fa | ||
|
|
a21d1e9358 | ||
|
|
9a9db9b5b6 | ||
|
|
79992579ca | ||
|
|
ec13643603 | ||
|
|
6365a8eec4 | ||
|
|
8b3bf216cb | ||
|
|
f6ffa7c485 | ||
|
|
0fbd9ee428 | ||
|
|
6b9510c58f | ||
|
|
f8561d38fd | ||
|
|
3e759e10bc | ||
|
|
042d7586d7 | ||
|
|
c1be9f7322 | ||
|
|
2c76424b6a | ||
|
|
8904d6093f | ||
|
|
3b07a5cb4f | ||
|
|
b0f4ea88a2 | ||
|
|
b9b6273cf5 | ||
|
|
81730a451a | ||
|
|
f5333fd271 | ||
|
|
54417357f9 | ||
|
|
32517e4df6 | ||
|
|
0f8506b4ce | ||
|
|
3a19eedf88 | ||
|
|
ce0837a3e6 | ||
|
|
57e72d425f | ||
|
|
9f51d78c50 | ||
|
|
e6f21a849d | ||
|
|
74f8464d17 | ||
|
|
55e621d147 | ||
|
|
d37106316d | ||
|
|
f3ddfb4258 | ||
|
|
772b9c9b90 | ||
|
|
694471af5d | ||
|
|
f60f7b7742 | ||
|
|
c205ad2e4e | ||
|
|
b704ab92d0 | ||
|
|
566ec0f7fe | ||
|
|
a42dc62ba4 | ||
|
|
6f020263c0 | ||
|
|
44d1611741 | ||
|
|
bd6b0932a6 | ||
|
|
61f46b300a | ||
|
|
82b83ad284 | ||
|
|
d12e6f5f67 | ||
|
|
e6556370b6 | ||
|
|
7ff4acba8e | ||
|
|
edf61053a4 | ||
|
|
d4c5b65859 | ||
|
|
273c685de7 | ||
|
|
4460ec07ee | ||
|
|
9754765987 | ||
|
|
d082740a09 | ||
|
|
806f56d936 | ||
|
|
98267c79ee | ||
|
|
4b84eccd1b | ||
|
|
52b5ffce8a | ||
|
|
eb1817aa74 | ||
|
|
682022ed77 | ||
|
|
5819082664 | ||
|
|
e834cf8c3e | ||
|
|
f7de4cf7db | ||
|
|
fd7a62700c | ||
|
|
e781d30842 | ||
|
|
100e018e05 | ||
|
|
4d7c29b73a | ||
|
|
30840a7bee | ||
|
|
a58496839d | ||
|
|
999acb6317 | ||
|
|
5329612f7a | ||
|
|
5791622620 | ||
|
|
489020a22d | ||
|
|
a24fe4e4ad | ||
|
|
70a83e3539 | ||
|
|
b3eda417ae | ||
|
|
84ace3e9de | ||
|
|
fe540f68fc | ||
|
|
2b174fd392 | ||
|
|
cf03145353 | ||
|
|
6c043c76d4 | ||
|
|
5e8d1d65be | ||
|
|
a819808ef8 | ||
|
|
3c9065b8ab | ||
|
|
013c2b5383 | ||
|
|
5bd4377f32 | ||
|
|
7950fec5f4 | ||
|
|
b9a648afe7 | ||
|
|
13fc8ac083 | ||
|
|
1b29b2eb24 | ||
|
|
9abd06dd0e | ||
|
|
5b363303c4 | ||
|
|
fbff5b5c40 | ||
|
|
c6eed92631 | ||
|
|
490203dcf5 | ||
|
|
c99b7c8e61 | ||
|
|
1bbd3739e0 | ||
|
|
ca2dd7ef23 | ||
|
|
43c21b71a4 | ||
|
|
4a0c66d331 | ||
|
|
8686803350 | ||
|
|
398d6024af | ||
|
|
044e0c0ba3 | ||
|
|
8d7d0b7fc2 | ||
|
|
881af11685 | ||
|
|
ac0687ac72 | ||
|
|
861dcf0ac3 | ||
|
|
98f4b25991 | ||
|
|
21600c5b36 | ||
|
|
4e1e2fdca3 | ||
|
|
2bd5e6fd65 | ||
|
|
31359ebebf | ||
|
|
cf6a63548f | ||
|
|
aa062d79b2 | ||
|
|
261a591680 | ||
|
|
30db3d2f75 | ||
|
|
8b0ec6836b | ||
|
|
ccdd55ce0b | ||
|
|
0739f7f060 | ||
|
|
7eab67bf8e | ||
|
|
5c879fb2b3 | ||
|
|
9a9fe0b162 | ||
|
|
f82741fde4 | ||
|
|
b0ce5c2615 | ||
|
|
5d34e48c27 | ||
|
|
d3fed82a61 | ||
|
|
cb98addc77 | ||
|
|
ea2fde6e2c | ||
|
|
dd7da4218d | ||
|
|
710c7df7e8 | ||
|
|
30e84cee7f | ||
|
|
b4d06efe8d | ||
|
|
d683b524bb | ||
|
|
9e1d151afd |
1102 changed files with 247304 additions and 19225 deletions
|
|
@ -7,6 +7,11 @@ node_modules
|
|||
server/dist
|
||||
web/dist
|
||||
shared/dist
|
||||
desktop/.desktop-release
|
||||
desktop/release
|
||||
.veritas-desktop-dev
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Git
|
||||
.git
|
||||
|
|
@ -45,9 +50,6 @@ LICENSE
|
|||
coverage
|
||||
|
||||
# Development tools
|
||||
cli/
|
||||
mcp/
|
||||
scripts/
|
||||
tasks/
|
||||
eslint.config.js
|
||||
.prettierrc
|
||||
|
|
|
|||
24
.env.example
24
.env.example
|
|
@ -47,12 +47,26 @@ VERITAS_ADMIN_KEY=
|
|||
# Default: <cwd>/.veritas-kanban
|
||||
# VERITAS_DATA_DIR=
|
||||
|
||||
# Storage backend and optional SQLite location.
|
||||
# VERITAS_STORAGE=file
|
||||
# VERITAS_SQLITE_PATH=
|
||||
|
||||
# Required only for governed SQLite DELETE compatibility or expert override mode.
|
||||
# Use a stable, unique value for this host; do not reuse it on another machine.
|
||||
# VERITAS_SQLITE_TOPOLOGY=single-host
|
||||
# VERITAS_SQLITE_HOST_ID=replace-with-stable-host-identity
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket.
|
||||
# Default (dev): http://localhost:5173,http://localhost:3000,
|
||||
# http://127.0.0.1:5173,http://127.0.0.1:3000
|
||||
# CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Optional operator HTTP proxy for selective run-scoped egress.
|
||||
# Destination policy is still evaluated and the pinned IP is sent through CONNECT.
|
||||
# Credentials are held in memory and are not persisted in launch or telemetry evidence.
|
||||
# VERITAS_EGRESS_UPSTREAM_PROXY=http://proxy-user:proxy-password@proxy.internal:3128
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
# Pino log level: fatal | error | warn | info | debug | trace | silent
|
||||
# LOG_LEVEL=info
|
||||
|
|
@ -81,5 +95,13 @@ VERITAS_ADMIN_KEY=
|
|||
# PROMETHEUS_METRICS_PUBLIC=false
|
||||
|
||||
# ── External Services ────────────────────────────────────────
|
||||
# Clawdbot gateway URL (default: http://127.0.0.1:18789)
|
||||
# OpenClaw gateway URL (default: http://127.0.0.1:18789) and optional bearer token.
|
||||
# OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789
|
||||
# OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional operator-declared OpenClaw version hint for provider runtime manifests.
|
||||
# This does not count as runtime-verified evidence; host registration must verify it.
|
||||
# OPENCLAW_GATEWAY_VERSION=
|
||||
|
||||
# Legacy gateway URL alias.
|
||||
# CLAWDBOT_GATEWAY=http://127.0.0.1:18789
|
||||
|
|
|
|||
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
A clear and concise description of what this PR does.
|
||||
|
||||
## Scope
|
||||
|
||||
**Linked issue:** Closes #
|
||||
|
||||
**In scope:** One independently shippable behavior and the documentation needed
|
||||
to use it.
|
||||
|
||||
**Linked follow-ups:** List separable UI, integration, refactor, or hardening
|
||||
work that was intentionally kept out of this PR, or write `None`.
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
|
|
@ -15,6 +25,15 @@ A clear and concise description of what this PR does.
|
|||
**How has this been tested?**
|
||||
Describe the tests you ran to verify your changes. Provide instructions so reviewers can reproduce.
|
||||
|
||||
**Verification tier:**
|
||||
|
||||
- [ ] Documentation or static checks only
|
||||
- [ ] Explicit focused diagnostic (manual workflow dispatch)
|
||||
- [ ] Full milestone gate (`ci:full`, critical security, integration, or release)
|
||||
|
||||
**Why this tier is sufficient:** Explain the changed behavior, covered failure
|
||||
modes, and why broader gates are or are not required.
|
||||
|
||||
**Test commands:**
|
||||
|
||||
```bash
|
||||
|
|
@ -23,9 +42,13 @@ Describe the tests you ran to verify your changes. Provide instructions so revie
|
|||
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR contains one coherent, independently shippable behavior
|
||||
- [ ] Separable follow-up work is linked instead of folded into this PR
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have added or updated coverage for the next declared test milestone
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] Any breaking changes have been documented in the PR description
|
||||
- [ ] I did not rerun unchanged passing gates after documentation or formatting-only edits
|
||||
- [ ] Optional desktop, artifact, and release workflows are marked relevant only when this PR touches their product boundary
|
||||
|
|
|
|||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
|
@ -12,6 +12,12 @@ updates:
|
|||
- 'BradGroux'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
ignore:
|
||||
# jsdom 30 requires Node >=22.22.2 and currently breaks the Mantine UI suite.
|
||||
# Keep receiving jsdom 29 patches until the runtime floor is deliberately raised.
|
||||
- dependency-name: 'jsdom'
|
||||
update-types:
|
||||
- 'version-update:semver-major'
|
||||
groups:
|
||||
# Group minor/patch updates to reduce PR noise
|
||||
production-dependencies:
|
||||
|
|
|
|||
457
.github/workflows/ci.yml
vendored
457
.github/workflows/ci.yml
vendored
|
|
@ -5,25 +5,157 @@ on:
|
|||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_scope:
|
||||
description: Unit-test verification tier
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options:
|
||||
- focused
|
||||
- full
|
||||
base_sha:
|
||||
description: Optional base commit for a focused run (defaults to HEAD^)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: >-
|
||||
${{
|
||||
format(
|
||||
'{0}-{1}-{2}',
|
||||
github.workflow,
|
||||
github.ref,
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(fromJSON('["labeled","unlabeled"]'), github.event.action) &&
|
||||
github.event.label.name != 'ci:full' &&
|
||||
format('cosmetic-{0}', github.run_id) ||
|
||||
'authoritative'
|
||||
)
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
# ─── Deterministic Test Scope ───────────────────────────────────
|
||||
select-tests:
|
||||
name: Select Test Scope
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
scope: ${{ steps.scope.outputs.scope }}
|
||||
packages: ${{ steps.scope.outputs.packages }}
|
||||
base_sha: ${{ steps.scope.outputs.base_sha }}
|
||||
diff_range: ${{ steps.scope.outputs.diff_range }}
|
||||
reason: ${{ steps.scope.outputs.reason }}
|
||||
coverage_packages: ${{ steps.scope.outputs.coverage_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Verify CI scope controls
|
||||
run: >-
|
||||
node --test
|
||||
scripts/check-actions-pinned.test.mjs
|
||||
scripts/check-delivery-cadence.test.mjs
|
||||
scripts/check-security-gates.test.mjs
|
||||
scripts/check-tracked-ignore.test.mjs
|
||||
scripts/select-ci-test-scope.test.mjs
|
||||
|
||||
- name: Guard delivery cadence
|
||||
run: node scripts/check-delivery-cadence.mjs
|
||||
|
||||
- name: Guard immutable GitHub Actions references
|
||||
run: node scripts/check-actions-pinned.mjs
|
||||
|
||||
- name: Guard continuous security gates
|
||||
run: node scripts/check-security-gates.mjs
|
||||
|
||||
- name: Reject tracked files covered by ignore rules
|
||||
run: node scripts/check-tracked-ignore.mjs
|
||||
|
||||
- name: Select verification tier
|
||||
id: scope
|
||||
shell: bash
|
||||
env:
|
||||
CI_EVENT_NAME: ${{ github.event_name }}
|
||||
CI_MANUAL_SCOPE: ${{ inputs.test_scope || '' }}
|
||||
CI_PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before || '' }}
|
||||
DISPATCH_BASE_SHA: ${{ inputs.base_sha || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$CI_EVENT_NAME" in
|
||||
pull_request)
|
||||
CI_BASE_SHA="$PR_BASE_SHA"
|
||||
CI_HEAD_SHA="$PR_HEAD_SHA"
|
||||
;;
|
||||
push)
|
||||
CI_BASE_SHA="$PUSH_BEFORE_SHA"
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
if [[ "$CI_BASE_SHA" =~ ^0+$ ]]; then
|
||||
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
|
||||
fi
|
||||
;;
|
||||
workflow_dispatch)
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
if [[ -n "$DISPATCH_BASE_SHA" ]]; then
|
||||
if [[ ! "$DISPATCH_BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
echo "::error::base_sha must be a 7-40 character hexadecimal commit ID"
|
||||
exit 1
|
||||
fi
|
||||
CI_BASE_SHA="$(git rev-parse --verify "${DISPATCH_BASE_SHA}^{commit}")"
|
||||
else
|
||||
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
|
||||
fi
|
||||
;;
|
||||
schedule)
|
||||
CI_BASE_SHA="$GITHUB_SHA"
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported CI event: $CI_EVENT_NAME"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export CI_BASE_SHA CI_HEAD_SHA
|
||||
node scripts/select-ci-test-scope.mjs
|
||||
|
||||
# ─── Lint & Type Check ───────────────────────────────────────────
|
||||
lint-and-typecheck:
|
||||
name: Lint & Type Check
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -34,6 +166,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check service filesystem boundary
|
||||
run: pnpm check:service-filesystem-boundary
|
||||
|
||||
- name: Build shared (dependency for typecheck)
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
|
|
@ -49,42 +184,320 @@ jobs:
|
|||
- name: Type check all packages
|
||||
run: pnpm typecheck
|
||||
|
||||
# ─── Workspace Unit Tests ────────────────────────────────────────
|
||||
test-workspace:
|
||||
name: Workspace Unit Tests
|
||||
# ─── Focused Related Tests ──────────────────────────────────────
|
||||
test-changed:
|
||||
name: Changed Tests
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
|
||||
echo "::error::Select Test Scope returned an invalid scope"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SELECTED_SCOPE" == "focused" && -z "$SELECTED_PACKAGES" ]]; then
|
||||
echo "::error::Focused scope requires at least one workspace"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared test dependency
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Run related tests in affected workspaces
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
env:
|
||||
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
|
||||
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
|
||||
VERITAS_DISABLE_WATCHERS: '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
IFS=',' read -r -a packages <<< "$SELECTED_PACKAGES"
|
||||
executed_packages=()
|
||||
{
|
||||
echo "### Changed Tests"
|
||||
echo
|
||||
echo "- Diff range: \`$DIFF_RANGE\`"
|
||||
echo "- Selected workspaces: \`$SELECTED_PACKAGES\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
for package_name in "${packages[@]}"; do
|
||||
related_files=()
|
||||
while IFS= read -r changed_file; do
|
||||
related_files+=("./${changed_file#"$package_name/"}")
|
||||
done < <(
|
||||
git diff --name-only --diff-filter=ACMR "$DIFF_RANGE" -- "$package_name/"
|
||||
)
|
||||
|
||||
if (( ${#related_files[@]} == 0 )); then
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$package_name" in
|
||||
server|web|cli|mcp)
|
||||
package_filter="@veritas-kanban/${package_name}"
|
||||
extra_args=()
|
||||
if [[ "$package_name" == "web" ]]; then
|
||||
extra_args+=(--testTimeout 15000)
|
||||
fi
|
||||
;;
|
||||
desktop)
|
||||
package_filter="@veritas-kanban/desktop"
|
||||
extra_args=(--config vitest.config.ts)
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown selected workspace: $package_name"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
pnpm --filter "$package_filter" exec vitest related \
|
||||
--run \
|
||||
--maxWorkers=4 \
|
||||
--passWithNoTests \
|
||||
"${extra_args[@]}" \
|
||||
"${related_files[@]}"
|
||||
executed_packages+=("$package_name")
|
||||
done
|
||||
|
||||
{
|
||||
if (( ${#executed_packages[@]} > 0 )); then
|
||||
echo "- Related coverage executed for: \`${executed_packages[*]}\`"
|
||||
else
|
||||
echo "- No added, copied, modified, or renamed workspace inputs required related coverage."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record focused-tier skip
|
||||
if: needs.select-tests.outputs.scope != 'focused'
|
||||
env:
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
run: |
|
||||
{
|
||||
echo "### Changed Tests"
|
||||
echo
|
||||
echo "- Decision: skipped related coverage because scope is \`$SELECTED_SCOPE\`."
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Full Workspace Unit Tests ───────────────────────────────────
|
||||
test-workspace:
|
||||
name: Workspace Unit Tests
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
|
||||
echo "::error::Select Test Scope returned an invalid scope"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared (dependency for workspace tests)
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Run workspace unit tests
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Run desktop readiness regression tests
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm desktop:test:readiness
|
||||
|
||||
- name: Run dual-storage parity tests
|
||||
run: pnpm --filter @veritas-kanban/server test -- src/__tests__/storage/dual-storage-parity.test.ts
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: >-
|
||||
pnpm --filter @veritas-kanban/server exec vitest run
|
||||
src/__tests__/storage/dual-storage-parity.test.ts
|
||||
|
||||
- name: Record full-suite evidence
|
||||
if: >-
|
||||
always() &&
|
||||
needs.select-tests.result == 'success' &&
|
||||
needs.select-tests.outputs.scope == 'full'
|
||||
env:
|
||||
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
CURRENT_JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
{
|
||||
echo "### Workspace Unit Tests"
|
||||
echo
|
||||
echo "- Diff range: \`${DIFF_RANGE:-not required}\`"
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
echo "- Workflow checkout SHA: \`$GITHUB_SHA\`"
|
||||
echo "- Current job status: \`$CURRENT_JOB_STATUS\`"
|
||||
echo "- Unit-test workspaces: \`server, web, cli, mcp\`"
|
||||
echo "- Workspace workers: \`4 maximum per Vitest project\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record full-tier skip
|
||||
if: needs.select-tests.outputs.scope != 'full'
|
||||
env:
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
run: |
|
||||
{
|
||||
echo "### Workspace Unit Tests"
|
||||
echo
|
||||
echo "- Decision: skipped the complete suite because scope is \`$SELECTED_SCOPE\`."
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Critical-path Coverage ─────────────────────────────────────
|
||||
critical-path-coverage:
|
||||
name: Critical Path Coverage
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
with:
|
||||
# Policy downgrade and changed-critical-file checks compare against the event base SHA.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify coverage policy
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
env:
|
||||
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
|
||||
run: pnpm check:coverage-policy
|
||||
|
||||
- name: Measure and ratchet critical paths
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
env:
|
||||
COVERAGE_PACKAGES: ${{ needs.select-tests.outputs.coverage_packages }}
|
||||
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
|
||||
run: pnpm test:coverage --packages "$COVERAGE_PACKAGES"
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: always() && needs.select-tests.outputs.coverage_packages != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: critical-path-coverage-${{ github.sha }}
|
||||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Record coverage skip
|
||||
if: needs.select-tests.outputs.coverage_packages == ''
|
||||
run: |
|
||||
{
|
||||
echo "### Critical-path coverage ratchets"
|
||||
echo
|
||||
echo "No governed critical-path package changed in this verification scope."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -95,6 +508,9 @@ jobs:
|
|||
- name: Build shared (dependency for all builds)
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Verify native Vite config loading
|
||||
run: pnpm check:vite-native-config
|
||||
|
||||
- name: Build all packages
|
||||
run: pnpm build
|
||||
|
||||
|
|
@ -129,13 +545,17 @@ jobs:
|
|||
# ─── Security Audit ──────────────────────────────────────────────
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -143,6 +563,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Reject tracked runtime security configuration
|
||||
run: pnpm check:security-artifacts
|
||||
|
||||
- name: Audit production dependencies (blocks on high/critical)
|
||||
run: pnpm audit --prod --audit-level=high
|
||||
|
||||
|
|
|
|||
74
.github/workflows/desktop-artifacts.yml
vendored
74
.github/workflows/desktop-artifacts.yml
vendored
|
|
@ -3,18 +3,7 @@ name: Desktop Artifacts
|
|||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- 'server/**'
|
||||
- 'web/**'
|
||||
- 'shared/**'
|
||||
- 'docs/DESKTOP-RELEASE.md'
|
||||
- 'scripts/desktop-after-pack.mjs'
|
||||
- 'scripts/prepare-desktop-release.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.github/workflows/desktop-artifacts.yml'
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -27,13 +16,25 @@ env:
|
|||
jobs:
|
||||
mac-unsigned:
|
||||
name: Unsigned macOS Artifact
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned macOS artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -54,7 +55,7 @@ jobs:
|
|||
run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-mac-unsigned
|
||||
path: |
|
||||
|
|
@ -67,13 +68,25 @@ jobs:
|
|||
|
||||
linux-unsigned:
|
||||
name: Unsigned Linux Artifacts
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned Linux artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -97,7 +110,7 @@ jobs:
|
|||
run: node ./node_modules/electron-builder/cli.js --linux AppImage deb rpm --x64 --publish never
|
||||
|
||||
- name: Upload Linux preview desktop artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-linux-unsigned
|
||||
path: |
|
||||
|
|
@ -111,13 +124,26 @@ jobs:
|
|||
|
||||
windows-unsigned:
|
||||
name: Unsigned Windows Artifacts
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Record milestone selection
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned Windows artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -138,7 +164,7 @@ jobs:
|
|||
run: node ./node_modules/electron-builder/cli.js --win nsis zip --x64 --publish never
|
||||
|
||||
- name: Upload Windows preview desktop artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-windows-unsigned
|
||||
path: |
|
||||
|
|
|
|||
129
.github/workflows/desktop-release.yml
vendored
129
.github/workflows/desktop-release.yml
vendored
|
|
@ -24,6 +24,7 @@ concurrency:
|
|||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
VERITAS_BUILD_SHA: ${{ github.sha }}
|
||||
VERITAS_UPDATE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -31,31 +32,81 @@ jobs:
|
|||
name: Signed and Notarized macOS Artifact
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Verify signing secrets are configured
|
||||
- name: Validate published release body
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pnpm validate:release -- --version "${version}" --github --skip-build-output
|
||||
|
||||
- name: Verify signing and notarization secrets are configured
|
||||
id: notarization-secrets
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
missing=0
|
||||
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_API_KEY_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER; do
|
||||
errors=0
|
||||
|
||||
for name in CSC_LINK CSC_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required for signed/notarized desktop releases"
|
||||
missing=1
|
||||
errors=1
|
||||
fi
|
||||
done
|
||||
exit "$missing"
|
||||
|
||||
api_key_count=0
|
||||
for name in APPLE_API_KEY_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER; do
|
||||
if [ -n "${!name}" ]; then
|
||||
api_key_count=$((api_key_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
apple_id_count=0
|
||||
for name in APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -n "${!name}" ]; then
|
||||
apple_id_count=$((apple_id_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${api_key_count}" -gt 0 ] && [ "${api_key_count}" -lt 3 ]; then
|
||||
echo "::error::App Store Connect API-key notarization requires APPLE_API_KEY_BASE64, APPLE_API_KEY_ID, and APPLE_API_ISSUER together"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
if [ "${apple_id_count}" -gt 0 ] && [ "${apple_id_count}" -lt 3 ]; then
|
||||
echo "::error::Apple ID notarization requires APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID together"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
if [ "${api_key_count}" -eq 3 ] && [ "${apple_id_count}" -eq 3 ]; then
|
||||
echo "::error::Configure exactly one notarization credential set, not both App Store Connect API-key and Apple ID credentials"
|
||||
errors=1
|
||||
elif [ "${api_key_count}" -eq 3 ]; then
|
||||
echo "notarization_mode=api-key" >> "${GITHUB_OUTPUT}"
|
||||
elif [ "${apple_id_count}" -eq 3 ]; then
|
||||
echo "notarization_mode=apple-id" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "::error::One complete notarization credential set is required: App Store Connect API key or Apple ID/app-specific password"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
exit "${errors}"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
@ -68,6 +119,7 @@ jobs:
|
|||
|
||||
- name: Write App Store Connect API key
|
||||
id: notary-key
|
||||
if: steps.notarization-secrets.outputs.notarization_mode == 'api-key'
|
||||
env:
|
||||
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||
run: |
|
||||
|
|
@ -82,7 +134,7 @@ jobs:
|
|||
|
||||
echo "key_path=${key_path}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Build, sign, notarize, and publish macOS artifacts
|
||||
- name: Build, sign, notarize, and stage macOS artifacts
|
||||
working-directory: desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -91,4 +143,63 @@ jobs:
|
|||
APPLE_API_KEY: ${{ steps.notary-key.outputs.key_path }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish always
|
||||
|
||||
- name: Finalize notarized macOS release assets
|
||||
env:
|
||||
APPLE_API_KEY: ${{ steps.notary-key.outputs.key_path }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: node scripts/finalize-macos-release-assets.mjs
|
||||
|
||||
- name: Upload macOS release assets
|
||||
working-directory: desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
tag="v${version}"
|
||||
dmg="release/Veritas-Kanban-${version}-mac-arm64.dmg"
|
||||
zip="release/Veritas-Kanban-${version}-mac-arm64.zip"
|
||||
|
||||
for file in "${dmg}" "${zip}"; do
|
||||
if [ ! -f "${file}" ]; then
|
||||
echo "::error::Expected release asset missing: ${file}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${file}" = "${dmg}" ]; then
|
||||
codesign --verify --verbose=2 "${file}"
|
||||
spctl -a -vvv -t open --context context:primary-signature "${file}"
|
||||
fi
|
||||
|
||||
checksum="$(shasum -a 256 "${file}" | awk '{print $1}')"
|
||||
printf '%s %s\n' "${checksum}" "$(basename "${file}")" > "${file}.sha256"
|
||||
done
|
||||
|
||||
assets=(
|
||||
"release/latest-mac.yml"
|
||||
"${dmg}"
|
||||
"${dmg}.blockmap"
|
||||
"${dmg}.sha256"
|
||||
"${zip}"
|
||||
"${zip}.blockmap"
|
||||
"${zip}.sha256"
|
||||
)
|
||||
|
||||
for file in "${assets[@]}"; do
|
||||
if [ ! -f "${file}" ]; then
|
||||
echo "::error::Expected release asset missing: ${file}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
gh release upload "${tag}" "${assets[@]}" --clobber
|
||||
|
|
|
|||
39
.github/workflows/docker-image.yml
vendored
Normal file
39
.github/workflows/docker-image.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Docker Image Contract
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
name: Build, Size, and Runtime Contract
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Docker image milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build production image
|
||||
run: docker build --target production --tag veritas-kanban:contract .
|
||||
|
||||
- name: Enforce image and runtime contract
|
||||
run: node scripts/check-docker-image.mjs veritas-kanban:contract
|
||||
51
.github/workflows/scheduled-qa.yml
vendored
51
.github/workflows/scheduled-qa.yml
vendored
|
|
@ -1,6 +1,9 @@
|
|||
name: Scheduled QA
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
schedule:
|
||||
- cron: '17 8 * * 1'
|
||||
workflow_dispatch:
|
||||
|
|
@ -29,18 +32,33 @@ env:
|
|||
VERITAS_AUTH_LOCALHOST_BYPASS: 'true'
|
||||
VERITAS_AUTH_LOCALHOST_ROLE: admin
|
||||
VERITAS_JWT_SECRET: scheduled-qa-jwt-secret-00000000000000000000000000000000
|
||||
VERITAS_STORAGE: sqlite
|
||||
RATE_LIMIT_MAX: '100000'
|
||||
RATE_LIMIT_WRITE_MAX: '100000'
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: Playwright E2E
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Playwright E2E milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\`, scheduled, or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -56,8 +74,8 @@ jobs:
|
|||
- name: Build shared package
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Install Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
env:
|
||||
|
|
@ -67,7 +85,7 @@ jobs:
|
|||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
|
|
@ -78,16 +96,29 @@ jobs:
|
|||
|
||||
k6:
|
||||
name: k6 Load Smoke
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
K6_PROFILE: ${{ github.event_name == 'workflow_dispatch' && inputs.load_profile || 'smoke' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### k6 milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Profile: \`$K6_PROFILE\`"
|
||||
echo "- Reason: explicit \`ci:full\`, scheduled, or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -162,7 +193,7 @@ jobs:
|
|||
|
||||
- name: Upload k6 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: k6-artifacts
|
||||
path: |
|
||||
|
|
|
|||
65
.github/workflows/security.yml
vendored
Normal file
65
.github/workflows/security.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Security Gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 9 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
queries: security-extended
|
||||
- name: Analyze JavaScript and TypeScript
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: '/language:javascript-typescript'
|
||||
|
||||
gitleaks:
|
||||
name: Gitleaks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GITLEAKS_VERSION: 8.30.1
|
||||
GITLEAKS_LINUX_X64_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Download verified gitleaks release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/gitleaks.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
--output "$archive"
|
||||
echo "${GITLEAKS_LINUX_X64_SHA256} ${archive}" | sha256sum --check --status
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks
|
||||
- name: Guard security workflow policy
|
||||
run: pnpm check:security-gates
|
||||
- name: Scan reviewed tree and test detection
|
||||
env:
|
||||
GITLEAKS_BIN: ${{ runner.temp }}/gitleaks
|
||||
run: pnpm check:gitleaks
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -44,6 +44,8 @@ tasks/archive/*.md
|
|||
tasks/backlog/*.md
|
||||
tasks/attachments/
|
||||
tasks/archive-attachments/
|
||||
/storage/
|
||||
/server/storage/
|
||||
.veritas-kanban/*
|
||||
!.veritas-kanban/.gitkeep
|
||||
.veritas-desktop-dev/
|
||||
|
|
@ -64,6 +66,7 @@ coverage/
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.veritas-kanban/worktrees/
|
||||
.veritas-kanban/worktree-manifests/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
|
@ -107,7 +110,7 @@ tasks/
|
|||
!tasks/
|
||||
!tasks/examples/
|
||||
!tasks/examples/*.md
|
||||
.veritas-kanban/
|
||||
/.veritas-kanban/
|
||||
|
||||
# Local security middleware (not shared)
|
||||
server/src/middleware/external-api-key.ts
|
||||
|
|
|
|||
|
|
@ -1,22 +1,53 @@
|
|||
# Gitleaks False Positives
|
||||
# Updated: 2026-01-29 (post-history-rewrite)
|
||||
# All entries below are placeholder/example/test values, NOT real secrets.
|
||||
# CLI snapshot uses an intentionally synthetic API key in serialized output.
|
||||
cli/src/__tests__/snapshot.test.ts:generic-api-key:220
|
||||
|
||||
# Documentation example: "your-admin-key" placeholder in deployment guide
|
||||
39423f74cf3849684e8de4ebf746156a6be0ea00:docs/DEPLOYMENT.md:curl-auth-header:557
|
||||
# API documentation contains non-functional response examples.
|
||||
docs/API-REFERENCE.md:generic-api-key:991
|
||||
docs/API-WORKFLOWS.md:generic-api-key:1460
|
||||
|
||||
# Documentation example: "dev-admin-key" placeholder in security audit
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:docs/SECURITY_AUDIT_2026-01-28.md:curl-auth-header:135
|
||||
# Operator documentation uses placeholders in curl authentication examples.
|
||||
docs/DEPLOYMENT.md:curl-auth-header:926
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:230
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:258
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:261
|
||||
docs/features/prd-driven-development.md:curl-auth-header:95
|
||||
docs/features/prd-driven-development.md:curl-auth-header:775
|
||||
docs/guides/SELF_HOST.md:curl-auth-header:742
|
||||
docs/security.md:curl-auth-header:51
|
||||
docs/security.md:curl-auth-header:58
|
||||
|
||||
# Test fixture: hardcoded test JWT secret (not used in production)
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:28
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:60
|
||||
# Demo seeding passes the operator-provided key variable to curl.
|
||||
seed-demo-data.sh:curl-auth-header:45
|
||||
|
||||
# .env.example placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:43
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:46
|
||||
# Environment template documents shell-variable authentication examples.
|
||||
server/.env.example:curl-auth-header:127
|
||||
server/.env.example:curl-auth-header:130
|
||||
|
||||
# Documentation placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:40
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:47
|
||||
# Compatibility test verifies redaction of a deliberately synthetic value.
|
||||
server/src/__tests__/buzz-compatibility-service.test.ts:generic-api-key:477
|
||||
|
||||
# Governance trace test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/governance-trace-service.test.ts:stripe-access-token:22
|
||||
|
||||
# Log redaction tests require JWT- and Stripe-shaped synthetic fixtures.
|
||||
server/src/__tests__/log-redaction.test.ts:jwt:17
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:30
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:31
|
||||
|
||||
# Completion service test verifies JWT-shaped output redaction.
|
||||
server/src/__tests__/provider-completion-service.test.ts:jwt:314
|
||||
|
||||
# Local admission tests use synthetic idempotency keys, not credentials.
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:606
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:619
|
||||
|
||||
# Authentication route tests require a synthetic JWT signing value.
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:31
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:80
|
||||
|
||||
# Skill capability test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/skill-capability-service.test.ts:stripe-access-token:63
|
||||
|
||||
# Multi-user UI test renders a non-secret token prefix fixture.
|
||||
web/src/__tests__/multi-user-tab.test.tsx:generic-api-key:123
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
pnpm check:security-artifacts
|
||||
pnpm check:actions-pinned
|
||||
pnpm check:tracked-ignore
|
||||
node scripts/check-delivery-cadence.mjs
|
||||
npx lint-staged
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Pre-commit hooks for veritas-kanban
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
# Or standalone gitleaks hook (no pre-commit framework needed):
|
||||
# gitleaks protect --staged --verbose
|
||||
# Or scan the reviewed tree without the pre-commit framework:
|
||||
# gitleaks dir . --redact=100
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
|
|
|||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm-lock.yaml
|
||||
526
AGENTS.md
Normal file
526
AGENTS.md
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
# AGENTS.md — Canonical Agent Instructions for Veritas Kanban
|
||||
|
||||
> **Canonical source.** Contributors and harnesses with repository-instruction discovery read
|
||||
> this file first. Every Veritas-managed run also receives an immutable task envelope; do not
|
||||
> assume a provider that disables custom instructions reads repository files implicitly.
|
||||
> Harness-specific supplements (for example `CLAUDE.md`) extend, never duplicate or contradict,
|
||||
> these rules. See `docs/AGENTS-TEMPLATE.md` for the managed-run and external-agent protocols.
|
||||
>
|
||||
> **Version:** 6.1.2
|
||||
> **Freshness policy:** update within two working days of any toolchain or architecture change.
|
||||
> Stale fields (package manager, Node version, provider list, test commands) are caught by
|
||||
> `pnpm check:pnpm-settings` and the smoke-test CI job.
|
||||
|
||||
---
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
| Tool | Required version | How to verify |
|
||||
| ------- | ---------------- | ---------------- |
|
||||
| Node.js | ≥ 22.22.1 | `node --version` |
|
||||
| pnpm | ≥ 11.0.0 | `pnpm --version` |
|
||||
| Git | ≥ 2.38 | `git --version` |
|
||||
|
||||
The `packageManager` field in `package.json` is pinned to `pnpm@11.1.1`. Do not install with npm
|
||||
or yarn. Do not up-rev the pin without updating this file.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
veritas-kanban/
|
||||
├── server/ Express + TypeScript API, agent orchestration, storage
|
||||
├── web/ React + Vite SPA
|
||||
├── cli/ Commander.js CLI (mirrors API endpoints)
|
||||
├── shared/ Shared TypeScript types and utilities
|
||||
├── mcp/ MCP server
|
||||
├── desktop/ Electron desktop wrapper
|
||||
├── docs/ Operator and developer documentation
|
||||
├── prompt-registry/ Prompt templates and optional review workflows
|
||||
└── .veritas-kanban/ Runtime data: agent-registry, logs, telemetry
|
||||
```
|
||||
|
||||
Workspaces are declared in `pnpm-workspace.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Essential commands
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pnpm install
|
||||
|
||||
# Build (all workspaces in dependency order)
|
||||
pnpm build
|
||||
|
||||
# Dev server (server + web, hot-reload)
|
||||
pnpm dev
|
||||
|
||||
# Tests
|
||||
pnpm test # Canonical sequential workspace unit gate
|
||||
pnpm test:unit # Shared build, then server, web, CLI, and MCP
|
||||
pnpm test:coverage # Critical-path V8 coverage, HTML/JSON reports, and ratchets
|
||||
pnpm test:e2e # Playwright end-to-end, zero retries
|
||||
|
||||
# Type check (builds shared first)
|
||||
pnpm typecheck
|
||||
|
||||
# Lint / fix
|
||||
pnpm lint
|
||||
pnpm lint:fix
|
||||
|
||||
# Smoke checks
|
||||
pnpm check:actions-pinned # Rejects mutable external GitHub Action references
|
||||
pnpm check:pnpm-settings # Validates package manager fields match this file
|
||||
pnpm check:tracked-ignore # Rejects tracked files covered by ignore rules
|
||||
pnpm check:coverage-policy # Validates coverage policy, configs, CI, and regression tests
|
||||
pnpm check:delivery-cadence # Prevents verification and review policy drift
|
||||
pnpm check:security-gates # Validates CodeQL/gitleaks workflow and exact suppressions
|
||||
pnpm check:gitleaks # Scans reviewed tree and proves new-secret detection
|
||||
pnpm check:vite-native-config # Loads web build and test configs with Vite's native loader
|
||||
pnpm check:service-filesystem-boundary # Prevents new direct filesystem imports in services
|
||||
pnpm test:ci-scope # Validates path-aware CI test selection
|
||||
pnpm smoke:cli-mcp # CLI ↔ MCP compatibility smoke test
|
||||
pnpm test:buzz:compatibility # Credential-free composed Buzz release gate
|
||||
```
|
||||
|
||||
Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, resolve with
|
||||
`pnpm install` and commit the updated `pnpm-lock.yaml` without reformatting it.
|
||||
|
||||
---
|
||||
|
||||
## GitHub workflow
|
||||
|
||||
- Use the authenticated GitHub CLI (`gh`) as the default interface for GitHub issues, pull
|
||||
requests, releases, workflow runs, and API calls.
|
||||
- Use `git` for local repository operations and `gh` for GitHub-hosted state.
|
||||
- Do not loop through alternate connectors or permission paths while `gh` is authenticated and
|
||||
can perform the operation.
|
||||
- Fall back only when `gh` is unavailable or cannot support the required operation. Report the
|
||||
exact blocker before changing paths.
|
||||
- Source every published GitHub release body from `docs/releases/vX.Y.Z.md` and pass that file
|
||||
to `gh release create` or `gh release edit` with `--notes-file`.
|
||||
- Never hand-author or repair a release body with `--notes`, the GitHub editor, or a raw API
|
||||
body. Edit the reviewed source file first, validate it, and publish that exact file.
|
||||
- Keep each prose paragraph and list item on one logical Markdown source line. Separate blocks
|
||||
with blank lines. Do not hard-wrap release prose or add carriage returns, trailing-space hard
|
||||
breaks, literal escaped newlines, HTML `<br>` tags, or blockquotes.
|
||||
- Prefer compact, natural paragraphs over bullet-per-sentence formatting. Use lists only for
|
||||
genuinely parallel items. Keep rendered prose blocks concise so they do not become walls of
|
||||
text on GitHub's release index.
|
||||
- Run `pnpm validate:release -- --version X.Y.Z`; the post-publication `--github` form also
|
||||
requires the published GitHub body to match the reviewed file exactly.
|
||||
- After publication, inspect both the releases index and tag page. Raw Markdown validation does
|
||||
not replace a rendered-format check.
|
||||
|
||||
---
|
||||
|
||||
## Sustainable execution cadence
|
||||
|
||||
- Keep each issue and pull request to one independently shippable behavior. When implementation
|
||||
reveals a separable UI surface, secondary integration, refactor, or hardening follow-up, open
|
||||
a linked issue instead of expanding the active pull request.
|
||||
- Re-scope before continuing when an issue no longer fits one coherent review, an unexpected
|
||||
subsystem becomes necessary, or verification work is larger than the behavior being changed.
|
||||
- At the 45-minute delivery checkpoint, if the issue is not pull-request ready, stop adding scope
|
||||
and report the concrete cause. Split independent remaining work into linked issues, or continue
|
||||
only when the next step is required to preserve correctness of the current behavior.
|
||||
- During ordinary implementation, use source inspection, changed-file formatting/linting, and
|
||||
touched-package type checking. Do not run workspace unit, coverage, E2E, desktop packaging, or
|
||||
Docker contract tests between implementation PRs.
|
||||
- When a maintainer explicitly declares a focused diagnostic milestone, run the exact Vitest slice
|
||||
once with
|
||||
`pnpm --filter <package> exec vitest run <exact-test-files>`. Do not use
|
||||
`pnpm --filter <package> test -- <test-files>` or
|
||||
`pnpm --filter <package> test -- --run <test-files>`; package wrappers can ignore that file
|
||||
boundary and expand into the entire package suite.
|
||||
- Do not rerun an unchanged passing gate after documentation, comments, or formatting-only edits.
|
||||
Rerun only the checks affected by the later change.
|
||||
- Use the complete workspace suite once at an explicit integration, critical-security, or release
|
||||
milestone. Pull-request label `ci:full`, scheduled CI, and manual full dispatch are the
|
||||
authoritative broad gates. Critical coverage, unsigned desktop artifacts, and the Docker image
|
||||
contract run only at those milestones.
|
||||
- Trust `scripts/select-ci-test-scope.mjs` and the `Select Test Scope` job to record the required
|
||||
CI tier. Ordinary pull requests and `main` pushes select no workspace tests. Do not add local
|
||||
test gates merely to duplicate a future milestone.
|
||||
- Do not wait for optional desktop packaging, artifact previews, or release workflows when the
|
||||
change does not touch their product boundary. They are evidence only when declared relevant.
|
||||
- Add enough regression coverage to prove the behavior and its meaningful failure modes. Test
|
||||
count is not a quality target.
|
||||
|
||||
---
|
||||
|
||||
## Architecture rules
|
||||
|
||||
### Server (Express + TypeScript)
|
||||
|
||||
- All routes go through centralized middleware in `server/src/middleware/`.
|
||||
- Auth: JWT + API keys. Dev bypass: `VERITAS_AUTH_LOCALHOST_BYPASS=true`.
|
||||
- Storage: always go through `storage/interfaces.ts`. Never import `fs` directly in service files.
|
||||
- Append-only durable records must complete the entire serialized write before
|
||||
sync. Never assume one `FileHandle.write()` call wrote every byte or ignore
|
||||
`bytesWritten`.
|
||||
- Error classes: `UnauthorizedError`, `ForbiddenError`, `BadRequestError`, `InternalError`.
|
||||
- Pagination: `sendPaginated(res, items, { page, limit, total })`.
|
||||
- Path traversal: always call `validatePathSegment()` on any user-supplied path component,
|
||||
then `ensureWithinBase(base, resolved)` before file I/O.
|
||||
- SQLite journal conversion runs from the bootstrap before `server.ts` imports routes. Normal
|
||||
startup eagerly creates many independent SQLite handles, so a live API handler cannot prove
|
||||
exclusive database ownership.
|
||||
- Governed SQLite `DELETE` or expert-override mode requires the signed external policy and the
|
||||
reference-counted process/host ownership lock. Do not reuse the short-lived generic `FileLock`
|
||||
for authoritative database ownership.
|
||||
|
||||
### Web (React + Vite)
|
||||
|
||||
- State: Zustand stores. No prop drilling past 2 levels.
|
||||
- Realtime: `useRealtimeUpdates` WebSocket hooks. Do not add polling when a hook exists.
|
||||
- Styling: Tailwind CSS with component-scoped overrides.
|
||||
- Frontend interfaces must exactly match server response shapes. Server is the source of truth.
|
||||
|
||||
### CLI (Commander.js)
|
||||
|
||||
- Every command mirrors an API endpoint.
|
||||
- `--json` flag for machine-readable output.
|
||||
- Colored output via `chalk`.
|
||||
|
||||
### Shared types
|
||||
|
||||
- All cross-package types live in `shared/src/types/`.
|
||||
- `AgentProvider` union is the single definition consumed by both server and web.
|
||||
**Currently supported providers:**
|
||||
`openclaw` | `codex-cli` | `codex-sdk` | `codex-app-server` | `codex-cloud` |
|
||||
`claude-code` | `acp-stdio` | `hermes-cli` | `ollama-local` | `ollama-cloud` |
|
||||
`lm-studio-local` | `custom`
|
||||
- Executable task adapters are currently `openclaw`, `codex-cli`, `codex-sdk`,
|
||||
`codex-app-server`, `claude-code`, `acp-stdio`, and `hermes-cli`. Explicitly
|
||||
configured providers outside that set must fail closed; never route them
|
||||
through an implicit OpenClaw fallback.
|
||||
- Probe and persist `provider-runtime-manifest/v1` before mutating attempt state.
|
||||
New runtime controls must use the persisted evidence instead of provider-name
|
||||
checks, and provider version/build changes must invalidate cached conformance.
|
||||
Increment `PROVIDER_RUNTIME_PROBE_REVISION` whenever probe semantics or the
|
||||
built-in adapter capability evidence changes.
|
||||
- Normalize every configured harness through `harness-support-profile/v1`.
|
||||
Settings, API diagnostics, `vk doctor`, dispatch, and telemetry must use the
|
||||
same support tier and redacted readiness evidence. Only known legacy records
|
||||
whose built-in type and command both identify `codex` or `hermes` may infer a
|
||||
provider during migration; provider-less or profile/adapter-mismatched records
|
||||
fail closed before an attempt is created.
|
||||
- Route direct, profile, conversation, provider-handoff, child-agent, retry,
|
||||
fallback, scheduled, watcher, and workflow launches through the shared
|
||||
admission controller. A `queued` response means Veritas durably accepted
|
||||
ownership; harnesses must not submit a duplicate or create a hidden
|
||||
provider-side queue. Provider adapters require
|
||||
`provider-admission-evidence/v1` before dispatch.
|
||||
- Phase authority uses the versioned contracts in
|
||||
`shared/src/types/phase-capability.types.ts`. Compile parent, phase, agent
|
||||
profile, sandbox, tool-catalog, and launch-policy authority only through
|
||||
`phase-capability-service.ts`; never union scopes or infer missing
|
||||
dimensions. The plan artifact exception is one harness-owned exact path and
|
||||
never implies general filesystem write authority. Active phase changes go
|
||||
only through `phase-transition-service.ts` with exact attempt, sequence,
|
||||
evidence-digest, and launch-manifest compare-and-set guards. Authority
|
||||
expansion requires an exact-action approval; an emergency override requires
|
||||
`admin:manage`, expires within 24 hours, and is durably reverted. Every task
|
||||
launch, workflow step, retry or fallback, resume, follow-up, fork, compaction
|
||||
control, and provider handoff must bind the effective phase before attempt
|
||||
mutation. Descendants inherit and intersect the exact parent launch or
|
||||
transition evidence and cannot widen it. Explicit phases fail closed when any
|
||||
required dimension is not enforceable. Run tool catalogs are filtered by the
|
||||
launch phase, mediated calls re-check the active phase, and approvals bind the
|
||||
exact phase evidence and transition sequence. ACP stdio is the only current
|
||||
adapter with enforceable command and external-action mediation; other
|
||||
adapters return typed blockers for explicit phases.
|
||||
- Credential-bound tool servers persist only exact definition/scope digests and
|
||||
safe target names in `run-tool-catalog/v1`. Discovery strips their source
|
||||
environment/header values, native provider injection omits them, and
|
||||
mediated invocation issues exact-action leases using the server-owned launch
|
||||
manifest digest. Credential-bound sessions are one-shot and raw values may
|
||||
exist only inside the controlled downstream dispatch callback.
|
||||
- Providers access credential-bound tools only through the system-owned
|
||||
`veritas-run` MCP bridge and an opaque in-memory run handle. Codex CLI/SDK,
|
||||
Codex app-server, Claude Code, and ACP stdio inject this shared contract;
|
||||
Hermes and OpenClaw fail closed until their certified transports can enforce
|
||||
it.
|
||||
- Classify launch credentials through `run-launch-credential-plan/v1`.
|
||||
Provider boot authentication, task integration definition IDs, and explicit
|
||||
high-risk environment passthrough are separate classes. Task integration
|
||||
credentials fail closed until an accepted tool or egress boundary proves
|
||||
brokered, non-bypassable delivery.
|
||||
- Atomically persist `admission-reservation/v1` before direct task attempts,
|
||||
workflow roots, executable workflow steps, pending-run state, or provider
|
||||
state. Workflow roots use the explicit `workflow-control` admission provider;
|
||||
provider-backed steps bind the resolved provider, selected host, root
|
||||
reservation, run, and step before attempt mutation. Capacity claims use the
|
||||
storage repository transaction or file lock, never process-local counters.
|
||||
Keep the invariant one-active-run-per-task policy and configured global,
|
||||
workspace, root-task, provider, and host ceilings aligned across dispatch,
|
||||
REST, and `vk`.
|
||||
Persist only a stable digest of caller-supplied idempotency values.
|
||||
Completion, interruption, cancellation, and start failure release once;
|
||||
restart recovery may reclaim only after the durable run supervisor verifies
|
||||
the original live process or session.
|
||||
- Bind every executable reservation to `execution-tree-identity/v1`. Descendants
|
||||
retain the root objective and exact parent edge across resume, follow-up,
|
||||
fork, retry, fallback, provider handoff, workflow step, and child-agent
|
||||
launches. Claim capacity and aggregate budget in the same repository lock or
|
||||
transaction. Usage events must be idempotent and attributable to one node;
|
||||
never copy cumulative parent or descendant totals into another contributor.
|
||||
Release unused reservation while retaining committed usage.
|
||||
- Persist `run-supervisor/v1` before provider dispatch. Restart recovery must
|
||||
validate the exact runtime, task-envelope, launch-manifest, worktree, host,
|
||||
lease, and process/session identity; replay only after the durable event
|
||||
cursor; and record a typed recovery action instead of starting duplicate work
|
||||
or signaling an unverified process.
|
||||
- Resolve selected MCP servers through `tool-server-definition/v1` and persist
|
||||
an immutable `run-tool-catalog/v1` before provider dispatch. Required
|
||||
discovery failures block launch; optional failures remain visible and
|
||||
audited.
|
||||
- Native provider configuration may expose only tools with an `allow`
|
||||
decision. Approval-required tools must use the Veritas-mediated
|
||||
`call_run_tool` path so the exact action hash is approved before dispatch.
|
||||
- Tool-server environment values and credential values are never persisted.
|
||||
Credential-bound tool definitions remain fail-closed until the provider
|
||||
launch credential broker is active.
|
||||
- Run-owned commands use `run-terminal-handle/v1`, never a provider's generic
|
||||
stdin channel. The current runtime supports background pipe mode with
|
||||
exact-action approval, stable request IDs, manifest-approved executable,
|
||||
cwd, and environment posture, bounded
|
||||
cursor-addressable redacted output, bounded single/any/all waits,
|
||||
foreground detachment, process-group termination, and durable journal
|
||||
reconstruction. A dangling handle becomes `interrupted` after restart
|
||||
because inherited pipes cannot be reattached safely. PTY, interactive stdin,
|
||||
and restart reattachment fail closed until their typed controls ship.
|
||||
- Harnesses start a run-owned command with
|
||||
`POST /api/v1/run-terminals/runs/:taskId/:attemptId/execute`. Send one stable
|
||||
`requestId`, a command plus argument array, `mode: "pipe"`, start mode,
|
||||
optional worktree-relative cwd, and environment names only. A `202`
|
||||
response requires an operator decision through `run-approvals`; retry the
|
||||
identical request after approval to receive the `201` handle. Never place
|
||||
credential values in arguments or environment fields.
|
||||
|
||||
---
|
||||
|
||||
## Agent provider notes
|
||||
|
||||
### OpenClaw (v2026.6.11)
|
||||
|
||||
- Task dispatch uses the gateway `/tools/invoke` endpoint with `sessions_spawn`.
|
||||
- **Required gateway policy:** `sessions_spawn` and `sessions_send` must be explicitly allowed
|
||||
on the operator-level gateway; they are blocked by default on fresh OpenClaw installs.
|
||||
- Set `OPENCLAW_GATEWAY_URL` (default `http://127.0.0.1:18789`) and optionally
|
||||
`OPENCLAW_GATEWAY_TOKEN`.
|
||||
- A pre-flight check is run before a task is marked active; policy denial returns an actionable
|
||||
configuration error.
|
||||
- See `docs/AGENT-PROVIDERS.md` § OpenClaw for full setup instructions.
|
||||
|
||||
### Hermes Agent (v2026.7.7.2)
|
||||
|
||||
- Dispatch uses the one-shot scripted interface: `hermes -z <prompt>`.
|
||||
- Hermes is spawned in the task worktree without a shell; stdout captures the final response,
|
||||
stderr captures diagnostics.
|
||||
- Project instructions are loaded automatically from `AGENTS.md` in the worktree root.
|
||||
- Session resume is not yet implemented; `--resume`/`--continue` are reserved for a future
|
||||
provider iteration.
|
||||
- Provider ID: `hermes-cli`. Auth probe: `hermes --version`.
|
||||
- Set `HERMES_API_KEY` or the appropriate model-provider key in the operator environment.
|
||||
- See `docs/AGENT-PROVIDERS.md` § Hermes for full setup instructions.
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
- `codex-cli`: `codex exec --sandbox workspace-write --json`
|
||||
- `codex-sdk`: programmatic SDK, requires `@openai/codex-sdk`
|
||||
- `codex-app-server`: pinned to `codex-cli 0.145.0`; supervised JSON-RPC v2 over
|
||||
strict stdio for one task-bound thread and turn.
|
||||
- App-server launch arguments are system-owned. Inherited MCP servers, hooks,
|
||||
plugins, apps, browser/computer tools, and remote control remain disabled.
|
||||
Selected run-scoped MCP servers are injected only through the immutable
|
||||
catalog's thread configuration.
|
||||
- App-server consumes only the checked-in v0.145.0 schemas and exposes
|
||||
`initialize`, thread start/resume/fork/compact/archive, and turn
|
||||
start/steer/interrupt. `thread/shellCommand` is never reachable.
|
||||
- `conversation-lifecycle/v1` persists opaque thread, turn, item, parent, and
|
||||
fork identities. Resume and fork validate the source launch manifest,
|
||||
provider/model/policy, base revision, and worktree compatibility before a new
|
||||
attempt is created.
|
||||
- App-server command, file, permission, tool-question, and elicitation requests
|
||||
use `run-approval/v1`. Decisions must preserve the persisted revision and
|
||||
exact action hash; interruption and cancellation invalidate pending requests.
|
||||
- Auth: `codex login status` / `OPENAI_API_KEY`
|
||||
|
||||
### Claude Code (v2.1.218)
|
||||
|
||||
- Provider ID: `claude-code`. Default command: `claude`.
|
||||
- Veritas launches `claude --bare --print --output-format stream-json` with
|
||||
static sandbox-derived permissions and no shell.
|
||||
- Bare mode requires explicit environment authentication. OAuth/keychain state
|
||||
reported by `claude auth status` does not prove bare-mode readiness.
|
||||
- The terminal `result` record is authoritative. Veritas drains stdout after
|
||||
process close, persists `session_id`, and maps partial, hook, tool, subagent,
|
||||
usage, cost, and result records into `run-event/v1`.
|
||||
- Resume uses the exact persisted session through system-owned `--resume`.
|
||||
Native history fork adds `--fork-session`; caller-supplied lifecycle flags
|
||||
remain prohibited. Run-scoped MCP uses a system-owned strict config and
|
||||
exposes only catalog tools with an `allow` decision.
|
||||
- The shared approval broker is available, but Claude stays on static
|
||||
`dontAsk` permissions until its adapter exposes a pinned interactive
|
||||
request/response contract.
|
||||
|
||||
### Agent Client Protocol (ACP v1)
|
||||
|
||||
- Provider ID: `acp-stdio`. Configure the exact ACP agent command and arguments.
|
||||
- Veritas launches the agent without a shell in the task worktree and negotiates
|
||||
stable ACP protocol version 1 before attempt mutation.
|
||||
- Capability evidence comes from `initialize`; resume/load, fork, and close fail
|
||||
closed when the runtime does not advertise them.
|
||||
- `session/update` records enter the causal run journal.
|
||||
`session/request_permission` uses the durable approval broker.
|
||||
- Only immutable all-allow MCP server catalogs can be passed natively because
|
||||
ACP v1 has no per-tool allowlist. Profiles may explicitly require the
|
||||
system-owned `veritas-run` bridge for mediated catalogs; otherwise partial
|
||||
native catalogs fail closed.
|
||||
- The built-in `buzz-agent` profile remains provider `acp-stdio`, pins Buzz
|
||||
`v0.4.24` at commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`, and rejects
|
||||
`buzz-acp`, version drift, session loading, and network MCP claims. Selected
|
||||
run tools are delivered only through the opaque, attempt-bound
|
||||
`veritas-run` bridge.
|
||||
- The built-in `copilot` profile remains provider `acp-stdio`, pins Copilot CLI
|
||||
`v1.0.74`, owns the stdio safety argv, rejects broad allow/remote/TCP/config
|
||||
injection, and records public-preview plus incomplete-source limitations.
|
||||
- The built-in `grok-build` profile remains provider `acp-stdio`, pins Grok
|
||||
Build `v0.2.111` build `94172f2aa4e5`, launches `grok agent --no-leader
|
||||
stdio`, and rejects approval bypass, reauthentication, leader, plugin,
|
||||
endpoint, prompt, and resume argument injection.
|
||||
- Harness certification uses `harness-conformance-suite/v1`; run the committed
|
||||
mock lane with `pnpm --filter @veritas-kanban/server exec tsx
|
||||
src/scripts/run-harness-conformance.ts -- --suite <suite.json>
|
||||
--observations <observations.json>`. Credential-gated lanes require explicit
|
||||
opt-in and never commit raw provider output or secrets.
|
||||
- Cross-harness compatibility is published as
|
||||
`harness-compatibility-matrix/v1`. API, `vk doctor`, Settings, telemetry, and
|
||||
`docs/HARNESS-COMPATIBILITY.md` must use the reviewed profile capability
|
||||
digest, fixture revision, invalidation policy, and source caveats rather than
|
||||
defining provider-specific tiers.
|
||||
- Runtime extensions use the in-process `runtime-hook/v1` bus. Only documented
|
||||
pre-events may deny, post-events remain passive, and arbitrary executable or
|
||||
HTTP handlers stay unsupported until their filesystem and egress boundaries
|
||||
are enforceable. See `docs/architecture/RUNTIME-HOOK-V1.md`.
|
||||
- `vk acp serve --stdio` exposes one Veritas-managed task as an ACP v1 server
|
||||
view for editors and other ACP clients. Bind with `--task` or require
|
||||
`_meta["veritas/taskId"]` on `session/new`; client-owned MCP catalogs fail
|
||||
closed.
|
||||
- ACP client disconnect never stops the durable Veritas run. Reconnect with
|
||||
`session/load` and `_meta["veritas/afterSequence"]`; cancellation uses the
|
||||
conversation interrupt path, not task termination.
|
||||
- See `docs/AGENT-PROVIDERS.md` § ACP stdio agent provider.
|
||||
|
||||
---
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- **No secrets in code.** Use environment variables or brokered credentials.
|
||||
- **Input validation.** All user input is validated with Zod schemas before processing.
|
||||
- **Path traversal.** `validatePathSegment()` + `ensureWithinBase()` on every user-supplied path.
|
||||
- **Env passthrough.** Agents receive only the keys in the configured safe allowlist; see
|
||||
`server/src/utils/codex-env.ts`, `server/src/utils/hermes-env.ts`, and
|
||||
`server/src/services/claude-code-adapter.ts` plus
|
||||
`server/src/services/acp-stdio-adapter.ts`.
|
||||
- **Launch arguments.** Never put credential values in provider commands or arguments; use an
|
||||
allowlisted environment key or run-scoped brokered credential reference.
|
||||
- **Workspace execution trust.** Scan repository-controlled instructions,
|
||||
hooks, MCP servers, workflows, extensions, and provider configuration before
|
||||
launch. Bind the exact inventory and decision to the run launch manifest,
|
||||
then rescan before provider creation. Project policy may narrow trust only.
|
||||
- **Log redaction.** Trace logs and telemetry run through `TRACE_SECRET_PATTERNS` before storage.
|
||||
- **No credentials in PR descriptions, test fixtures, or log snippets.**
|
||||
|
||||
---
|
||||
|
||||
## Testing expectations
|
||||
|
||||
- Framework: **Vitest** (server, cli, mcp), **React Testing Library** (web).
|
||||
- Test files: `*.test.ts` co-located in `src/__tests__/` or alongside source.
|
||||
- Aim for >80% coverage on critical paths (agent dispatch, auth, storage adapters).
|
||||
- Use `vi.mock()`/`vi.fn()` to isolate external processes and HTTP calls; no live credentials
|
||||
in unit tests.
|
||||
- Credential-gated smoke tests document the tested provider version in a `@smoke` describe block.
|
||||
- Live MCP-to-HTTP integration groups require a running API and explicit
|
||||
`VK_MCP_INTEGRATION_TEST=1`; the default MCP test suite must remain
|
||||
server-independent.
|
||||
- Match actual runtime schema in test fixtures — wrong field names (`status: "success"` vs
|
||||
`success: true`) are a common source of false-passing tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-agent runtime
|
||||
|
||||
- Agent registry: `.veritas-kanban/agent-registry.json` (file-based).
|
||||
- Agent names: use ALL CAPS for acronyms (VERITAS, TARS, CASE, K-2SO, R2-D2, MAX).
|
||||
- Heartbeat timeout: 5 min (configurable). Stale-check interval: 1 min.
|
||||
- Activity data source of truth: `status-history` files, not `activity.json`.
|
||||
- Dashboard optimistic updates: use `onMutate` in Zustand mutations.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
| Artifact | Style |
|
||||
| ----------- | --------------------------------------------------------- |
|
||||
| TS files | `kebab-case.ts` |
|
||||
| Components | `PascalCase.tsx` |
|
||||
| Variables | `camelCase` |
|
||||
| Constants | `UPPER_SNAKE_CASE` |
|
||||
| Git commits | Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`) |
|
||||
| Branches | `feat/description-issue-number`, `fix/...`, or `docs/...` |
|
||||
|
||||
---
|
||||
|
||||
## Code quality gates
|
||||
|
||||
1. **No direct `fs` imports** in service files — use the storage abstraction layer.
|
||||
2. **All provider schemas validated** — do not guess flag names; verify against versioned docs
|
||||
or provider `--help` output.
|
||||
3. **pnpm-lock.yaml** is generated by pnpm; do not reformat or hand-edit it.
|
||||
|
||||
Independent or cross-model review is optional. Run it only when the task,
|
||||
configured governance policy, issue owner, or release owner explicitly requires
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
## File locations quick-reference
|
||||
|
||||
| What | Where |
|
||||
| ----------------- | -------------------------------------------------------- |
|
||||
| API routes | `server/src/routes/` |
|
||||
| Services | `server/src/services/` |
|
||||
| Zod schemas | `server/src/schemas/` |
|
||||
| Storage | `server/src/storage/` |
|
||||
| Server utilities | `server/src/utils/` |
|
||||
| Provider adapters | `server/src/services/agent-provider-adapter-registry.ts` |
|
||||
| React components | `web/src/components/` |
|
||||
| Zustand stores | `web/src/stores/` |
|
||||
| CLI commands | `cli/src/commands/` |
|
||||
| Shared types | `shared/src/` |
|
||||
| MCP server | `mcp/src/` |
|
||||
| Prompt registry | `prompt-registry/` |
|
||||
| SOPs | `docs/SOP-*.md` |
|
||||
| Agent registry | `.veritas-kanban/agent-registry.json` |
|
||||
| Agent run logs | `.veritas-kanban/logs/` |
|
||||
| Telemetry events | `.veritas-kanban/telemetry/` |
|
||||
|
||||
---
|
||||
|
||||
## Harness instruction sources
|
||||
|
||||
| Harness | Instruction source | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| Buzz Agent | Veritas task envelope; repository files only if the runtime reads them | ACP task, worktree, tool, and completion contract |
|
||||
| Grok Build | Veritas task envelope | ACP task, worktree, tool, and completion contract |
|
||||
| GitHub Copilot CLI | Veritas task envelope | ACP task, worktree, tool, and completion contract |
|
||||
| Codex / GPT | `AGENTS.md` plus Veritas task envelope | Canonical repository rules and managed-run contract |
|
||||
| Claude Code | `AGENTS.md`, `CLAUDE.md`, and Veritas task envelope | Canonical rules plus Claude-specific lessons |
|
||||
| Hermes | `AGENTS.md` plus Veritas task envelope | Hermes reads `AGENTS.md` from the worktree |
|
||||
| OpenClaw | `AGENTS.md` plus the gateway task request | Canonical rules and callback completion contract |
|
||||
999
CHANGELOG.md
999
CHANGELOG.md
File diff suppressed because it is too large
Load diff
164
CLAUDE.md
164
CLAUDE.md
|
|
@ -1,150 +1,64 @@
|
|||
# CLAUDE.md — Agent Guidelines for Veritas Kanban
|
||||
# CLAUDE.md — Claude-Specific Supplement for Veritas Kanban
|
||||
|
||||
This file defines project-specific rules, context, and lessons learned for AI agents working on Veritas Kanban. Update it after every mistake, discovery, or workflow change.
|
||||
|
||||
> **Last updated:** 2026-02-06 (v2.0.0)
|
||||
> **Freshness check:** Review monthly or after major releases
|
||||
> **Canonical instructions are in `AGENTS.md`.** Read that file first. This supplement contains
|
||||
> Claude-specific lessons and common mistakes caught by previous Claude runs. Do not duplicate
|
||||
> `AGENTS.md` content here.
|
||||
>
|
||||
> **Last updated:** 2026-08-24 (v6.1.2 release freshness)
|
||||
> **Freshness check:** Update after mistakes; review monthly.
|
||||
|
||||
---
|
||||
|
||||
## Project Context
|
||||
## What changed in v2.1
|
||||
|
||||
**Veritas Kanban** is an open-source AI-native task management system. It's designed for humans + AI agents to collaborate on work through a shared board, CLI, and API.
|
||||
`AGENTS.md` is now the canonical project instruction file. It supersedes the duplicate context
|
||||
that was previously embedded here. The fields updated from their stale v2.0 values:
|
||||
|
||||
- **Primary language:** TypeScript (strict mode)
|
||||
- **Monorepo:** pnpm workspaces — `server/`, `web/`, `cli/`, `shared/`, `mcp/`
|
||||
- **Build:** Node 22+, pnpm 9+
|
||||
- **Test:** Vitest (server), React Testing Library (web)
|
||||
- **Style:** ESLint + Prettier, conventional commits
|
||||
- **pnpm:** was `9+` → now `≥ 11.0.0` (pinned `pnpm@11.1.1`)
|
||||
- **Node:** was `22+` → now `≥ 22.22.1`
|
||||
- **Providers:** managed Buzz, Grok Build, Codex, Claude Code, Copilot CLI,
|
||||
Hermes, and OpenClaw contracts are documented in `AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
### Server (Express + TypeScript)
|
||||
|
||||
- All routes go through centralized middleware in `server/src/middleware/`
|
||||
- Auth: JWT + API keys, localhost bypass for dev (`VERITAS_AUTH_LOCALHOST_BYPASS=true`)
|
||||
- Storage: Abstract via `storage/interfaces.ts` — never import `fs` directly in services
|
||||
- Error handling: Use `UnauthorizedError`, `ForbiddenError`, `BadRequestError`, `InternalError`
|
||||
- Pagination: Use `sendPaginated(res, items, {page, limit, total})`
|
||||
|
||||
### Web (React + Vite)
|
||||
|
||||
- State: Zustand stores, no prop drilling past 2 levels
|
||||
- Realtime: WebSocket via `useRealtimeUpdates` hooks
|
||||
- Styling: Tailwind CSS, component-scoped styles
|
||||
|
||||
### CLI (Commander.js)
|
||||
|
||||
- Every command mirrors an API endpoint
|
||||
- JSON output via `--json` flag for scripting
|
||||
- Use `chalk` for colored output
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Gates
|
||||
|
||||
1. **Cross-model review required for all code changes**
|
||||
- If Claude writes it, GPT reviews (and vice versa)
|
||||
- See `prompt-registry/cross-model-review.md`
|
||||
|
||||
2. **No hardcoded secrets** — use environment variables
|
||||
|
||||
3. **All user input validated** — use Zod schemas
|
||||
|
||||
4. **Path traversal prevention** — use `validatePathSegment()` from security module
|
||||
|
||||
5. **Tests for new features** — aim for >80% coverage on critical paths
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes (Don't Repeat These)
|
||||
## Lessons learned (Claude-specific)
|
||||
|
||||
### Security
|
||||
|
||||
- ❌ Forgot global middleware — flagged missing per-route auth that was already in `app.use()`
|
||||
- ❌ Used `path.join()` without validation — allows `../` traversal
|
||||
- ✅ Always check `validatePathSegment()` for any user-supplied path component
|
||||
- ❌ Forgot global middleware — flagged missing per-route auth that was already in `app.use()`.
|
||||
Global middleware is in `server/src/middleware/`; check there before adding per-route auth.
|
||||
- ❌ Used `path.join()` without validation — allows `../` traversal.
|
||||
Always follow with `validatePathSegment()` + `ensureWithinBase()`.
|
||||
|
||||
### Architecture
|
||||
|
||||
- ❌ Imported `fs` directly in service files — breaks storage abstraction
|
||||
- ❌ Added polling when WebSocket hook existed — use `useRealtimeAgentStatus`
|
||||
- ❌ Frontend interface didn't match server response (e.g., `totalAgents` vs `total` in registry stats)
|
||||
- ✅ Check for existing hooks/services before creating new ones
|
||||
- ✅ Server response format is source of truth — frontend interfaces must match exactly
|
||||
|
||||
### Multi-Agent (v2.0)
|
||||
|
||||
- Agent names use ALL CAPS for acronyms (VERITAS, TARS, CASE, K-2SO, R2-D2, MAX)
|
||||
- Agent registry is file-based at `.veritas-kanban/agent-registry.json`
|
||||
- Heartbeat timeout: 5 min (configurable). Stale check interval: 1 min
|
||||
- Activity data uses `status-history` (not `activity.json`) as source of truth
|
||||
- Timezone: server uses local time; clients send `?tz=<offset>` for cross-region display
|
||||
- Dashboard widgets: use `onMutate` for optimistic updates (archive, status changes)
|
||||
- ❌ Imported `fs` directly in service files — breaks storage abstraction.
|
||||
- ❌ Added polling when WebSocket hook existed — use `useRealtimeAgentStatus`.
|
||||
- ❌ Frontend interface didn't match server response (`totalAgents` vs `total`).
|
||||
Server response is the source of truth; interfaces must match exactly.
|
||||
- ❌ Agent provider guessing — always verify flag names and interfaces against versioned docs
|
||||
before implementing a new provider adapter.
|
||||
|
||||
### Testing
|
||||
|
||||
- ❌ Used wrong field in backfilled events (`status: "success"` vs `success: true`)
|
||||
- ✅ Match actual runtime schema exactly in test fixtures
|
||||
- ❌ Used wrong schema field in test fixtures (`status: "success"` vs `success: true`).
|
||||
Copy fixture shapes from live runtime output or type definitions, not from memory.
|
||||
- ✅ `pnpm-lock.yaml` is generated by pnpm; never reformat or hand-edit it.
|
||||
|
||||
### Multi-agent runtime
|
||||
|
||||
- Heartbeat timeout: 5 min. Stale-check interval: 1 min.
|
||||
- Activity source of truth: `status-history` files, not `activity.json`.
|
||||
- Dashboard optimistic updates: use `onMutate`, not refetch-after-mutate.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
## When to update this file
|
||||
|
||||
### Naming
|
||||
|
||||
- Files: `kebab-case.ts`
|
||||
- Components: `PascalCase.tsx`
|
||||
- Variables/functions: `camelCase`
|
||||
- Constants: `UPPER_SNAKE_CASE`
|
||||
|
||||
### Git
|
||||
|
||||
- Branch: `feat/description-issue-number`, `fix/description-issue-number`
|
||||
- Commit: Conventional commits (`feat:`, `fix:`, `docs:`, `chore:`)
|
||||
- PR: Always reference issue number
|
||||
|
||||
### Task Workflow
|
||||
|
||||
1. Start timer: `vk begin <id>`
|
||||
2. Update status: `vk status <id> in-progress`
|
||||
3. Work, commit, push
|
||||
4. Cross-model review
|
||||
5. Complete: `vk done <id> "summary"`
|
||||
- After a mistake that a rule would have prevented.
|
||||
- After any review catches a systemic pattern worth preserving.
|
||||
- Monthly freshness review.
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
| What | Where |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| API routes | `server/src/routes/` |
|
||||
| Services | `server/src/services/` |
|
||||
| Schemas | `server/src/schemas/` |
|
||||
| Storage | `server/src/storage/` |
|
||||
| React components | `web/src/components/` |
|
||||
| Zustand stores | `web/src/stores/` |
|
||||
| CLI commands | `cli/src/commands/` |
|
||||
| Shared types | `shared/src/` |
|
||||
| MCP server | `mcp/src/` |
|
||||
| Prompts | `prompt-registry/` |
|
||||
| SOPs | `docs/SOP-*.md` |
|
||||
| Agent registry | `.veritas-kanban/agent-registry.json` |
|
||||
| Telemetry events | `.veritas-kanban/telemetry/` |
|
||||
|
||||
---
|
||||
|
||||
## When to Update This File
|
||||
|
||||
- After a bug that could have been prevented by a rule
|
||||
- After discovering a pattern that should be standard
|
||||
- After a cross-model review catches something systemic
|
||||
- Monthly freshness review (add to calendar)
|
||||
|
||||
---
|
||||
|
||||
## Credit
|
||||
|
||||
Structure inspired by Anthropic's CLAUDE.md convention and [BoardKit Orchestrator](https://github.com/BoardKit/orchestrator) by Monika Voutov.
|
||||
_Structure inspired by Anthropic's CLAUDE.md convention._
|
||||
|
|
|
|||
181
CONTRIBUTING.md
181
CONTRIBUTING.md
|
|
@ -5,7 +5,7 @@ Thanks for your interest in contributing! This guide will help you get started.
|
|||
## Prerequisites
|
||||
|
||||
- **Node.js** 22 or later
|
||||
- **pnpm** 9+ (package manager)
|
||||
- **pnpm** 11+ (package manager)
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
|
@ -73,31 +73,75 @@ veritas-kanban/
|
|||
|
||||
2. Make your changes — write code, add tests, update docs.
|
||||
|
||||
3. Run type checking, linting, and tests before committing:
|
||||
3. Run touched-package type checking and changed-file linting before
|
||||
committing. Ordinary implementation pull requests do not run workspace
|
||||
tests:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm --filter @veritas-kanban/server typecheck
|
||||
pnpm exec eslint server/src/path/to/changed.ts
|
||||
```
|
||||
|
||||
At an explicitly declared focused diagnostic milestone, use direct
|
||||
`exec vitest run` invocation for one exact-file slice. Do not use
|
||||
`pnpm --filter <package> test -- <test-files>` or
|
||||
`pnpm --filter <package> test -- --run <test-files>` as a focused command.
|
||||
Package wrappers can ignore that file boundary and expand into the entire
|
||||
package suite.
|
||||
|
||||
Build `@veritas-kanban/shared` first and type-check its known consumers when
|
||||
a shared contract changes. Use `pnpm test` at an explicit integration,
|
||||
critical-security, or release milestone, or when a maintainer explicitly
|
||||
selects the `ci:full` gate. Critical coverage, E2E, desktop packaging, and
|
||||
Docker contracts follow the same milestone boundary.
|
||||
|
||||
4. Commit using [conventional commits](#commit-conventions).
|
||||
|
||||
5. Push to your fork and open a pull request.
|
||||
|
||||
### Scope and Verification Budget
|
||||
|
||||
This cadence extends the deterministic CI selector delivered in
|
||||
[#1000](https://github.com/BradGroux/veritas-kanban/issues/1000).
|
||||
|
||||
- Keep one independently shippable behavior per issue and pull request.
|
||||
- Split separable UI work, secondary integrations, refactors, and additional
|
||||
hardening into linked follow-up issues before implementing them.
|
||||
- Re-scope when a second unexpected subsystem becomes necessary or the
|
||||
verification effort becomes larger than the changed behavior.
|
||||
- Do not rerun an unchanged passing check after documentation, comments, or
|
||||
formatting-only edits.
|
||||
- Treat `Select Test Scope` as the CI authority. Ordinary pull requests and
|
||||
`main` pushes select no workspace tests; manual focused diagnostics and full
|
||||
milestone selections are recorded in the job summary.
|
||||
- Do not wait for optional desktop artifacts, packaging previews, or release
|
||||
workflows unless the pull request changes that product boundary.
|
||||
- Test the behavior and meaningful failure modes. Do not use raw test count as
|
||||
a quality measure.
|
||||
- The dependency-free delivery cadence checker guards these rules in
|
||||
pre-commit and the early CI scope-control job without installing packages or
|
||||
running workspace tests.
|
||||
|
||||
### Branch Merge Protocol
|
||||
|
||||
**Critical:** When merging multiple feature branches, merge **one at a time**. Never batch-merge parallel branches.
|
||||
When merging multiple feature branches, merge one at a time so the next branch
|
||||
can rebase on the exact result.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Merge first branch to `main`
|
||||
2. Build all packages: `pnpm build`
|
||||
3. Run smoke tests (see [Testing Requirements](#testing-requirements))
|
||||
4. Only after smoke tests pass, merge the next branch
|
||||
5. Repeat for each branch
|
||||
2. Confirm the required GitHub checks for that pull request
|
||||
3. Rebase the next branch on the updated `main`
|
||||
4. Inspect conflict resolution and run changed-file static checks
|
||||
5. Merge the next branch
|
||||
|
||||
**Why:** Parallel branches often introduce integration issues that are hidden when batch-merging. Sequential merges with testing between each merge catch these immediately.
|
||||
The complete workspace suite, coverage, integration, E2E, desktop artifact,
|
||||
and Docker gates run once at the declared milestone. They are not repeated
|
||||
after every unrelated merge.
|
||||
|
||||
**Why:** Sequential merges keep conflicts attributable without paying the
|
||||
release-certification cost after every independent change. The declared
|
||||
milestone verifies the integrated candidate once.
|
||||
|
||||
### One Agent Per File Rule
|
||||
|
||||
|
|
@ -136,37 +180,28 @@ The `--model` flag is optional but recommended — it shows which AI model is be
|
|||
|
||||
See [SQUAD-CHAT-PROTOCOL.md](docs/SQUAD-CHAT-PROTOCOL.md) for full details.
|
||||
|
||||
### Pre-Commit Review Protocol (Mandatory)
|
||||
### Risk-Proportional Review
|
||||
|
||||
Before every commit, run these 4 reviews:
|
||||
Review the changed behavior once before committing. In that pass, cover
|
||||
correctness and any security, reliability, performance, accessibility, or
|
||||
architecture risks that actually apply to the change.
|
||||
|
||||
1. **Code Review** — Code quality, anti-patterns, architectural issues, file locking, path validation
|
||||
2. **Functionality Review** — All endpoints work, CRUD operations, settings save/load
|
||||
3. **Performance Review** — API response times, bundle size, React optimizations, memory leaks
|
||||
4. **Security Review** — Auth/authz, injection vectors, secrets exposure, CORS/CSP, rate limiting
|
||||
|
||||
All four must pass (10/10) before committing. If ANY review says unsafe:
|
||||
|
||||
1. Fix the issue
|
||||
2. Have the SAME reviewer who found it verify the fix
|
||||
3. Get human approval
|
||||
4. Then commit
|
||||
|
||||
**Never commit when a review says "unsafe." Never push without human approval.**
|
||||
|
||||
These reviews are mandatory, not optional. They catch runtime issues that static analysis and builds miss.
|
||||
Do not create separate review tasks for inapplicable categories or require
|
||||
numeric review scores. If the review finds an unsafe behavior, fix it and
|
||||
recheck the affected path before committing. Independent or cross-model review
|
||||
is optional unless a configured governance policy, issue owner, or release
|
||||
owner explicitly requires it.
|
||||
|
||||
### Pre-Merge Checklist
|
||||
|
||||
Before merging any branch, verify:
|
||||
Before merging, verify the checks selected for the changed product boundary:
|
||||
|
||||
- [ ] **Type exports:** All new types added to `shared/` are exported in `shared/src/types/index.ts`
|
||||
- [ ] **Type checks pass:** `pnpm typecheck` succeeds for all workspace packages (shared, server, web, CLI, MCP)
|
||||
- [ ] **Builds pass:** `pnpm build` succeeds for all packages (shared, server, web, CLI, MCP)
|
||||
- [ ] **No hardcoded values:** No hardcoded ports, URLs, or timeouts in application code
|
||||
- [ ] **CSP/CORS configs:** Security policies work in both `NODE_ENV=development` AND `NODE_ENV=production`
|
||||
- [ ] **Frontend hooks:** All HTTP calls use shared helpers (`apiFetch`) and all WebSocket/URL logic uses `window.location.host` (not hardcoded ports)
|
||||
- [ ] **Environment variables:** All configurable values use env vars with sensible defaults
|
||||
- [ ] **Selected CI tier:** Every required check started for the pull request is green.
|
||||
- [ ] **Implementation evidence:** The diff and applicable static checks support the changed behavior.
|
||||
- [ ] **Shared contracts, when changed:** New types are exported and known consumers type-check.
|
||||
- [ ] **Configuration, when changed:** Ports, URLs, timeouts, environment variables, CSP, and CORS behave in the affected modes.
|
||||
- [ ] **Frontend integration, when changed:** HTTP calls use shared helpers and location-sensitive behavior avoids hardcoded hosts.
|
||||
- [ ] **Milestone gate, when selected:** Complete build, typecheck, test, security, integration, E2E, or artifact checks required by `ci:full` or the release plan pass once.
|
||||
|
||||
### Environment Rules
|
||||
|
||||
|
|
@ -181,17 +216,19 @@ Before merging any branch, verify:
|
|||
|
||||
### Testing Requirements
|
||||
|
||||
**"Builds clean" is necessary but NOT sufficient.**
|
||||
Run browser or API smoke tests only at an explicit integration or release
|
||||
milestone when the change affects that product boundary. Choose the smallest
|
||||
runtime check that proves the behavior:
|
||||
|
||||
Before declaring a branch ready to merge, verify **runtime behavior:**
|
||||
- **Server or API changes:** Exercise the changed endpoint and its meaningful auth or failure path. Add a health check only when startup or routing changed.
|
||||
- **Web changes:** Open the changed route and verify its primary interaction, keyboard flow, and failure state.
|
||||
- **Realtime changes:** Verify the changed event path with the minimum number of clients needed to prove propagation.
|
||||
- **Desktop changes:** Use the relevant desktop readiness or packaging smoke check.
|
||||
- **Documentation and static tooling:** No runtime smoke is required unless deterministic CI escalates the change.
|
||||
|
||||
1. **Health check:** `curl http://localhost:3000/api/health` returns 200
|
||||
2. **Auth flow:** Log in via the UI, verify token handling works
|
||||
3. **Task CRUD:** Create, update, move, and delete a task
|
||||
4. **WebSocket connection:** Verify real-time updates work (open two browser tabs, change task in one, see update in the other)
|
||||
5. **No stray processes:** Check for leftover Vite dev servers or conflicting processes before starting: `lsof -i :3000`
|
||||
|
||||
**Static code reviews (AI or human) cannot catch runtime issues.** You must test in a running browser.
|
||||
Static review does not replace runtime evidence when runtime behavior changed,
|
||||
but unrelated browser, CRUD, WebSocket, or packaging checks add no useful
|
||||
confidence to a focused change.
|
||||
|
||||
### Common Integration Failures
|
||||
|
||||
|
|
@ -247,7 +284,11 @@ docs: update README with deployment instructions
|
|||
2. **Branch naming:** Use descriptive names like `feat/task-filters`, `fix/login-redirect`, `docs/api-reference`.
|
||||
3. **Open a PR** against `main`.
|
||||
4. **Fill out the PR template** — describe changes, link related issues, include screenshots for UI changes.
|
||||
5. **Ensure CI passes** — all checks must be green.
|
||||
5. **Ensure the selected PR CI tier passes** — all checks started for the pull
|
||||
request must be green. The scope selector records affected workspaces but
|
||||
defers their tests on ordinary pull requests. Use `ci:full` for release
|
||||
candidates, critical integration/security boundaries, or other changes that
|
||||
require an explicit complete-suite gate.
|
||||
6. **Request review** — a maintainer will review and may request changes.
|
||||
7. **Address feedback** — push additional commits as needed.
|
||||
8. **Merge** — once approved, a maintainer will merge.
|
||||
|
|
@ -270,12 +311,19 @@ Follow the existing conventions in `.eslintrc.*`, `.prettierrc`, and `tsconfig.j
|
|||
pnpm test
|
||||
```
|
||||
|
||||
This is the canonical unit gate. It builds the shared package, then runs the
|
||||
server, web, CLI, and MCP suites sequentially with at most four Vitest workers
|
||||
per project. The final line reports PASS, FAIL, or NOT RUN for every workspace.
|
||||
|
||||
- **End-to-end tests** use [Playwright](https://playwright.dev/):
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Playwright does not retry failures. Screenshots and traces from the first
|
||||
failure are retained in `test-results/` and uploaded by Scheduled QA.
|
||||
|
||||
- **Load smoke tests** use [k6](https://k6.io/):
|
||||
|
||||
```bash
|
||||
|
|
@ -292,6 +340,47 @@ Follow the existing conventions in `.eslintrc.*`, `.prettierrc`, and `tsconfig.j
|
|||
- Write tests for new features and bug fixes.
|
||||
- Ensure existing tests pass before submitting.
|
||||
|
||||
### CI tiers
|
||||
|
||||
| Trigger | Stable checks | Scope |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Documentation-only pull request or merge | Static gates; test jobs record skip decisions | No workspace tests |
|
||||
| Ordinary code pull request or merge to `main` | `Lint & Type Check`, `Build`, `Security Audit`, scope recording | No workspace tests or coverage; affected packages remain visible |
|
||||
| Pull request with `ci:full` | Default checks plus every milestone test and artifact gate | Complete unit, coverage, desktop, Docker, and applicable integration gates |
|
||||
| Nightly 08:00 UTC or manual `CI` dispatch with `test_scope=full` | Static gates plus complete workspace and coverage gates | Authoritative recurring or operator-selected milestone |
|
||||
| Manual `CI` dispatch with `test_scope=focused` and optional `base_sha` | Static gates plus `Changed Tests` | Explicit diagnostic slice for affected workspaces; no coverage ratchet |
|
||||
| Manual `Desktop Artifacts` or `Docker Image Contract` dispatch | Selected artifact or container contract | Explicit operator milestone outside a pull request |
|
||||
|
||||
`Select Test Scope` is the decision record for each run. Its summary names the
|
||||
event, exact base/head range, changed-path count, selected tier, affected
|
||||
workspaces, and why `Changed Tests` or `Workspace Unit Tests` ran or skipped.
|
||||
Shared contracts, package manifests, lockfiles, storage implementations,
|
||||
desktop source, and known-workspace deletions are recorded as affected
|
||||
workspaces without launching tests. Build and typecheck remain
|
||||
whole-repository gates on every ordinary code pull request. The full workspace
|
||||
suite and release-grade artifact gates run at scheduled, explicit `ci:full`,
|
||||
critical integration/security, and release milestones.
|
||||
|
||||
Run the selector contract locally with:
|
||||
|
||||
```bash
|
||||
pnpm test:ci-scope
|
||||
```
|
||||
|
||||
Release validation remains the final authority: clean-clone build, full unit
|
||||
and integration suites, applicable E2E, and signed artifact verification.
|
||||
|
||||
The operational target for the default pull-request tier is under 10 minutes,
|
||||
with no workspace tests, coverage, or desktop/container packaging. This is a
|
||||
target rather than an SLA; dependency installation and hosted-runner
|
||||
availability still vary. Behavior changes should include coverage that the
|
||||
next declared milestone can exercise.
|
||||
|
||||
Optional `Desktop Artifacts`, packaging previews, and release workflows are not
|
||||
merge blockers outside their path boundary. If one starts without providing
|
||||
evidence required by the pull request, continue based on required checks;
|
||||
maintainers may cancel the redundant run.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a [GitHub Discussion](https://github.com/BradGroux/veritas-kanban/discussions) or reach out to the maintainers.
|
||||
|
|
|
|||
83
Dockerfile
83
Dockerfile
|
|
@ -5,18 +5,19 @@
|
|||
# 1. deps — Install all workspace dependencies (shared cache layer)
|
||||
# 2. build-shared — Build the shared package
|
||||
# 3. build-web — Build React frontend with Vite
|
||||
# 4. build-server — Compile Express server TypeScript
|
||||
# 5. production — Minimal runtime image
|
||||
# 4. build-server — Compile the Express server TypeScript
|
||||
# 5. production-deps — Install the server-only runtime closure
|
||||
# 6. production — Minimal runtime image
|
||||
#
|
||||
# Target image size: < 200MB
|
||||
# Target image size: < 200,000,000 bytes on arm64; < 600,000,000 bytes on amd64
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Install dependencies (shared across build stages)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
FROM node:22-alpine3.24 AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -27,8 +28,10 @@ COPY server/package.json ./server/
|
|||
COPY web/package.json ./web/
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp/package.json ./mcp/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# Install all dependencies (dev + prod) for building
|
||||
ENV HUSKY=0
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -61,45 +64,54 @@ COPY server/ ./server/
|
|||
RUN pnpm --filter @veritas-kanban/server build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5: Production runtime
|
||||
# Stage 5: Install the server-only production dependency closure
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Security: run as non-root
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S veritas -u 1001 -G nodejs
|
||||
FROM node:22-alpine3.24 AS production-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config for pnpm (include real web/package.json for lockfile integrity)
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
COPY web/package.json ./web/
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp/package.json ./mcp/
|
||||
COPY scripts/ ./scripts/
|
||||
RUN corepack enable && \
|
||||
corepack prepare pnpm@11.1.1 --activate && \
|
||||
HUSKY=0 pnpm install --frozen-lockfile --prod --filter @veritas-kanban/server... && \
|
||||
rm -rf /root/.cache/node/corepack /root/.local/share/pnpm/store /root/.local/share/pnpm/.tools
|
||||
|
||||
# Install production-only dependencies
|
||||
# --ignore-scripts: skip husky prepare hook (not needed in container)
|
||||
# Note: web deps get installed to satisfy the lockfile, but we remove them
|
||||
# since the frontend is pre-built as static assets
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts && \
|
||||
rm -rf web/node_modules && \
|
||||
pnpm store prune
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 6: Production runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
# The matching Alpine base keeps Node's musl ABI while excluding npm,
|
||||
# Corepack, headers, and package-manager tooling from the runtime image.
|
||||
FROM alpine:3.24 AS production
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=build-shared /app/shared/dist ./shared/dist
|
||||
COPY --from=build-server /app/server/dist ./server/dist
|
||||
COPY --from=build-web /app/web/dist ./web/dist
|
||||
RUN apk add --no-cache ca-certificates libstdc++ && \
|
||||
addgroup -g 1001 -S nodejs && \
|
||||
adduser -S veritas -u 1001 -G nodejs
|
||||
|
||||
# Create data directories for persistent storage and runtime config
|
||||
# Note: services resolve .veritas-kanban from both cwd/.. and cwd directly,
|
||||
# so we create it at /app/ level AND ensure server/ is writable for services
|
||||
# that use process.cwd()/.veritas-kanban when WORKDIR is /app/server
|
||||
RUN mkdir -p /app/data /app/.veritas-kanban /app/tasks && \
|
||||
chown -R veritas:nodejs /app/data /app/.veritas-kanban /app/tasks /app/server
|
||||
COPY --from=production-deps /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only the resolved server runtime closure. The platform-specific Codex
|
||||
# binary remains available, while npm, pnpm, workspace manifests, and build
|
||||
# tooling never enter the production stage.
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/server/node_modules ./server/node_modules
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/shared/package.json ./shared/package.json
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/server/package.json ./server/package.json
|
||||
|
||||
# Copy only built runtime artifacts. CLI, MCP, frontend dependencies, source,
|
||||
# and build tooling never enter the production stage.
|
||||
COPY --from=build-shared --chown=veritas:nodejs /app/shared/dist ./shared/dist
|
||||
COPY --from=build-server --chown=veritas:nodejs /app/server/dist ./server/dist
|
||||
COPY --from=build-web --chown=veritas:nodejs /app/web/dist ./web/dist
|
||||
|
||||
# Create the single volume-backed storage root. Runtime state is stored at
|
||||
# /app/data/.veritas-kanban and task data at /app/data/tasks.
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R veritas:nodejs /app/data /app/server
|
||||
|
||||
# Switch to non-root user
|
||||
USER veritas
|
||||
|
|
@ -115,8 +127,7 @@ EXPOSE 3001
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||
|
||||
# Set working directory to server/ so path.resolve(cwd, '..') resolves to /app
|
||||
# (Services use process.cwd()/.. to find .veritas-kanban and tasks directories)
|
||||
# The runtime path contract is independent of cwd when DATA_DIR is set.
|
||||
WORKDIR /app/server
|
||||
|
||||
# Start server
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document outlines the manual integration steps required to complete feature
|
|||
**Action:** Export the new prompt registry types
|
||||
|
||||
Add these lines to the exports:
|
||||
|
||||
```typescript
|
||||
// Prompt Registry Types
|
||||
export type {
|
||||
|
|
@ -41,9 +42,10 @@ app.use('/api/prompt-registry', promptRegistryRouter);
|
|||
**Location:** Find where other routes like `templates`, `tasks`, `chat` are registered. Add the prompt registry route in the same pattern.
|
||||
|
||||
**Expected pattern:**
|
||||
|
||||
```typescript
|
||||
app.use('/api/templates', templateRouter);
|
||||
app.use('/api/prompt-registry', promptRegistryRouter); // <-- Add this line
|
||||
app.use('/api/prompt-registry', promptRegistryRouter); // <-- Add this line
|
||||
app.use('/api/tasks', taskRouter);
|
||||
```
|
||||
|
||||
|
|
@ -151,7 +153,7 @@ These files are complete and require no further changes:
|
|||
|
||||
## Notes
|
||||
|
||||
- All files follow existing code patterns (zod validation, gray-matter for storage, React Query hooks)
|
||||
- All files follow existing code patterns (zod validation, YAML frontmatter storage, React Query hooks)
|
||||
- No external dependencies added beyond what's already in use
|
||||
- Storage uses file-based approach (consistent with template-service)
|
||||
- Endpoints follow REST conventions
|
||||
|
|
@ -160,6 +162,7 @@ These files are complete and require no further changes:
|
|||
## Questions?
|
||||
|
||||
Refer to reference implementations:
|
||||
|
||||
- **Template pattern:** `server/src/services/template-service.ts`
|
||||
- **Route pattern:** `server/src/routes/templates.ts`
|
||||
- **API client pattern:** `web/src/lib/api/entities.ts`
|
||||
|
|
|
|||
255
README.md
255
README.md
|
|
@ -10,11 +10,11 @@ Start with a visual Kanban board. Add CLI, MCP, OpenClaw, Squad Chat webhooks, w
|
|||
|
||||
[](https://github.com/BradGroux/veritas-kanban/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](CONTRIBUTING.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> 🎬 [Watch the full demo video](https://bradgroux.github.io/veritas-kanban/demo/)
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ Want to take the easy way out? Ask your agent:
|
|||
Clone and set up veritas-kanban locally using the board-only setup path first. Install dependencies with pnpm, copy server/.env.example to server/.env, and start the dev server. Verify the UI at localhost:3000 and the API health endpoint at localhost:3001/api/health. Do not configure OpenClaw, MCP, Squad Chat webhooks, workflows, or notifications unless I explicitly ask for that layer.
|
||||
```
|
||||
|
||||
Want to do it yourself? Get up and running in under 5 minutes:
|
||||
Want to do it yourself? Choose the packaged Mac app or a local source checkout:
|
||||
|
||||
For the packaged Mac desktop app:
|
||||
|
||||
|
|
@ -49,6 +49,11 @@ brew tap BradGroux/tap
|
|||
brew install --cask veritas-kanban
|
||||
```
|
||||
|
||||
Existing desktop users should follow the
|
||||
[routine Mac upgrade](docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md#routine-mac-desktop-upgrade)
|
||||
path so backup, heartbeat pause, app replacement, launch, and exact-version
|
||||
server readiness happen in the right order.
|
||||
|
||||
For local source development:
|
||||
|
||||
```bash
|
||||
|
|
@ -59,7 +64,10 @@ cp server/.env.example server/.env # Edit to change VERITAS_ADMIN_KEY
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) for source runs, or launch `Veritas Kanban.app` from `/Applications` after the Homebrew install. The board auto-seeds with example tasks on first run so you can explore right away.
|
||||
Open [http://localhost:3000](http://localhost:3000) for source runs, or install
|
||||
the signed/notarized Mac app with
|
||||
`brew tap BradGroux/tap && brew install --cask veritas-kanban`. The board
|
||||
auto-seeds with example tasks on first run so you can explore right away.
|
||||
|
||||
A working board means the UI loads and `http://localhost:3001/api/health` returns healthy. Agent-ready and external wake/delivery-ready are separate setup levels; use [Setup Paths](docs/SETUP-PATHS.md#readiness-levels) before adding those layers.
|
||||
|
||||
|
|
@ -84,28 +92,39 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
|
||||
- [Setup Paths](docs/SETUP-PATHS.md) — start here for board-only, CLI, MCP, OpenClaw, and self-hosted paths without mixing optional layers into first-run setup.
|
||||
- [Getting Started Guide](docs/GETTING-STARTED.md) — zero ➝ agent-ready in 5 minutes, plus sanity checks and prompt registry tips.
|
||||
- [MCP Server Guide](docs/mcp/README.md) — optional MCP setup, 36 tools, architecture, tool catalog, security model, and read/write smoke checks.
|
||||
- [MCP Server Guide](docs/mcp/README.md) — optional MCP setup, 42 tools, architecture, tool catalog, security model, and read/write smoke checks.
|
||||
- [Agent Guide and `AGENTS.md` Template](docs/AGENTS-TEMPLATE.md) — shared managed-run protocol, external self-reporting template, and unmanaged MCP setup.
|
||||
- [Agent Providers](docs/AGENT-PROVIDERS.md) — evidence-backed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw, and optional model profiles.
|
||||
- [v6 Agent Runtime Control Plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md) — authority, adapter, lifecycle, approval, tool, credential, Buzz, and certification boundaries.
|
||||
- [Phase Capability Profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md) — versioned execution-phase authority contracts, deterministic intersections, exact-path plan artifacts, and current delivery boundaries.
|
||||
- [Phase Transition Journal](docs/architecture/PHASE-TRANSITION-JOURNAL.md) — durable compare-and-set transitions, approval and override controls, restart recovery, REST, and CLI operations.
|
||||
- [Knowledge Collections v1](docs/architecture/KNOWLEDGE-COLLECTIONS-V1.md) — immutable sources, cited pages, stable identity, bidirectional links, and reversible reviewed ingestion with file/SQLite parity.
|
||||
- [OpenAI Codex Integration Roadmap](docs/CODEX-INTEGRATION.md) — optional local execution, SDK sessions, cloud delegation, MCP setup, workflows, telemetry, and release QA.
|
||||
- [Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) — authority model, HermesAgent roster, QA evidence gate, and GitHub-backed task templates.
|
||||
- [Codex Integration SOP](docs/SOP-codex-integration.md) & [Codex Workflow Examples](docs/EXAMPLES-codex-workflows.md) — operational playbooks for using Codex as a first-class Veritas agent.
|
||||
- [API Reference](docs/API-REFERENCE.md) — Auth, endpoints, request/response examples, WebSocket, common workflows.
|
||||
- [v5 Identity and RBAC Model](docs/IDENTITY-RBAC.md) — users, workspaces, memberships, roles, agent tokens, permission matrix, migration, and UX flows.
|
||||
- [v5 Mantine Migration Plan](docs/UI-MANTINE-MIGRATION.md) — component inventory, migration order, retained custom surfaces, rollback strategy, and cleanup gates.
|
||||
- [v5 GA Checklist](docs/V5-GA-CHECKLIST.md) — release-gate reference, follow-up evidence tracking, Mantine visual/accessibility cleanup evidence, and bundle checks.
|
||||
- [v5 Visual Tour](docs/V5-VISUAL-TOUR.md) — release-safe dummy screenshots and GIFs for the v5 board, task work view, Maintenance Center, and mobile/PWA shell.
|
||||
- [v5 Upgrade, Install, Remote, And Admin Guide](docs/V5-UPGRADE-INSTALL-ADMIN-GUIDE.md) — fresh install, v4-to-v5 upgrade, desktop setup, remote/server, mobile/PWA, admin, backup, and diagnostics paths.
|
||||
- [v5 Compatibility And Release Policy](docs/V5-COMPATIBILITY-AND-RELEASE-POLICY.md) — supported version combinations, update channels, stale-client behavior, rollback limits, and release validation.
|
||||
- [v5 Release Notes](docs/V5-RELEASE-NOTES.md) — breaking changes, migration warnings, published v5.0.0 artifacts, and deferred post-GA backlog.
|
||||
- [v5 Desktop Architecture ADR](docs/architecture/ADR-0001-v5-desktop-architecture.md) — shell decision, native/server boundaries, connection modes, lifecycle, packaging, and security model.
|
||||
- [Identity and RBAC Model](docs/IDENTITY-RBAC.md) — users, workspaces, memberships, roles, agent tokens, permission matrix, migration, and UX flows.
|
||||
- [v6 GA Checklist](docs/V6-GA-CHECKLIST.md) — release gates for harness certification, migration, runtime, desktop, and distribution evidence.
|
||||
- [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) — release-safe views of provider support, Buzz setup, approvals, and run evidence.
|
||||
- [v6 Upgrade, Install, Remote, And Admin Guide](docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md) — fresh install, v5-to-v6 upgrade, harness setup, desktop, backup, and diagnostics paths.
|
||||
- [v6 Compatibility And Release Policy](docs/V6-COMPATIBILITY-AND-RELEASE-POLICY.md) — provider support tiers, tested builds, platform combinations, update channels, and rollback limits.
|
||||
- [v6 Release Notes](docs/V6-RELEASE-NOTES.md) — user-facing highlights, stabilization fixes, install/upgrade steps, behavior changes, and known limits.
|
||||
- [Desktop Architecture ADR](docs/architecture/ADR-0001-v5-desktop-architecture.md) — shell decision, native/server boundaries, connection modes, lifecycle, packaging, and security model.
|
||||
- [Post-GA Desktop Agent Workbench Spec](docs/DESKTOP-AGENT-WORKBENCH.md) — desktop workbench UX, run controls, approvals, evidence, native affordances, and safety coverage.
|
||||
- [Post-GA Native Mobile Offline ADR](docs/architecture/ADR-0003-post-ga-native-mobile-offline.md) — native mobile authority model, offline queue semantics, conflict handling, and security review.
|
||||
- [Post-GA Cloud Sync And Hosted SaaS ADR](docs/architecture/ADR-0004-post-ga-cloud-sync-hosted-saas.md) — optional hosted model, tenant isolation, lifecycle, support, cost, and migration boundaries.
|
||||
- [Self-Hosting Guide](docs/guides/SELF_HOST.md) — production deployment, reverse proxy, auth hardening, Docker, and backups.
|
||||
- [Agent Task Workflow SOP](docs/SOP-agent-task-workflow.md) — lifecycle, API/CLI snippets, prompts.
|
||||
- [Squad Chat Protocol](docs/SQUAD-CHAT-PROTOCOL.md) — agent messaging, system events (spawned/completed/failed), model attribution, and helper scripts.
|
||||
- [Buzz Integration](docs/BUZZ-INTEGRATION.md) — signed Squad Chat bridging,
|
||||
explicit persona/team import, and a separate disabled-by-default
|
||||
`buzz-agent` profile under the generic ACP provider.
|
||||
- [Agent Providers](docs/AGENT-PROVIDERS.md#grok-build-acp) — exact-version
|
||||
Grok Build, GitHub Copilot CLI, Buzz Agent, Claude Code, and Codex runtime
|
||||
setup, safety policy, and known limitations.
|
||||
- [Sprint Planning SOP](docs/SOP-sprint-planning.md) — epic → sprint → task breakdown.
|
||||
- [Multi-Agent Orchestration](docs/SOP-multi-agent-orchestration.md) — PM + worker handoffs.
|
||||
- [Cross-Model Code Review](docs/SOP-cross-model-code-review.md) — enforce Claude ↔ GPT reviews.
|
||||
- [Optional Cross-Model Code Review](docs/SOP-cross-model-code-review.md) — add an independent model only when the issue or release owner requires it.
|
||||
- [Agent Governance SOPs](docs/) — [Policy engine](docs/SOP-agent-policy-engine.md), [drift detection](docs/SOP-behavioral-drift-detection.md), [decision audit](docs/SOP-decision-audit-trail.md), [output evaluation](docs/SOP-output-evaluation.md), [user feedback](docs/SOP-user-feedback.md).
|
||||
- [Operational SOPs](docs/) — [Broadcasts](docs/SOP-broadcasts.md), [delegation](docs/SOP-delegation.md), [deliverables](docs/SOP-deliverables.md), [work products](docs/features/work-products.md), [prompt registry](docs/SOP-prompt-registry.md), [squad chat](docs/SOP-squad-chat.md), [system health](docs/SOP-system-health-monitoring.md).
|
||||
- [Best Practices](docs/BEST-PRACTICES.md) & [Tips + Tricks](docs/TIPS-AND-TRICKS.md) — patterns, shortcuts, integrations.
|
||||
|
|
@ -123,7 +142,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
|
||||
2. **Never trigger agents from uncontrolled inputs.** Don't let inbound emails, webhooks from third parties, or public form submissions automatically spawn agent work. An attacker who can craft an input can control your agent.
|
||||
|
||||
3. **Principle of least privilege.** Give agents the minimum permissions they need. Use the `agent` role (not `admin`) for API keys. Restrict file system access. Don't run agents as root.
|
||||
3. **Principle of least privilege.** Give agents the minimum permissions they need. Use the `agent` role (not `admin`) for API keys. Restrict file system access with sandbox policy presets, enforce run budgets before long-running work, and don't run agents as root.
|
||||
|
||||
4. **Review before merge.** Agents can write code — that doesn't mean the code is correct or safe. Always review agent-generated code before merging to production branches. Use the built-in code review workflow.
|
||||
|
||||
|
|
@ -133,9 +152,9 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
|
||||
7. **Rotate credentials regularly.** If an agent has access to API keys, tokens, or secrets, rotate them on a schedule. Don't embed real credentials in task descriptions or prompts.
|
||||
|
||||
8. **Isolate environments.** Run agents in containers, VMs, or sandboxed environments when possible. Keep agent workspaces separate from sensitive data.
|
||||
8. **Isolate environments.** Run agents in containers, VMs, or sandboxed environments when possible. Keep agent workspaces separate from sensitive data, use deny-by-default network presets for untrusted work, and broker credentials instead of exposing broad environment variables.
|
||||
|
||||
**The bottom line:** Agentic AI is transformational, but it amplifies both your capabilities and your mistakes. Plan accordingly, start small, and add autonomy gradually as you build confidence in your guardrails.
|
||||
**The bottom line:** Agents amplify both useful work and mistakes. Start locally, keep permissions narrow, and add autonomy only after the smaller setup is understood and verified.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -143,21 +162,31 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
|
||||
### 🛡️ Agent Governance
|
||||
|
||||
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted criteria profiles (regex, keyword, numeric range, custom expressions).
|
||||
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Sandbox Policy Presets** — Assign reusable filesystem, network, environment, and credential rules to agents, workflow agents, or one-off runs; unsupported required controls fail closed before launch with redacted audit traces. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted bounded criteria profiles (regex, keyword, numeric range, occurrence ratio).
|
||||
|
||||
### 🤖 Agent Orchestration
|
||||
|
||||
Spawn autonomous coding agents on tasks when you choose to connect an agent runner. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Squad Chat gives agents a shared local communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
Spawn autonomous coding agents on tasks when you choose to connect an agent runner. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Team roster manifests and workspace capability discovery route work to the right agent or trusted workspace before a run starts. Shared live run sessions let workspace members observe an active task run, co-drive with attributed messages, or fork a clean follow-up task without taking over the parent run. Squad Chat gives agents a shared local communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
|
||||

|
||||
|
||||
### 🧭 Veritas Cutover + Hermes Support
|
||||

|
||||
|
||||
Veritas now documents the GitHub-backed operating model for Codex and HermesAgent work. The new cutover guide names Veritas as the source of truth, routes HermesAgent/Hermes Gateway as the control plane for agent execution, keeps Mission Control focused on display/control, and makes GitHub Issues/PRs/reviews/CI the implementation record. It also adds the active Hermes roster, required QA evidence gates, and copy/paste task templates for product specs, research/revenue intake, and approval-gated client workflows.
|
||||
Desktop Board Chat and Squad Chat open in a bounded right-side Workbench dock by
|
||||
default. Switch to Bottom when vertical space is preferable; both orientations
|
||||
keep the board, header, close control, and keyboard recovery paths reachable.
|
||||
|
||||

|
||||
|
||||
### 🧭 Provider And Cutover Operations
|
||||
|
||||
The cutover guide documents a GitHub-backed operating model for Codex and HermesAgent work. Veritas remains the source of truth, HermesAgent/Hermes Gateway can provide the execution control plane, and GitHub Issues, pull requests, reviews, and CI remain the durable implementation record. Copy/paste task templates cover product specs, research intake, and approval-gated client workflows.
|
||||
|
||||
### 🧠 OpenAI Codex Integration
|
||||
|
||||
Codex can run as a first-class Veritas agent through local `codex exec`, SDK-backed sessions, GitHub-native `@codex` delegation, workflow-engine steps, review actions, Settings health checks, and MCP access to the board. The docs include a roadmap, SOP, workflow examples, and an AGENTS template so Codex work can be started, tracked, reviewed, and released through the same Veritas lifecycle as other agents.
|
||||
Codex can run as the default first-class Veritas agent through local `codex exec`, SDK-backed sessions, GitHub-native `@codex` delegation, workflow-engine steps, review actions, Settings health checks, and MCP access to the board. Ollama Local, Ollama Cloud, and LM Studio Local profiles are optional routing targets for users who want local/server-hosted models or explicit cloud model execution. The docs include a roadmap, SOP, workflow examples, and an AGENTS template so provider-backed work can be started, tracked, reviewed, and released through the same Veritas lifecycle as other agents.
|
||||
|
||||

|
||||
|
||||
### 📊 Customizable Dashboard
|
||||
|
||||
|
|
@ -181,13 +210,17 @@ Not just cards on a board. Tasks have dependency graphs with cycle detection, cr
|
|||
|
||||
Isolated worktrees per task — no branch switching, no conflicts. Built-in code review with unified diff viewer and inline comments. Approval workflows (approve, request changes, reject). Visual merge conflict resolution. Create GitHub PRs directly from the task detail panel. Bidirectional GitHub Issues sync with label mapping.
|
||||
|
||||
### 📁 Zero Infrastructure
|
||||
### 📁 Local-First Storage
|
||||
|
||||
Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no Redis, and no Docker required for local use. Clone, `pnpm install`, `pnpm dev` — done. Everything is `grep`-friendly, version-controllable, and human-readable. Back up your entire board with `git push`.
|
||||
File storage remains the zero-infrastructure default: tasks are Markdown,
|
||||
settings are JSON, and workflows are YAML. SQLite is available for governed
|
||||
multi-user and higher-integrity deployments; Redis and Docker are not required
|
||||
for local use. Clone, `pnpm install`, and `pnpm dev` to start. Back up the
|
||||
complete configured storage root, not only the Git-tracked board files.
|
||||
|
||||
### 🔌 Optional Integration Surfaces
|
||||
|
||||
- **MCP Server** — 36 tools across 8 categories via Model Context Protocol
|
||||
- **MCP Server** — 42 tools across 9 categories via Model Context Protocol
|
||||
- **CLI** — `vk begin <id>` / `vk done <id> "summary"` replaces 6 API calls with 2 commands
|
||||
- **REST API** — Full lifecycle management. If it can make HTTP calls, it can drive the board.
|
||||
|
||||
|
|
@ -216,12 +249,33 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Custom agents** — Add your own agents with any name and command; not limited to built-in types
|
||||
- **Platform-agnostic API** — REST endpoints work with any agentic platform
|
||||
- **HermesAgent support** — documents HermesAgent/Hermes Gateway as the active control plane, with Veritas as the GitHub-backed source of truth
|
||||
- **OpenAI Codex support** — Local CLI runs, SDK-backed sessions, Codex Cloud delegation, workflow steps, review actions, health checks, and MCP setup
|
||||
- **OpenAI Codex support** — Local CLI runs, SDK-backed sessions, Codex Cloud delegation, workflow steps, review actions, health checks, MCP setup, and default routing for fresh installs
|
||||
- **Local LLM provider profiles** — Optional Ollama Local, Ollama Cloud, and LM Studio Local profiles with health metadata and routing support
|
||||
- **Team roster routing** — Workspace coordinator/member manifests route tasks by capabilities, reviewers, fallbacks, and escalation posture
|
||||
- **Workspace capability discovery** — Trusted workspace capability catalogs let Veritas package delegated work intake before handing work across workspace boundaries
|
||||
- **Agent profile packages** — Portable YAML/JSON packages that bundle role, runtime, prompt, tools, permissions, sandbox, budget, workflow, and health metadata for reusable launches
|
||||
- **Phase capability contract** — Built-in explore, plan, implement, verify, and
|
||||
publish profiles compile parent, phase, agent, sandbox, tool, and launch
|
||||
authority without widening it. The current slice defines the shared contract
|
||||
and compiler; runtime transition and enforcement work remains explicitly
|
||||
tracked.
|
||||
- **Provider-owned task envelopes** — OpenClaw, Codex CLI, Codex SDK, and Hermes render the same immutable task contract through adapter-owned transports with explicit commit policy and completion posture
|
||||
- **Decision review sessions** — Multi-participant decision reviews with independent responses, critique rounds, final synthesis packets, work-product attachment, and decision audit links
|
||||
- **Shared live run sessions** — Create workspace-scoped view, co-drive, or fork links for active task runs; viewers receive live output and events, editors send attributed messages and mobile-safe approval responses, and forks create linked tasks without mutating the parent run
|
||||
- **Sandbox policy presets** — Built-in and custom presets for filesystem scope, network egress, environment passthrough, and credential brokering, with Settings dry-runs before agent launch
|
||||
- **Agent budget enforcement** — Workspace, agent, workflow, workflow-agent, and per-run caps for tokens, cost, tool calls, runtime, retries, and fan-out with auditable warn, approval, downgrade, pause, or cancel decisions
|
||||
- **Optional OpenClaw support** — Native integration with [OpenClaw](https://github.com/openclaw/openclaw) when you want OpenClaw to execute or wake agents
|
||||
- **Squad Chat** — Real-time agent-to-agent communication with WebSocket updates, system lifecycle events, model attribution per message, and configurable display names
|
||||
- **@Mention notifications** — @agent-name parsing in comments, thread subscriptions
|
||||
- **Broadcast Notifications** — Priority-based persistent notifications with read receipts and agent-specific delivery
|
||||
- **Squad Chat Webhook** — Configurable webhooks (generic HTTP or OpenClaw Direct) for external agent integration
|
||||
- **Buzz Communication Adapter** — Native signed root/reply bridge between one mapped Buzz community channel and Squad Chat, with durable replay, ambiguous-send reconciliation, and operator-confirmed persona/team definition materialization
|
||||
- **Buzz Agent ACP profile** — Generic ACP execution with pinned Buzz v0.4.24
|
||||
identity/capability evidence, safe environment allowlists, and honest
|
||||
no-resume and stdio-only MCP posture
|
||||
- **GitHub Copilot CLI ACP profile** — Generic ACP execution with a system-owned
|
||||
stdio/public-preview launch baseline, exact v1.0.74 compatibility evidence,
|
||||
bounded restrictive process policy, and honest source/authentication limits
|
||||
- **Agent registry** — Service discovery with heartbeat tracking, capabilities, and live status
|
||||
- **Multi-agent dashboard** — Real-time sidebar with expandable agent cards, status indicators
|
||||
- **Multi-agent task assignment** — Assign multiple agents per task with color-coded chips
|
||||
|
|
@ -273,7 +327,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Activity page** — Status history with clickable task navigation, color-coded badges, and daily summary
|
||||
- **Daily standup summary** — Generate standup reports via API or CLI (`vk summary standup`)
|
||||
- **Task Templates** — Create reusable templates with defaults, subtasks, and multi-task blueprints
|
||||
- **Documentation freshness** — Steward workflow with freshness headers and automated staleness detection
|
||||
- **Documentation freshness** — Registry-backed review dates, thresholds, scores, and staleness alerts
|
||||
- **Cost prediction** — Multi-factor cost estimation for tasks
|
||||
|
||||
#### Dashboard
|
||||
|
|
@ -310,7 +364,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
#### Integration
|
||||
|
||||
- **CLI** — `vk` command for terminal workflows
|
||||
- **MCP Server** — 36 tools across 8 categories via Model Context Protocol
|
||||
- **MCP Server** — 42 tools across 9 categories via Model Context Protocol
|
||||
- **Codex MCP setup** — documented `codex mcp add veritas-kanban` setup for local and API-key-backed deployments
|
||||
- **Notifications** — Teams integration for task updates
|
||||
|
||||
|
|
@ -320,16 +374,16 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
| ------------------- | ------------------------------------ | -------------------------------- |
|
||||
| **Frontend** | React, Vite, Tailwind CSS, Shadcn UI | React 19, Vite 7.3, Tailwind 4.2 |
|
||||
| **Backend** | Express, WebSocket | Express 5.2 |
|
||||
| **Language** | TypeScript (strict mode) | 6.0 |
|
||||
| **Storage** | Markdown files with YAML frontmatter | gray-matter |
|
||||
| **Git** | simple-git, worktree management | — |
|
||||
| **Testing** | Playwright (E2E), Vitest (unit) | Playwright 1.58, Vitest 4 |
|
||||
| **Runtime** | Node.js | 22+ |
|
||||
| **Package Manager** | pnpm | 9+ |
|
||||
| Layer | Technology | Version |
|
||||
| ------------------- | ------------------------------------- | ------------------------------------------- |
|
||||
| **Frontend** | React, Vite, Tailwind CSS, Mantine UI | React 19, Vite 8, Tailwind 4.3, Mantine 9.5 |
|
||||
| **Backend** | Express, WebSocket | Express 5.2 |
|
||||
| **Language** | TypeScript (strict mode) | 6.0 |
|
||||
| **Storage** | Markdown files with YAML frontmatter | yaml + local frontmatter helper |
|
||||
| **Git** | simple-git, worktree management | — |
|
||||
| **Testing** | Playwright (E2E), Vitest (unit) | Playwright 1.62, Vitest 4.1 |
|
||||
| **Runtime** | Node.js | 22.22.1+ |
|
||||
| **Package Manager** | pnpm | 11.1.1 (pinned) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -348,7 +402,7 @@ Veritas Kanban is neither. It's the **visual command center for agentic work**
|
|||
| **YAML workflow pipelines** | ✅ Loops, gates, parallel | ⚠️ Code-defined only | ❌ |
|
||||
| **Real-time agent dashboard** | ✅ Status, model attribution | ❌ | ❌ |
|
||||
| **Agent communication** | ✅ Squad Chat with lifecycle events | ⚠️ Internal only | ❌ |
|
||||
| **MCP server** | ✅ 36 tools | ❌ | ❌ |
|
||||
| **MCP server** | ✅ 42 tools | ❌ | ❌ |
|
||||
| **CLI** | ✅ Full lifecycle | ❌ | ⚠️ Limited |
|
||||
| **Git worktrees + code review** | ✅ Built-in | ❌ | ❌ |
|
||||
| **Task persistence** | ✅ Markdown files | ❌ In-memory | ✅ Database |
|
||||
|
|
@ -432,7 +486,7 @@ veritas-kanban/ ← pnpm monorepo
|
|||
└── agent-requests/
|
||||
```
|
||||
|
||||
**Data flow:** Web ↔ REST API / WebSocket ↔ Server ↔ Markdown/YAML files on disk
|
||||
**Data flow:** Web ↔ REST API / WebSocket ↔ Server ↔ configured file or SQLite storage
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -461,7 +515,7 @@ curl -H "X-API-Version: v1" http://localhost:3001/api/tasks
|
|||
|
||||
> 📖 **Comprehensive CLI guide:** [docs/CLI-GUIDE.md](docs/CLI-GUIDE.md) — installation, every command, scripting examples, and tips.
|
||||
|
||||
Manage your entire task lifecycle with two commands.
|
||||
Handle the common start-and-complete task lifecycle with two commands.
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
|
|
@ -483,6 +537,9 @@ vk doctor # Redacted setup health report
|
|||
vk doctor --json # Support-safe JSON report
|
||||
vk snapshot --format markdown # Redacted runtime support snapshot
|
||||
vk prompts import prompt-registry --dry-run
|
||||
vk sqlite journal status --json # Journal maintenance and override posture
|
||||
vk tool-servers list --json # Registered run-scoped MCP servers
|
||||
vk tool-servers discover <id> # Refresh version-bound tool discovery
|
||||
```
|
||||
|
||||
Validates Node version, server health, API auth, and optionally creates a welcome task to get you started.
|
||||
|
|
@ -498,6 +555,11 @@ attaching it to a support handoff.
|
|||
`vk prompts import` syncs file-based prompt templates into the runtime registry.
|
||||
Run with `--dry-run` first; rerun with `--force` only when you want disk content
|
||||
to replace a differing runtime template.
|
||||
`vk sqlite journal preview` and `apply` stage governed journal conversion for
|
||||
the configured authoritative database. Conversion executes before server
|
||||
startup, with a verified backup, rollback, and single-host ownership policy;
|
||||
pre-close failures revert mode while SQLite exclusivity is still held, and the
|
||||
live API never flips journal mode in place.
|
||||
|
||||
### Workflow Commands
|
||||
|
||||
|
|
@ -585,7 +647,16 @@ vk github mappings # List issue↔task mappings
|
|||
```bash
|
||||
vk agents:pending # List pending agent requests
|
||||
vk agents:status <id> # Check if agent running
|
||||
vk agents:complete <id> -s # Mark agent complete
|
||||
vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>
|
||||
vk launch-preview <id> --json # Inspect effective launch evidence without dispatch
|
||||
vk profiles list # List reusable agent profile packages
|
||||
vk profiles validate ./agent.yml # Validate a package before import
|
||||
vk profiles import ./agent.yml # Import or replace a package
|
||||
vk start <task> --profile <id> # Launch a task with a profile package
|
||||
vk agent:resume <task> --source-attempt <id> -m "Continue the work"
|
||||
vk agent:fork <task> --source-attempt <id> --fork-turn <id> -m "Try another path"
|
||||
vk agent:steer <task> --attempt <id> -m "Use the smaller fix"
|
||||
vk agent:compact <task> --attempt <id>
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
|
@ -612,6 +683,7 @@ VK also documents the Codex and Hermes operating model:
|
|||
- **HermesAgent/Hermes Gateway is the active control plane** for the named Hermes roster and execution routing.
|
||||
- **Mission Control is display/control only** in the cutover model, while GitHub Issues, PRs, review comments, and CI remain the durable delivery record.
|
||||
- **OpenAI Codex can be a first-class agent** through local CLI runs, SDK sessions, Codex Cloud delegation, workflow steps, review actions, and MCP access.
|
||||
- **Ollama and LM Studio profiles are first-class routing targets** for local/server-hosted model workflows, with Ollama Cloud available when cloud execution is intentional.
|
||||
|
||||
### How It Works
|
||||
|
||||
|
|
@ -668,17 +740,31 @@ vk agents:pending
|
|||
# then call the completion endpoint automatically.
|
||||
```
|
||||
|
||||
### Codex + HermesAgent
|
||||
### Managed agent harnesses and external clients
|
||||
|
||||
- Follow the [Codex Integration SOP](docs/SOP-codex-integration.md) when Codex should implement, review, or delegate Veritas tasks.
|
||||
- Use the [Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) when routing work through the HermesAgent roster, enforcing QA evidence, or creating GitHub-backed task templates.
|
||||
- Configure Codex MCP access with the [MCP Server Guide](docs/mcp/README.md#codex) so Codex reads and updates Veritas through typed tools instead of one-off HTTP calls.
|
||||
- Start with the [Agent Guide and `AGENTS.md` Template](docs/AGENTS-TEMPLATE.md)
|
||||
so managed and external agents do not duplicate lifecycle callbacks or
|
||||
telemetry.
|
||||
- Use the [Agent Providers guide](docs/AGENT-PROVIDERS.md) to enable and operate
|
||||
Buzz Agent, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw,
|
||||
ACP-compatible agents, Ollama, or LM Studio.
|
||||
- Use [Harness Compatibility](docs/HARNESS-COMPATIBILITY.md) and
|
||||
`vk doctor --json` to verify the installed runtime instead of relying on a
|
||||
provider name alone.
|
||||
- Use the [Buzz Integration guide](docs/BUZZ-INTEGRATION.md) for relay,
|
||||
community, persona/team import, ACP execution, and workflow-trigger setup.
|
||||
- Configure unmanaged client access with the
|
||||
[MCP Server Guide](docs/mcp/README.md). Managed runs receive only their
|
||||
selected run-scoped catalog and do not need a separate global VK MCP config.
|
||||
- Follow the [Codex Integration SOP](docs/SOP-codex-integration.md) or
|
||||
[Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) only when those
|
||||
specialized workflows apply.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 MCP Server
|
||||
|
||||
Optional. The MCP server exposes 36 tools across 8 categories (tasks, agents, automation, notifications, summaries, sprints, comments, projects) via [Model Context Protocol](https://modelcontextprotocol.io/). Skip this for board-only use.
|
||||
Optional. The MCP server exposes 42 tools across 9 categories (tasks, agents, automation, notifications, summaries, sprints, comments, projects, and run-scoped tool control) via [Model Context Protocol](https://modelcontextprotocol.io/). Skip this for board-only use.
|
||||
|
||||
**→ [Full MCP documentation](docs/mcp/README.md)** — architecture, quickstart, tool catalog with examples, security model, read/write smoke checks, and troubleshooting.
|
||||
|
||||
|
|
@ -712,7 +798,7 @@ Verify discovery with `openclaw mcp list`. See [Troubleshooting](docs/TROUBLESHO
|
|||
**Troubleshooting MCP connection issues:**
|
||||
|
||||
- **Always restart the MCP client after MCP config changes** — MCP servers are discovered at startup
|
||||
- **Verify tools are available:** Run `openclaw mcp list` to confirm 36 Veritas Kanban tools appear
|
||||
- **Verify tools are available:** Run `openclaw mcp list` to confirm 42 Veritas Kanban tools appear
|
||||
- **When reporting issues, provide:**
|
||||
- OpenClaw version (`openclaw --version`)
|
||||
- VK version and health (`curl http://localhost:3001/api/health`)
|
||||
|
|
@ -754,7 +840,7 @@ pnpm build # Production build
|
|||
pnpm typecheck # TypeScript strict check
|
||||
pnpm lint # ESLint
|
||||
pnpm lint:budget # ESLint with current warning budget
|
||||
pnpm test # Unit tests (Vitest)
|
||||
pnpm test # Canonical unit gate (server, web, CLI, MCP)
|
||||
pnpm test:e2e # E2E tests (Playwright)
|
||||
pnpm test:load:smoke # k6 API smoke test
|
||||
pnpm validate:release # Release readiness checks
|
||||
|
|
@ -764,39 +850,47 @@ pnpm validate:release # Release readiness checks
|
|||
|
||||
## 📚 Documentation
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | -------------------------------------------- |
|
||||
| [Features](docs/FEATURES.md) | Complete feature reference |
|
||||
| [v5 Visual Tour](docs/V5-VISUAL-TOUR.md) | Release-safe dummy screenshots and GIFs |
|
||||
| [API Reference](docs/API-REFERENCE.md) | Auth, endpoints, WebSocket docs |
|
||||
| [CLI Guide](docs/CLI-GUIDE.md) | Comprehensive CLI usage guide |
|
||||
| [Self-Hosting Guide](docs/guides/SELF_HOST.md) | Production deployment, reverse proxy, Docker |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, bare metal, env config |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues & solutions |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute, PR guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting |
|
||||
| [Code of Conduct](CODE_OF_CONDUCT.md) | Community guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Sprint Docs](docs/) | Sprint planning & audit reports |
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | --------------------------------------------------- |
|
||||
| [Features](docs/FEATURES.md) | Complete feature reference |
|
||||
| [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) | Provider, Buzz, approval, and run evidence views |
|
||||
| [API Reference](docs/API-REFERENCE.md) | Auth, endpoints, WebSocket docs |
|
||||
| [CLI Guide](docs/CLI-GUIDE.md) | Comprehensive CLI usage guide |
|
||||
| [Self-Hosting Guide](docs/guides/SELF_HOST.md) | Production deployment, reverse proxy, Docker |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, bare metal, env config |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues and solutions |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute and pull request guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting |
|
||||
| [Code of Conduct](CODE_OF_CONDUCT.md) | Community guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Documentation Index](docs/) | Operator, developer, architecture, and release docs |
|
||||
|
||||
---
|
||||
|
||||
## 📸 v5 Visuals
|
||||
## 📸 Visuals
|
||||
|
||||
<details>
|
||||
<summary><strong>Click to expand v5 screenshots and GIFs</strong></summary>
|
||||
<summary><strong>Click to expand screenshots and GIFs</strong></summary>
|
||||
|
||||
These captures use release-safe dummy content against the current v5 app surfaces. See the [v5 Visual Tour](docs/V5-VISUAL-TOUR.md) for the full set and capture notes.
|
||||
These captures use release-safe dummy content against the current app surfaces. See the [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) for the current release views and retained v5 shell captures.
|
||||
|
||||
### Desktop
|
||||
|
||||
| Board and workflow tour | Task work view |
|
||||
| --------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
|  |  |
|
||||
| Board and workflow tour | Desktop shell |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
| Board overview | Maintenance Center |
|
||||
| Workbench panel | Agent provider settings |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
| Squad Chat coordination | Human reply adapter settings |
|
||||
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
| Task work view | Maintenance Center |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
### Mobile/PWA
|
||||
|
||||
|
|
@ -810,19 +904,22 @@ These captures use release-safe dummy content against the current v5 app surface
|
|||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
Current planning lives in GitHub, not in a stale README checklist:
|
||||
Current work and priorities live in GitHub, not in a version-specific README checklist:
|
||||
|
||||
- [Open issues](https://github.com/BradGroux/veritas-kanban/issues)
|
||||
- [v5.0 roadmap issues](https://github.com/BradGroux/veritas-kanban/issues?q=is%3Aissue%20state%3Aopen%20label%3Arelease%3Av5.0)
|
||||
- [v5.0 SQLite schema and migration strategy](docs/SQLITE-SCHEMA.md)
|
||||
- [v5.0 SQLite migration recovery drill](docs/MIGRATION-RECOVERY.md)
|
||||
- [v5.0 desktop architecture decision](docs/architecture/ADR-0001-v5-desktop-architecture.md)
|
||||
- [post-GA desktop agent workbench spec](docs/DESKTOP-AGENT-WORKBENCH.md)
|
||||
- [Release history](CHANGELOG.md)
|
||||
- [GitHub releases](https://github.com/BradGroux/veritas-kanban/releases)
|
||||
|
||||
Longer-lived product and architecture direction is recorded separately:
|
||||
|
||||
- [v6 agent runtime control plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md)
|
||||
- [phase capability profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md)
|
||||
- [tool control plane v1](docs/architecture/TOOL-CONTROL-PLANE-V1.md)
|
||||
- [post-GA desktop agent workbench](docs/DESKTOP-AGENT-WORKBENCH.md)
|
||||
- [post-GA native mobile offline decision](docs/architecture/ADR-0003-post-ga-native-mobile-offline.md)
|
||||
- [post-GA cloud sync and hosted SaaS decision](docs/architecture/ADR-0004-post-ga-cloud-sync-hosted-saas.md)
|
||||
- [Release history](CHANGELOG.md)
|
||||
|
||||
Use issues for current work and the changelog for shipped work.
|
||||
Use issues for current work, architecture records for durable direction, and the changelog and releases for shipped work.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
70
SECURITY.md
70
SECURITY.md
|
|
@ -27,6 +27,76 @@ Instead, email us at:
|
|||
|
||||
We will work with you to understand the issue and coordinate disclosure.
|
||||
|
||||
## Repository Secret Hygiene
|
||||
|
||||
Runtime authentication state must never be committed. In particular, any
|
||||
`.veritas-kanban/security.json` file may contain password and recovery-key
|
||||
hashes for an enabled installation. The repository ignores these files and
|
||||
enforces the rule locally and in CI:
|
||||
|
||||
```bash
|
||||
pnpm check:security-artifacts
|
||||
```
|
||||
|
||||
Deleting a sensitive file in a later commit does not remove it from Git
|
||||
history. If authentication material is ever tracked, treat it as exposed:
|
||||
|
||||
1. Determine whether it belongs to a real installation.
|
||||
2. Rotate or invalidate the affected password, recovery key, sessions, and
|
||||
related credentials before relying on repository cleanup.
|
||||
3. Review reachable history, forks, and clones and make an explicit decision
|
||||
about coordinated history rewriting versus retaining the invalidated blob.
|
||||
4. Record sensitive evidence in a private security advisory, not a public
|
||||
issue, commit message, test fixture, or log.
|
||||
|
||||
GitHub secret scanning and push protection should remain enabled for the
|
||||
repository. The tracked-file guard complements those services because generic
|
||||
password and recovery-key hashes may not match provider-specific signatures.
|
||||
|
||||
## CI Supply Chain Integrity
|
||||
|
||||
Every external GitHub Action and reusable workflow reference must use a full
|
||||
40-character commit SHA followed by a readable release comment. Local actions
|
||||
under `./.github/actions/` are reviewed with the repository and do not need a
|
||||
remote revision. Docker actions must use a complete SHA-256 image digest.
|
||||
|
||||
The same policy is enforced locally and in CI:
|
||||
|
||||
```bash
|
||||
pnpm check:actions-pinned
|
||||
```
|
||||
|
||||
Dependabot retains the `github-actions` ecosystem entry so reviewed updates can
|
||||
advance both the immutable commit and its release comment.
|
||||
|
||||
## Continuous Security Gates
|
||||
|
||||
The `Security Gates` workflow runs CodeQL and gitleaks for pull requests, main
|
||||
branch updates, and a weekly schedule. CodeQL uses the extended JavaScript and
|
||||
TypeScript security query suite. Repository merge protection blocks CodeQL
|
||||
errors and high-or-critical security alerts. Gitleaks scans the current tree,
|
||||
accepts only the exact reviewed fingerprints in `.gitleaksignore`, and proves
|
||||
that a newly introduced synthetic secret is still rejected.
|
||||
|
||||
Brad Groux owns Dependabot and GitHub security alert triage. New dependency,
|
||||
code-scanning, or secret-scanning alerts must be reviewed privately within two
|
||||
working days. Confirm exploitability and affected releases before opening a
|
||||
public issue. Track confirmed vulnerabilities in a private GitHub security
|
||||
advisory, prioritize critical and high findings for the next safe patch, and
|
||||
record false positives at the narrowest available fingerprint or path. Do not
|
||||
disable a detector class to clear a gate.
|
||||
|
||||
Dependabot vulnerability alerts and security updates, GitHub secret scanning,
|
||||
and push protection must remain enabled. Security-update pull requests use the
|
||||
existing `BradGroux` reviewer assignment in `.github/dependabot.yml`.
|
||||
|
||||
Run the repository controls locally with:
|
||||
|
||||
```bash
|
||||
pnpm check:security-gates
|
||||
pnpm check:gitleaks
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@veritas-kanban/cli",
|
||||
"version": "5.0.0",
|
||||
"version": "6.1.2",
|
||||
"description": "CLI for Veritas Kanban task management",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -9,17 +9,18 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run --maxWorkers=4",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"commander": "^14.0.3",
|
||||
"chalk": "^5.3.0"
|
||||
"commander": "^15.0.0",
|
||||
"chalk": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^26.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"tsx": "^4.22.4"
|
||||
"tsx": "^4.23.12"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Brad Groux <brad@digitalmeld.io>",
|
||||
|
|
|
|||
400
cli/src/__tests__/acp.test.ts
Normal file
400
cli/src/__tests__/acp.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
RunApprovalRequest,
|
||||
RunEventEnvelope,
|
||||
RunEventPage,
|
||||
Task,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { AcpServerView, readAcpStatus, type AcpApiClient } from '../commands/acp.js';
|
||||
|
||||
function request(id: number, method: string, params: unknown): string {
|
||||
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
||||
}
|
||||
|
||||
function event(
|
||||
sequence: number,
|
||||
kind: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): RunEventEnvelope {
|
||||
return {
|
||||
schemaVersion: 'run-event/v1',
|
||||
eventId: `event_${sequence}`,
|
||||
taskId: 'task_1',
|
||||
runId: 'attempt_1',
|
||||
attemptId: 'attempt_1',
|
||||
sequence,
|
||||
receivedAt: '2026-07-24T12:00:00.000Z',
|
||||
kind,
|
||||
source: { provider: 'codex-cli', adapter: 'codex-cli', agent: 'codex' },
|
||||
redaction: { status: 'none', fields: [], originalBytes: 1, persistedBytes: 1 },
|
||||
payload,
|
||||
payloadHash: `sha256:${'a'.repeat(64)}`,
|
||||
} as RunEventEnvelope;
|
||||
}
|
||||
|
||||
function task(attempt?: Task['attempt']): Task {
|
||||
return {
|
||||
id: 'task_1',
|
||||
title: 'ACP task',
|
||||
description: 'Use the ACP view',
|
||||
type: 'code',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
project: 'veritas-kanban',
|
||||
created: '2026-07-24T12:00:00.000Z',
|
||||
updated: '2026-07-24T12:00:00.000Z',
|
||||
git: {
|
||||
repo: 'veritas-kanban',
|
||||
branch: 'feat/acp',
|
||||
baseBranch: 'main',
|
||||
worktreePath: '/tmp/task_1',
|
||||
},
|
||||
...(attempt ? { attempt, attempts: [attempt] } : {}),
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const approval: RunApprovalRequest = {
|
||||
schemaVersion: 'run-approval/v1',
|
||||
id: 'runapproval_123456789012',
|
||||
workspaceId: 'local',
|
||||
taskId: 'task_1',
|
||||
attemptId: 'attempt_1',
|
||||
provider: 'codex-cli',
|
||||
agentId: 'codex',
|
||||
requestKind: 'approval',
|
||||
actionClass: 'shell',
|
||||
action: 'Run tests',
|
||||
actionHash: `sha256:${'b'.repeat(64)}`,
|
||||
details: 'pnpm test',
|
||||
resourceScope: ['/tmp/task_1'],
|
||||
riskClass: 'medium',
|
||||
evidenceRevision: `sha256:${'c'.repeat(64)}`,
|
||||
providerRequestId: 'provider_approval_1',
|
||||
mobileSafe: true,
|
||||
status: 'pending',
|
||||
revision: 1,
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
expiresAt: '2026-07-24T12:30:00.000Z',
|
||||
};
|
||||
|
||||
describe('vk ACP server view', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('streams one provider-neutral turn through events and the durable approval broker', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const decisions: unknown[] = [];
|
||||
let eventRead = 0;
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
if (requestPath === '/api/auth/context') {
|
||||
return { role: 'admin', workspaceId: 'local', permissions: ['*'] };
|
||||
}
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) {
|
||||
expect(JSON.parse(String(options?.body))).toMatchObject({
|
||||
message: 'Implement the scoped task',
|
||||
agent: 'codex',
|
||||
});
|
||||
return { attemptId: 'attempt_1' };
|
||||
}
|
||||
if (requestPath.includes('/attempts/attempt_1/events?')) {
|
||||
eventRead += 1;
|
||||
return eventRead === 1
|
||||
? page([
|
||||
event(1, 'message.delta', { summary: 'Working on it.' }),
|
||||
event(2, 'tool.started', { summary: 'Run tests' }),
|
||||
event(3, 'approval.requested', { approvalId: approval.id }),
|
||||
])
|
||||
: page([
|
||||
event(4, 'tool.completed', { summary: 'Tests passed', success: true }),
|
||||
event(5, 'run.completed'),
|
||||
]);
|
||||
}
|
||||
if (requestPath === `/api/run-approvals/${approval.id}`) return approval;
|
||||
if (requestPath === `/api/run-approvals/${approval.id}/decision`) {
|
||||
decisions.push(JSON.parse(String(options?.body)));
|
||||
return { ...approval, status: 'approved' };
|
||||
}
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
agent: 'codex',
|
||||
pollIntervalMs: 1,
|
||||
now: () => Date.parse('2026-07-24T12:00:00.000Z'),
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine(
|
||||
request(1, 'initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: 'fixture', version: '1.0.0' },
|
||||
})
|
||||
);
|
||||
await server.acceptLine(
|
||||
request(2, 'session/new', {
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [],
|
||||
_meta: { 'veritas/taskId': 'task_1' },
|
||||
})
|
||||
);
|
||||
const sessionId = String((writes.at(-1)?.result as Record<string, unknown>).sessionId);
|
||||
const prompt = server.acceptLine(
|
||||
request(3, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Implement the scoped task' }],
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(writes.some((record) => record.method === 'session/request_permission')).toBe(true);
|
||||
});
|
||||
const permission = writes.find((record) => record.method === 'session/request_permission');
|
||||
await server.acceptLine(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: permission?.id,
|
||||
result: { outcome: { outcome: 'selected', optionId: 'allow_once' } },
|
||||
})
|
||||
);
|
||||
await prompt;
|
||||
|
||||
expect(decisions).toEqual([
|
||||
{
|
||||
decision: 'approved',
|
||||
expectedRevision: 1,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: 'ACP client selected allow once.',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
writes.filter((record) => record.method === 'session/update').map((record) => record.params)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'agent_message_chunk' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'tool_call' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'tool_call_update' }),
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(writes.at(-1)).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
result: { stopReason: 'end_turn' },
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and replays a durable attempt, then cancels it without a stop fallback', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const calls: Array<{ path: string; body?: unknown }> = [];
|
||||
const attempt = {
|
||||
id: 'attempt_1',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
started: '2026-07-24T12:00:00.000Z',
|
||||
conversation: {
|
||||
schemaVersion: 'conversation-lifecycle/v1',
|
||||
mode: 'fresh',
|
||||
intent: 'fresh',
|
||||
state: 'active',
|
||||
contextWindow: {
|
||||
posture: 'healthy',
|
||||
measuredAt: '2026-07-24T12:00:00.000Z',
|
||||
},
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
},
|
||||
} as Task['attempt'];
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
calls.push({
|
||||
path: requestPath,
|
||||
...(options?.body ? { body: JSON.parse(String(options.body)) } : {}),
|
||||
});
|
||||
if (requestPath === '/api/tasks') return [task(attempt)];
|
||||
if (requestPath.includes('/events?')) {
|
||||
return page([event(2, 'message.delta', { summary: 'replayed' })]);
|
||||
}
|
||||
if (requestPath.endsWith('/conversation/interrupt')) {
|
||||
return { delivered: true };
|
||||
}
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
const sessionId = `vkacp_${Buffer.from('task_1').toString('base64url')}`;
|
||||
|
||||
await server.acceptLine(
|
||||
request(1, 'session/load', {
|
||||
sessionId,
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [],
|
||||
_meta: { 'veritas/afterSequence': 1 },
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(writes.some((record) => record.method === 'session/update')).toBe(true);
|
||||
});
|
||||
await server.acceptLine(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/cancel',
|
||||
params: { sessionId },
|
||||
})
|
||||
);
|
||||
|
||||
expect(calls).toContainEqual({
|
||||
path: '/api/agents/task_1/conversation/interrupt',
|
||||
body: { attemptId: 'attempt_1' },
|
||||
});
|
||||
expect(calls.some((call) => call.path.endsWith('/stop'))).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnects the protocol view without stopping or interrupting the durable run', async () => {
|
||||
const calls: string[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string) => {
|
||||
calls.push(requestPath);
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) return { attemptId: 'attempt_1' };
|
||||
if (requestPath.includes('/events?')) return page([]);
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
pollIntervalMs: 1,
|
||||
write: vi.fn(),
|
||||
});
|
||||
const sessionId = `vkacp_${Buffer.from('task_1').toString('base64url')}`;
|
||||
|
||||
await server.acceptLine(request(1, 'session/new', { cwd: '/tmp/task_1', mcpServers: [] }));
|
||||
const prompt = server.acceptLine(
|
||||
request(2, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Keep the durable run alive' }],
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(calls.some((call) => call.includes('/events?'))).toBe(true);
|
||||
});
|
||||
server.disconnect();
|
||||
await prompt;
|
||||
|
||||
expect(calls.some((call) => call.endsWith('/stop'))).toBe(false);
|
||||
expect(calls.some((call) => call.endsWith('/conversation/interrupt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the same ACP client contract for two configured providers', async () => {
|
||||
const launchedAgents: unknown[] = [];
|
||||
|
||||
for (const agent of ['codex', 'claude']) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) {
|
||||
launchedAgents.push(JSON.parse(String(options?.body)).agent);
|
||||
return { attemptId: 'attempt_1' };
|
||||
}
|
||||
if (requestPath.includes('/events?')) return page([event(1, 'run.completed')]);
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
agent,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine(request(1, 'session/new', { cwd: '/tmp/task_1', mcpServers: [] }));
|
||||
const sessionId = String((writes.at(-1)?.result as Record<string, unknown>).sessionId);
|
||||
await server.acceptLine(
|
||||
request(2, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Use the selected provider' }],
|
||||
})
|
||||
);
|
||||
expect(writes.at(-1)).toMatchObject({
|
||||
id: 2,
|
||||
result: { stopReason: 'end_turn' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(launchedAgents).toEqual(['codex', 'claude']);
|
||||
});
|
||||
|
||||
it('fails malformed, unsupported, and client-owned tool-catalog requests closed', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string) => {
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine('{bad');
|
||||
await server.acceptLine(request(2, 'unknown/method', {}));
|
||||
await server.acceptLine(
|
||||
request(3, 'session/new', {
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [{ name: 'unowned', command: 'node', args: [], env: [] }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(writes.map((record) => (record.error as Record<string, unknown>)?.code)).toEqual([
|
||||
-32700, -32601, -32003,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports API-backed readiness without claiming provider capabilities', async () => {
|
||||
const ready = await readAcpStatus(
|
||||
vi.fn(async () => ({ role: 'admin', workspaceId: 'local' })) as AcpApiClient
|
||||
);
|
||||
expect(ready).toMatchObject({
|
||||
protocolVersion: 1,
|
||||
transport: 'stdio',
|
||||
ready: true,
|
||||
providerNeutral: true,
|
||||
durableRuns: true,
|
||||
role: 'admin',
|
||||
workspaceId: 'local',
|
||||
});
|
||||
|
||||
const blocked = await readAcpStatus(
|
||||
vi.fn(async () => {
|
||||
throw new Error('API unavailable');
|
||||
}) as AcpApiClient
|
||||
);
|
||||
expect(blocked).toMatchObject({ ready: false, error: 'API unavailable' });
|
||||
});
|
||||
});
|
||||
|
||||
function page(events: RunEventEnvelope[]): RunEventPage {
|
||||
return {
|
||||
schemaVersion: 'run-event/v1',
|
||||
taskId: 'task_1',
|
||||
attemptId: 'attempt_1',
|
||||
events,
|
||||
nextCursor: events.at(-1)?.sequence ?? 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
438
cli/src/__tests__/admission.test.ts
Normal file
438
cli/src/__tests__/admission.test.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const api = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api }));
|
||||
|
||||
import { registerAdmissionCommands } from '../commands/admission.js';
|
||||
|
||||
describe('vk admission commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('lists reservations as JSON with all operator filters preserved', async () => {
|
||||
api.mockResolvedValue({
|
||||
generatedAt: '2026-07-25T10:00:00.000Z',
|
||||
reservations: [{ id: 'admission_1', state: 'active' }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'list',
|
||||
'--workspace',
|
||||
'workspace-a',
|
||||
'--workflow-run',
|
||||
'run_1234567890_abcdef',
|
||||
'--workflow-step',
|
||||
'execute',
|
||||
'--root-reservation',
|
||||
'admission_root',
|
||||
'--root-objective',
|
||||
'objective-a',
|
||||
'--node',
|
||||
'node-child',
|
||||
'--parent-node',
|
||||
'node-root',
|
||||
'--state',
|
||||
'active',
|
||||
'released',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/admission?workspaceId=workspace-a&workflowRunId=run_1234567890_abcdef&workflowStepId=execute&rootReservationId=admission_root&rootObjectiveId=objective-a&nodeId=node-child&parentNodeId=node-root&state=active&state=released&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toEqual({
|
||||
generatedAt: '2026-07-25T10:00:00.000Z',
|
||||
reservations: [{ id: 'admission_1', state: 'active' }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects one reservation as JSON', async () => {
|
||||
api.mockResolvedValue({ id: 'admission_1', state: 'released' });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'get', 'admission_1', '--json']);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/admission_1');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toEqual({
|
||||
id: 'admission_1',
|
||||
state: 'released',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects an aggregate execution tree as JSON', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-budget-summary/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
contributors: [],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'tree',
|
||||
'objective-a',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a?limit=25');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
rootObjectiveId: 'objective-a',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('shows durable execution-tree control in human output', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-budget-summary/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
control: {
|
||||
schemaVersion: 'execution-tree-control/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
state: 'cancelled',
|
||||
trigger: 'operator',
|
||||
reason: 'Operator stopped runaway expansion.',
|
||||
idempotencyKey: 'sha256:cancelled',
|
||||
recordedAt: '2026-07-25T12:00:00.000Z',
|
||||
},
|
||||
committed: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
toolCalls: 0,
|
||||
runtimeSeconds: 0,
|
||||
idleRuntimeSeconds: 0,
|
||||
costUsd: 0,
|
||||
retries: 0,
|
||||
fanOut: 0,
|
||||
},
|
||||
reserved: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
toolCalls: 0,
|
||||
runtimeSeconds: 0,
|
||||
idleRuntimeSeconds: 0,
|
||||
costUsd: 0,
|
||||
retries: 0,
|
||||
fanOut: 0,
|
||||
},
|
||||
policies: [],
|
||||
contributors: [],
|
||||
contributorCount: 0,
|
||||
truncated: false,
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'tree', 'objective-a']);
|
||||
|
||||
expect(output.mock.calls.map(([line]) => String(line)).join('\n')).toContain(
|
||||
'control=cancelled trigger=operator'
|
||||
);
|
||||
expect(output.mock.calls.map(([line]) => String(line)).join('\n')).toContain(
|
||||
'Operator stopped runaway expansion.'
|
||||
);
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels one queued launch with a stable idempotency identity', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-cancellation/v1',
|
||||
scope: 'queued-launch',
|
||||
queueEntry: { id: 'admission_queue_1', state: 'terminal' },
|
||||
reservationReleased: true,
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'cancel',
|
||||
'admission_queue_1',
|
||||
'--reason',
|
||||
'Operator cancelled the queued launch.',
|
||||
'--idempotency-key',
|
||||
'cancel-queue-entry-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/queue/admission_queue_1/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator cancelled the queued launch.',
|
||||
idempotencyKey: 'cancel-queue-entry-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
scope: 'queued-launch',
|
||||
queueEntry: { state: 'terminal' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels an execution tree and reports remaining verified runs', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-cancellation/v1',
|
||||
scope: 'execution-tree',
|
||||
rootObjectiveId: 'objective-a',
|
||||
queueEntriesCancelled: 2,
|
||||
interruptedAttempts: 1,
|
||||
runningAttempts: [],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'cancel-tree',
|
||||
'objective-a',
|
||||
'--reason',
|
||||
'Operator cancelled runaway expansion.',
|
||||
'--idempotency-key',
|
||||
'cancel-execution-tree-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator cancelled runaway expansion.',
|
||||
idempotencyKey: 'cancel-execution-tree-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
scope: 'execution-tree',
|
||||
queueEntriesCancelled: 2,
|
||||
interruptedAttempts: 1,
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('resumes an eligible execution tree with a stable idempotency identity', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-control/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
state: 'resumed',
|
||||
resumedAt: '2026-07-25T12:00:00.000Z',
|
||||
resumeReason: 'Operator confirmed pressure cleared.',
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'resume-tree',
|
||||
'objective-a',
|
||||
'--reason',
|
||||
'Operator confirmed pressure cleared.',
|
||||
'--idempotency-key',
|
||||
'resume-execution-tree-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator confirmed pressure cleared.',
|
||||
idempotencyKey: 'resume-execution-tree-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
state: 'resumed',
|
||||
rootObjectiveId: 'objective-a',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('lists the admission queue as JSON with all operator filters preserved', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 2, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
pagination: { page: 2, limit: 25, total: 26, hasMore: false },
|
||||
entries: [{ id: 'admission_queue_1', state: 'queued', position: 26 }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'list',
|
||||
'--workspace',
|
||||
'workspace-a',
|
||||
'--root-objective',
|
||||
'objective-a',
|
||||
'--node',
|
||||
'node-a',
|
||||
'--source',
|
||||
'workflow',
|
||||
'--state',
|
||||
'queued',
|
||||
'requeued',
|
||||
'--priority',
|
||||
'3',
|
||||
'--limiting-scope',
|
||||
'provider',
|
||||
'--min-age',
|
||||
'60000',
|
||||
'--max-age',
|
||||
'3600000',
|
||||
'--page',
|
||||
'2',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/admission/queue?workspaceId=workspace-a&rootObjectiveId=objective-a&nodeId=node-a&source=workflow&state=queued&state=requeued&priority=3&limitingScope=provider&minAgeMs=60000&maxAgeMs=3600000&page=2&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
conditional: true,
|
||||
entries: [{ id: 'admission_queue_1', position: 26 }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects one admission queue entry as JSON', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 1, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
entry: {
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
id: 'admission_queue_1',
|
||||
state: 'leased',
|
||||
readiness: 'reserved',
|
||||
},
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'get',
|
||||
'admission_queue_1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/queue/admission_queue_1');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
entry: { id: 'admission_queue_1', readiness: 'reserved' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('prints compact conditional queue output without an exact start promise', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 1, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
snapshotTruncated: false,
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
id: 'admission_queue_1',
|
||||
state: 'queued',
|
||||
position: 1,
|
||||
rawPriority: 1,
|
||||
effectivePriority: 2,
|
||||
agePromotion: 1,
|
||||
ageMs: 60_000,
|
||||
readiness: 'conditional',
|
||||
lease: { posture: 'none' },
|
||||
limitingPolicies: [],
|
||||
conditionalStartFactors: ['capacity-recheck'],
|
||||
launch: {
|
||||
source: 'direct',
|
||||
target: 'direct',
|
||||
taskKey: `sha256:${'a'.repeat(64)}`,
|
||||
rootTaskKey: `sha256:${'b'.repeat(64)}`,
|
||||
workspaceKey: `sha256:${'c'.repeat(64)}`,
|
||||
provider: 'codex-cli',
|
||||
hostKey: `sha256:${'d'.repeat(64)}`,
|
||||
},
|
||||
retry: {
|
||||
count: 0,
|
||||
maximum: 3,
|
||||
availableAt: '2026-07-25T12:00:00.000Z',
|
||||
},
|
||||
createdAt: '2026-07-25T11:59:00.000Z',
|
||||
updatedAt: '2026-07-25T11:59:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'queue', 'list', '--limit', '1']);
|
||||
|
||||
const rendered = output.mock.calls.map(([line]) => String(line)).join('\n');
|
||||
expect(rendered).toContain('priority=1->2 readiness=conditional');
|
||||
expect(rendered).toContain('Conditional snapshot at 2026-07-25T12:00:00.000Z');
|
||||
expect(rendered).not.toMatch(/\bETA\b|starts? at|start time/i);
|
||||
output.mockRestore();
|
||||
});
|
||||
});
|
||||
513
cli/src/__tests__/agents-runtime-capabilities.test.ts
Normal file
513
cli/src/__tests__/agents-runtime-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
mockApi: vi.fn(),
|
||||
mockFindTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api: mockApi }));
|
||||
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
||||
|
||||
import { registerAgentCommands } from '../commands/agents.js';
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
function expectLaunchBody(expected: Record<string, unknown>): void {
|
||||
const [url, request] = mockApi.mock.calls.at(-1) as [string, { method: string; body: string }];
|
||||
const { idempotencyKey, ...body } = JSON.parse(request.body) as Record<string, unknown>;
|
||||
expect(url).toBe('/api/agents/task_1/start');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(idempotencyKey).toMatch(/^vk-cli:task_1:[0-9a-f-]{36}$/);
|
||||
expect(body).toEqual(expected);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('vk agent runtime capability controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindTask.mockResolvedValue({
|
||||
id: 'task_1',
|
||||
type: 'code',
|
||||
git: { worktreePath: '/tmp/task_1' },
|
||||
});
|
||||
mockApi.mockImplementation(async (url: string) =>
|
||||
url.endsWith('/status')
|
||||
? { running: true, attemptId: 'attempt_1' }
|
||||
: { attemptId: 'attempt_1' }
|
||||
);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('forwards required runtime capabilities to the launch API', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'start',
|
||||
'task_1',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--phase',
|
||||
'implement',
|
||||
'--parent-attempt',
|
||||
'attempt_parent',
|
||||
'--require-capability',
|
||||
'tool.mcp',
|
||||
'output.structured',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expectLaunchBody({
|
||||
agent: 'codex',
|
||||
phase: 'implement',
|
||||
requiredRuntimeCapabilities: ['tool.mcp', 'output.structured'],
|
||||
parentAttemptId: 'attempt_parent',
|
||||
});
|
||||
});
|
||||
|
||||
it('previews one explicit phase against exact parent launch evidence', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'launch-preview',
|
||||
'task_1',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--phase',
|
||||
'plan',
|
||||
'--parent-attempt',
|
||||
'attempt_parent',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/launch-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: 'codex',
|
||||
profileId: undefined,
|
||||
phase: 'plan',
|
||||
requiredRuntimeCapabilities: undefined,
|
||||
commitPolicy: undefined,
|
||||
parentAttemptId: 'attempt_parent',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an explicit run commit policy', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['start', 'task_1', '--agent', 'codex', '--commit-policy', 'forbidden', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expectLaunchBody({
|
||||
agent: 'codex',
|
||||
commitPolicy: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces authoritative fail-closed stop errors from the API', async () => {
|
||||
mockApi
|
||||
.mockResolvedValueOnce({ running: true, attemptId: 'attempt_1' })
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Provider runtime does not support stop run: run.stop is unsupported.')
|
||||
);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
|
||||
_code?: number | string | null
|
||||
) => {
|
||||
throw new Error('process.exit called');
|
||||
}) as typeof process.exit);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['stop', 'task_1'], { from: 'user' })).rejects.toThrow(
|
||||
'process.exit called'
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('run.stop is unsupported'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('binds stop requests to the attempt returned by status', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['stop', 'task_1', '--json'], { from: 'user' });
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/agents/task_1/status');
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('binds recovery cancellation to the exact persisted parent attempt', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['agent:cancel-recovery', 'task_1', '--attempt', 'attempt_parent', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/recovery/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_parent' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads durable phase state for one exact attempt', async () => {
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['agent:phase', 'task_1', '--attempt', 'attempt_1', '--limit', '25', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/phase?attemptId=attempt_1&limit=25');
|
||||
});
|
||||
|
||||
it('binds the first phase transition to exact evidence and manifest provenance', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
const fromEvidence = { digest: `sha256:${'1'.repeat(64)}` };
|
||||
const targetEvidence = { digest: `sha256:${'2'.repeat(64)}` };
|
||||
await fs.writeFile(fromPath, JSON.stringify(fromEvidence));
|
||||
await fs.writeFile(targetPath, JSON.stringify(targetEvidence));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] }).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
current: null,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/phase/transitions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: 'attempt_1',
|
||||
operationId: 'phase-op-1',
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: fromEvidence.digest,
|
||||
expectedManifestDigest: `sha256:${'3'.repeat(64)}`,
|
||||
reason: 'Approved plan is ready.',
|
||||
fromEvidence,
|
||||
targetEvidence,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects partially numeric phase approval lifetimes before transition', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
await fs.writeFile(fromPath, JSON.stringify({ digest: `sha256:${'1'.repeat(64)}` }));
|
||||
await fs.writeFile(targetPath, JSON.stringify({ digest: `sha256:${'2'.repeat(64)}` }));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as typeof process.exit);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--approval-ttl-ms',
|
||||
'1000x',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--approval-ttl-ms must be an integer')
|
||||
);
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('decides an exact phase approval with revision and action-hash guards', async () => {
|
||||
const approval = {
|
||||
id: 'runapproval_000000000001',
|
||||
revision: 4,
|
||||
actionHash: 'a'.repeat(64),
|
||||
status: 'pending',
|
||||
};
|
||||
mockApi.mockResolvedValueOnce(approval).mockResolvedValueOnce({
|
||||
...approval,
|
||||
revision: 5,
|
||||
status: 'approved',
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:decide-phase-approval',
|
||||
approval.id,
|
||||
'--decision',
|
||||
'approve',
|
||||
'--note',
|
||||
'Expansion reviewed.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, `/api/run-approvals/${approval.id}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: 'approved',
|
||||
expectedRevision: 4,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: 'Expansion reviewed.',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a native history fork from an explicit source attempt and turn', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:fork',
|
||||
'task_1',
|
||||
'--source-attempt',
|
||||
'attempt_parent',
|
||||
'--message',
|
||||
'Explore the alternate fix',
|
||||
'--fork-turn',
|
||||
'turn_7',
|
||||
'--phase',
|
||||
'explore',
|
||||
'--require-capability',
|
||||
'tool.mcp',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
const [url, request] = mockApi.mock.calls.at(-1) as [string, { method: string; body: string }];
|
||||
const { idempotencyKey, ...body } = JSON.parse(request.body) as Record<string, unknown>;
|
||||
|
||||
expect(url).toBe('/api/agents/task_1/conversation/fork');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(idempotencyKey).toMatch(
|
||||
/^vk-cli:task_1:conversation:fork:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(body).toEqual({
|
||||
sourceAttemptId: 'attempt_parent',
|
||||
message: 'Explore the alternate fix',
|
||||
forkTurnId: 'turn_7',
|
||||
phase: 'explore',
|
||||
requiredRuntimeCapabilities: ['tool.mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('binds compact controls to the exact active attempt', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['agent:compact', 'task_1', '--attempt', 'attempt_1', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/compact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards attempt and manifest provenance when completing a run', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
const digest = `sha256:${'a'.repeat(64)}`;
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agents:complete',
|
||||
'task_1',
|
||||
'--attempt-id',
|
||||
'attempt_1',
|
||||
'--manifest-digest',
|
||||
digest,
|
||||
'--summary',
|
||||
'Done',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: 'attempt_1',
|
||||
providerRuntimeManifestDigest: digest,
|
||||
success: true,
|
||||
summary: 'Done',
|
||||
error: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('scans the exact task workspace execution inventory', async () => {
|
||||
mockApi.mockResolvedValueOnce({
|
||||
inventory: {
|
||||
identity: { digest: `sha256:${'1'.repeat(64)}` },
|
||||
digest: `sha256:${'2'.repeat(64)}`,
|
||||
projectPolicy: { maximumTrust: 'restricted' },
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['workspace-trust', 'scan', 'task_1', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust');
|
||||
});
|
||||
|
||||
it('records and revokes exact-inventory workspace decisions', async () => {
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'workspace-decision-1',
|
||||
mode: 'trusted',
|
||||
});
|
||||
const digest = `sha256:${'3'.repeat(64)}`;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'decide',
|
||||
'task_1',
|
||||
'--mode',
|
||||
'trusted',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Reviewed exact inventory',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/decisions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: 'trusted',
|
||||
inventoryDigest: digest,
|
||||
reason: 'Reviewed exact inventory',
|
||||
expiresAt: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'revoke',
|
||||
'task_1',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Authorization withdrawn',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: digest,
|
||||
reason: 'Authorization withdrawn',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -89,4 +89,141 @@ describe('CLI API permission preflight', () => {
|
|||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires agent write permission to start a fresh conversation', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/agents/task_1/conversation/fresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['agent:write'],
|
||||
path: '/api/agents/task_1/conversation/fresh',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requires task write permission for delegated workspace intake', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workspace:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/workspace-capabilities/intake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['task:write'],
|
||||
path: '/api/workspace-capabilities/intake',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires workflow execute permission for scheduler run actions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workflow:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/scheduler/items/workflow%3Aweekly/run', {
|
||||
method: 'POST',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['workflow:execute'],
|
||||
path: '/api/scheduler/items/workflow%3Aweekly/run',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires workflow execute permission for queue monitor run actions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workflow:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/queue-monitors/veritas-backlog/run', {
|
||||
method: 'POST',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['workflow:execute'],
|
||||
path: '/api/queue-monitors/veritas-backlog/run',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires write permission for SQLite journal preview and admin permission for apply', async () => {
|
||||
const readOnlyContext = {
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['backup:read'],
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(readOnlyContext));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
await expect(
|
||||
api('/api/maintenance/sqlite/journal/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetMode: 'wal' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['backup:write'],
|
||||
path: '/api/maintenance/sqlite/journal/preview',
|
||||
});
|
||||
|
||||
await expect(
|
||||
api('/api/maintenance/sqlite/journal/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ previewId: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['admin:manage'],
|
||||
path: '/api/maintenance/sqlite/journal/apply',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,25 @@ function doctorFetch(routes: Record<string, Response>) {
|
|||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function compatibilityResponse(supportStatuses: unknown[]): Response {
|
||||
return jsonResponse({
|
||||
schemaVersion: 'harness-compatibility-matrix/v1',
|
||||
generatedAt: '2026-06-04T07:00:00.000Z',
|
||||
probeRevision: 14,
|
||||
digest: 'a'.repeat(64),
|
||||
tierDefinitions: {},
|
||||
records: [
|
||||
{
|
||||
profileId: 'openai-codex-app-server',
|
||||
testedVersions: ['codex-cli 0.145.0'],
|
||||
sourceAvailability: 'open-source',
|
||||
certification: { status: 'not-run' },
|
||||
},
|
||||
],
|
||||
supportStatuses,
|
||||
});
|
||||
}
|
||||
|
||||
const baseRoutes: Record<string, Response> = {
|
||||
'/api/health': jsonResponse({ ok: true, version: '4.3.2', uptimeMs: 1000 }),
|
||||
'/api/auth/context': jsonResponse({
|
||||
|
|
@ -37,6 +56,23 @@ const baseRoutes: Record<string, Response> = {
|
|||
provider: 'codex-cli',
|
||||
},
|
||||
]),
|
||||
'/api/config/harness-compatibility': compatibilityResponse([
|
||||
{
|
||||
agentType: 'codex',
|
||||
profileId: 'openai-codex-cli',
|
||||
adapterId: 'codex-cli',
|
||||
transport: 'process-jsonl',
|
||||
supportTier: 'configured',
|
||||
reason: 'Certification evidence is not current.',
|
||||
failureClass: 'none',
|
||||
checkedAt: '2026-06-04T07:00:00.000Z',
|
||||
enabled: true,
|
||||
executableFound: true,
|
||||
authenticated: true,
|
||||
diagnosticCommands: ['codex --version', 'codex login status'],
|
||||
remediation: ['Run vk doctor.'],
|
||||
},
|
||||
]),
|
||||
'/api/agents/routing': jsonResponse({
|
||||
enabled: true,
|
||||
defaultAgent: 'codex',
|
||||
|
|
@ -54,6 +90,10 @@ const baseRoutes: Record<string, Response> = {
|
|||
cli: { installed: true, authenticated: true },
|
||||
recommendations: [],
|
||||
}),
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse(
|
||||
{ error: 'not found' },
|
||||
404
|
||||
),
|
||||
};
|
||||
|
||||
describe('vk doctor', () => {
|
||||
|
|
@ -80,6 +120,12 @@ describe('vk doctor', () => {
|
|||
expect(report.checks.find((check) => check.id === 'agents')).toMatchObject({
|
||||
status: 'pass',
|
||||
});
|
||||
expect(report.checks.find((check) => check.id === 'harness-support')).toMatchObject({
|
||||
status: 'warn',
|
||||
details: expect.objectContaining({
|
||||
configured: 1,
|
||||
}),
|
||||
});
|
||||
expect(formatDoctorReport(report)).toContain('Doctor result: clean');
|
||||
});
|
||||
|
||||
|
|
@ -126,6 +172,55 @@ describe('vk doctor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('fails closed for an enabled unsupported harness and preserves safe remediation', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/config/harness-compatibility': compatibilityResponse([
|
||||
{
|
||||
agentType: 'claude-code',
|
||||
profileId: 'claude-code',
|
||||
transport: 'process-jsonl',
|
||||
supportTier: 'unsupported',
|
||||
reason: 'No executable adapter is registered.',
|
||||
failureClass: 'adapter-unavailable',
|
||||
checkedAt: '2026-06-04T07:00:00.000Z',
|
||||
enabled: true,
|
||||
executableFound: true,
|
||||
authenticated: true,
|
||||
diagnosticCommands: ['claude --version'],
|
||||
remediation: ['Disable this profile or install a supported adapter.'],
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-06-04T07:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'harness-support')).toMatchObject({
|
||||
status: 'fail',
|
||||
details: {
|
||||
blocking: [
|
||||
expect.objectContaining({
|
||||
profileId: 'claude-code',
|
||||
diagnosticCommands: ['claude --version'],
|
||||
remediation: ['Disable this profile or install a supported adapter.'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts local paths and webhook secrets from support-safe JSON', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
|
|
@ -178,4 +273,121 @@ describe('vk doctor', () => {
|
|||
status: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports exact Buzz health in human and JSON output', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail:
|
||||
'Buzz relay identity, configured signing identity, membership posture, and read capabilities are compatible.',
|
||||
reasonCode: 'ok',
|
||||
buzz: {
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'compatible',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [],
|
||||
evidenceKey: 'safe-evidence',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'pass',
|
||||
details: {
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
testedRelease: '0.4.24',
|
||||
buzz: {
|
||||
checks: {
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
evidenceKey: 'safe-evidence',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(formatDoctorReport(report)).toContain('[PASS] Buzz compatibility');
|
||||
});
|
||||
|
||||
it('fails doctor for an enabled Buzz membership denial with remediation', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'not_member',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz authenticated the identity but denied relay membership.',
|
||||
reasonCode: 'relay_membership_required',
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'fail',
|
||||
details: {
|
||||
status: 'not_member',
|
||||
reasonCode: 'relay_membership_required',
|
||||
},
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
214
cli/src/__tests__/goals.test.ts
Normal file
214
cli/src/__tests__/goals.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const api = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api }));
|
||||
|
||||
import { registerGoalCommands } from '../commands/goals.js';
|
||||
|
||||
const GOAL_ID = 'goal_0123456789abcdef';
|
||||
|
||||
describe('vk goals commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('lists goals as JSON with bounded filters', async () => {
|
||||
api.mockResolvedValue({
|
||||
generatedAt: '2026-07-26T02:00:00.000Z',
|
||||
goals: [{ id: GOAL_ID, state: 'blocked' }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'list',
|
||||
'--state',
|
||||
'active',
|
||||
'blocked',
|
||||
'--root-task',
|
||||
'task-865',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/goals?state=active&state=blocked&rootTaskId=task-865&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
goals: [{ id: GOAL_ID, state: 'blocked' }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('creates an evidence-gated task goal', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'active', revision: 1 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'create',
|
||||
'--objective',
|
||||
'Deliver durable controls.',
|
||||
'--acceptance',
|
||||
'REST passes',
|
||||
'CLI passes',
|
||||
'--requirement',
|
||||
'focused-tests|test|Focused tests pass.',
|
||||
'--root-task',
|
||||
'task-865',
|
||||
'--mode',
|
||||
'automatic',
|
||||
'--max-turns',
|
||||
'20',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/goals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objective: 'Deliver durable controls.',
|
||||
constraints: [],
|
||||
acceptanceCriteria: ['REST passes', 'CLI passes'],
|
||||
root: { kind: 'task', taskId: 'task-865' },
|
||||
continuation: { mode: 'automatic', maxTurns: 20 },
|
||||
completionRequirements: [
|
||||
{
|
||||
id: 'focused-tests',
|
||||
verificationKind: 'test',
|
||||
description: 'Focused tests pass.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('transitions with exact revision and structured completion evidence', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'complete', revision: 3 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'transition',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'2',
|
||||
'--state',
|
||||
'complete',
|
||||
'--reason',
|
||||
'All verification passed.',
|
||||
'--evidence-json',
|
||||
'[{"requirementId":"focused-tests","evidenceId":"ci-1082","summary":"Passed."}]',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 2,
|
||||
state: 'complete',
|
||||
reason: 'All verification passed.',
|
||||
blocker: undefined,
|
||||
completionEvidence: [
|
||||
{
|
||||
requirementId: 'focused-tests',
|
||||
evidenceId: 'ci-1082',
|
||||
summary: 'Passed.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('links a run to the continuation chain', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'active', revision: 4 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'link-run',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'3',
|
||||
'--task',
|
||||
'task-865',
|
||||
'--attempt',
|
||||
'attempt-3',
|
||||
'--conversation',
|
||||
'conversation-3',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/runs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 3,
|
||||
taskId: 'task-865',
|
||||
attemptId: 'attempt-3',
|
||||
conversationId: 'conversation-3',
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('approves and dispatches a bounded conversation rollover', async () => {
|
||||
api.mockResolvedValue({
|
||||
action: 'dispatched',
|
||||
goal: { id: GOAL_ID, revision: 8 },
|
||||
continuation: {
|
||||
id: 'continuation-rollover',
|
||||
kind: 'rollover',
|
||||
state: 'dispatched',
|
||||
resultAttemptId: 'attempt-8',
|
||||
},
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'rollover',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'7',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/rollover`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 7,
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
action: 'dispatched',
|
||||
continuation: { kind: 'rollover', resultAttemptId: 'attempt-8' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
});
|
||||
119
cli/src/__tests__/sqlite-maintenance.test.ts
Normal file
119
cli/src/__tests__/sqlite-maintenance.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerSqliteCommands } from '../commands/sqlite.js';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('vk sqlite journal', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
function program(): Command {
|
||||
const command = new Command();
|
||||
command.exitOverride();
|
||||
registerSqliteCommands(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('previews the exact target and emits stable JSON without ANSI output', async () => {
|
||||
const preview = {
|
||||
schemaVersion: 'sqlite-journal-preview/v1',
|
||||
id: '98af3a58-1b8b-41b3-8162-dfdb1f257740',
|
||||
token: 'a'.repeat(64),
|
||||
createdAt: '2026-07-15T00:00:00.000Z',
|
||||
expiresAt: '2026-07-15T00:15:00.000Z',
|
||||
targetMode: 'delete',
|
||||
currentMode: 'wal',
|
||||
databaseLocation: 'configured',
|
||||
filesystemType: 'apfs',
|
||||
filesystemPosture: 'supported-local',
|
||||
ownershipState: 'server-open',
|
||||
activeConnectionCount: 4,
|
||||
sidecars: [],
|
||||
backupLocation: 'adjacent-secure-directory',
|
||||
singleHost: true,
|
||||
overrideRequired: true,
|
||||
risks: ['restart required'],
|
||||
restartRequired: true,
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ role: 'admin', isLocalhost: false, permissions: ['*'] })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(preview));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await program().parseAsync(
|
||||
[
|
||||
'sqlite',
|
||||
'journal',
|
||||
'preview',
|
||||
'--target',
|
||||
'delete',
|
||||
'--single-host',
|
||||
'--override-reason',
|
||||
'Approved rollback mode',
|
||||
'--expires-at',
|
||||
'2026-07-16T00:00:00.000Z',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
'http://localhost:3001/api/maintenance/sqlite/journal/preview'
|
||||
);
|
||||
const request = fetchMock.mock.calls[1][1] as RequestInit;
|
||||
expect(JSON.parse(String(request.body))).toEqual({
|
||||
targetMode: 'delete',
|
||||
singleHost: true,
|
||||
overrideReason: 'Approved rollback mode',
|
||||
expiresAt: '2026-07-16T00:00:00.000Z',
|
||||
});
|
||||
const rendered = String(output.mock.calls[0][0]);
|
||||
expect(JSON.parse(rendered)).toEqual(preview);
|
||||
expect(rendered).not.toContain(String.fromCharCode(27));
|
||||
});
|
||||
|
||||
it('refuses apply locally without matching confirmation and acknowledgement', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await program().parseAsync(
|
||||
[
|
||||
'sqlite',
|
||||
'journal',
|
||||
'apply',
|
||||
'--preview-id',
|
||||
'98af3a58-1b8b-41b3-8162-dfdb1f257740',
|
||||
'--preview-token',
|
||||
'a'.repeat(64),
|
||||
'--confirm',
|
||||
'60f2dd7e-35e8-4630-b0cc-687a4c013677',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
mockApi: vi.fn(),
|
||||
mockFindTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api: mockApi }));
|
||||
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
||||
|
||||
import { registerTaskCommands } from '../commands/tasks.js';
|
||||
|
||||
describe('vk task execution policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'task_1',
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
created: '2026-07-16T00:00:00.000Z',
|
||||
updated: '2026-07-16T00:00:00.000Z',
|
||||
});
|
||||
mockFindTask.mockResolvedValue({ id: 'task_1' });
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on create', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['create', 'Policy task', '--commit-policy', 'forbidden', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
executionPolicy: { commitPolicy: 'forbidden' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on update', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['update', 'task_1', '--commit-policy', 'required', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks/task_1', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
|
||||
});
|
||||
});
|
||||
});
|
||||
846
cli/src/commands/acp.ts
Normal file
846
cli/src/commands/acp.ts
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import type { Command } from 'commander';
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpJsonRpcId,
|
||||
AcpJsonRpcMessage,
|
||||
AcpPromptResponse,
|
||||
ClientAuthContext,
|
||||
RunApprovalRequest,
|
||||
RunEventEnvelope,
|
||||
RunEventPage,
|
||||
Task,
|
||||
TaskAttempt,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { ACP_PROTOCOL_VERSION } from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
const MAX_PROTOCOL_LINE_BYTES = 1024 * 1024;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const ACP_SERVER_NAME = 'Veritas Kanban';
|
||||
const ACP_SERVER_VERSION = '6.0.0';
|
||||
|
||||
export const ACP_SERVER_METHODS = [
|
||||
'initialize',
|
||||
'session/new',
|
||||
'session/load',
|
||||
'session/resume',
|
||||
'session/prompt',
|
||||
'session/cancel',
|
||||
] as const;
|
||||
|
||||
export type AcpApiClient = <T>(requestPath: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
export interface AcpServerViewOptions {
|
||||
api?: AcpApiClient;
|
||||
write: (record: AcpJsonRpcMessage) => void;
|
||||
boundTaskId?: string;
|
||||
agent?: string;
|
||||
profileId?: string;
|
||||
pollIntervalMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface ViewSession {
|
||||
sessionId: string;
|
||||
taskId: string;
|
||||
cwd: string;
|
||||
attemptId?: string;
|
||||
cursor: number;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
interface MethodOutcome {
|
||||
result: unknown;
|
||||
afterResponse?: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingClientRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface AcpStatus {
|
||||
schemaVersion: 'veritas-acp-server-status/v1';
|
||||
protocolVersion: typeof ACP_PROTOCOL_VERSION;
|
||||
transport: 'stdio';
|
||||
ready: boolean;
|
||||
methods: readonly string[];
|
||||
durableRuns: true;
|
||||
providerNeutral: true;
|
||||
role?: string;
|
||||
workspaceId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class AcpServerView {
|
||||
private readonly apiClient: AcpApiClient;
|
||||
private readonly writeRecord: (record: AcpJsonRpcMessage) => void;
|
||||
private readonly boundTaskId?: string;
|
||||
private readonly agent?: string;
|
||||
private readonly profileId?: string;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly sessions = new Map<string, ViewSession>();
|
||||
private readonly pendingClientRequests = new Map<string | number, PendingClientRequest>();
|
||||
private nextClientRequestId = 1;
|
||||
private disconnected = false;
|
||||
|
||||
constructor(options: AcpServerViewOptions) {
|
||||
this.apiClient = options.api ?? api;
|
||||
this.writeRecord = (record) => {
|
||||
if (!this.disconnected) options.write(record);
|
||||
};
|
||||
this.boundTaskId = options.boundTaskId;
|
||||
this.agent = options.agent;
|
||||
this.profileId = options.profileId;
|
||||
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
for (const pending of this.pendingClientRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('ACP client disconnected.'));
|
||||
}
|
||||
this.pendingClientRequests.clear();
|
||||
}
|
||||
|
||||
async acceptLine(line: string): Promise<void> {
|
||||
if (Buffer.byteLength(line, 'utf8') > MAX_PROTOCOL_LINE_BYTES) {
|
||||
this.writeError(null, -32600, 'ACP record exceeds the 1 MiB limit.');
|
||||
return;
|
||||
}
|
||||
let record: unknown;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
this.writeError(null, -32700, 'Invalid JSON.');
|
||||
return;
|
||||
}
|
||||
if (!isRecord(record) || record.jsonrpc !== '2.0') {
|
||||
this.writeError(null, -32600, 'Invalid JSON-RPC record.');
|
||||
return;
|
||||
}
|
||||
if ('result' in record || 'error' in record) {
|
||||
this.acceptClientResponse(record);
|
||||
return;
|
||||
}
|
||||
if (typeof record.method !== 'string') {
|
||||
this.writeError(
|
||||
validId(record.id) ? record.id : null,
|
||||
-32600,
|
||||
'JSON-RPC method is required.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const id = validId(record.id) ? record.id : undefined;
|
||||
if (id === undefined) {
|
||||
await this.handleNotification(record.method, record.params);
|
||||
return;
|
||||
}
|
||||
await this.handleRequest(id, record.method, record.params);
|
||||
}
|
||||
|
||||
private async handleRequest(id: AcpJsonRpcId, method: string, params: unknown): Promise<void> {
|
||||
try {
|
||||
const outcome = await this.dispatch(method, params);
|
||||
this.writeRecord({ jsonrpc: '2.0', id, result: outcome.result });
|
||||
if (outcome.afterResponse) void outcome.afterResponse();
|
||||
} catch (error) {
|
||||
const rpcError = error instanceof AcpViewError ? error : AcpViewError.internal(error);
|
||||
this.writeError(id, rpcError.code, rpcError.message, rpcError.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNotification(method: string, params: unknown): Promise<void> {
|
||||
if (method !== 'session/cancel') return;
|
||||
try {
|
||||
const input = requiredRecord(params, 'session/cancel params');
|
||||
const session = this.requireSession(requiredString(input.sessionId, 'sessionId'));
|
||||
if (!session.attemptId) return;
|
||||
await this.apiClient(
|
||||
`/api/agents/${encodeURIComponent(session.taskId)}/conversation/interrupt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: session.attemptId }),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// Notifications have no response. Durable run state remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(method: string, params: unknown): Promise<MethodOutcome> {
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return this.initialize(params);
|
||||
case 'session/new':
|
||||
return this.newSession(params);
|
||||
case 'session/load':
|
||||
case 'session/resume':
|
||||
return this.loadSession(params);
|
||||
case 'session/prompt':
|
||||
return this.prompt(params);
|
||||
default:
|
||||
throw new AcpViewError(-32601, `Unsupported ACP method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'initialize params');
|
||||
if (input.protocolVersion !== ACP_PROTOCOL_VERSION) {
|
||||
throw new AcpViewError(-32602, 'Unsupported ACP protocol version.', {
|
||||
expected: ACP_PROTOCOL_VERSION,
|
||||
received: input.protocolVersion,
|
||||
});
|
||||
}
|
||||
await this.apiClient<ClientAuthContext>('/api/auth/context');
|
||||
return {
|
||||
result: {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
sessionCapabilities: { resume: {} },
|
||||
},
|
||||
agentInfo: {
|
||||
name: ACP_SERVER_NAME,
|
||||
title: ACP_SERVER_NAME,
|
||||
version: ACP_SERVER_VERSION,
|
||||
},
|
||||
_meta: {
|
||||
'veritas/providerNeutral': true,
|
||||
'veritas/durableRuns': true,
|
||||
'veritas/supportedMethods': ACP_SERVER_METHODS,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async newSession(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/new params');
|
||||
rejectClientMcp(input.mcpServers);
|
||||
const metadata = optionalRecord(input._meta);
|
||||
const taskReference =
|
||||
this.boundTaskId ??
|
||||
optionalString(metadata['veritas/taskId']) ??
|
||||
optionalString(metadata.veritasTaskId);
|
||||
if (!taskReference) {
|
||||
throw new AcpViewError(
|
||||
-32602,
|
||||
'Bind the server with --task or pass _meta["veritas/taskId"].'
|
||||
);
|
||||
}
|
||||
const task = await this.resolveTask(taskReference);
|
||||
const cwd = requiredString(input.cwd, 'cwd');
|
||||
this.assertTaskWorktree(task, cwd);
|
||||
const sessionId = sessionIdForTask(task.id);
|
||||
this.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
taskId: task.id,
|
||||
cwd,
|
||||
cursor: 0,
|
||||
busy: false,
|
||||
});
|
||||
return {
|
||||
result: {
|
||||
sessionId,
|
||||
_meta: { 'veritas/taskId': task.id },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSession(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/load params');
|
||||
rejectClientMcp(input.mcpServers);
|
||||
const sessionId = requiredString(input.sessionId, 'sessionId');
|
||||
const taskId = taskIdFromSession(sessionId);
|
||||
if (this.boundTaskId) {
|
||||
const boundTask = await this.resolveTask(this.boundTaskId);
|
||||
if (boundTask.id !== taskId) {
|
||||
throw new AcpViewError(-32003, 'ACP session is outside the bound task scope.');
|
||||
}
|
||||
}
|
||||
const task = await this.resolveTask(taskId);
|
||||
const cwd = requiredString(input.cwd, 'cwd');
|
||||
this.assertTaskWorktree(task, cwd);
|
||||
const metadata = optionalRecord(input._meta);
|
||||
const requestedAttemptId = optionalString(metadata['veritas/attemptId']);
|
||||
const attempt = requestedAttemptId
|
||||
? findTaskAttempt(task, requestedAttemptId)
|
||||
: latestTaskAttempt(task);
|
||||
if (requestedAttemptId && !attempt) {
|
||||
throw new AcpViewError(-32602, 'Requested Veritas attempt was not found.');
|
||||
}
|
||||
const afterSequence = optionalNonNegativeInteger(metadata['veritas/afterSequence']) ?? 0;
|
||||
const session: ViewSession = {
|
||||
sessionId,
|
||||
taskId: task.id,
|
||||
cwd,
|
||||
attemptId: attempt?.id,
|
||||
cursor: afterSequence,
|
||||
busy: false,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
return {
|
||||
result: {},
|
||||
...(attempt
|
||||
? {
|
||||
afterResponse: async () => {
|
||||
await this.replayAvailable(session);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async prompt(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/prompt params');
|
||||
const session = this.requireSession(requiredString(input.sessionId, 'sessionId'));
|
||||
if (session.busy) throw new AcpViewError(-32004, 'An ACP prompt is already active.');
|
||||
const message = promptText(input.prompt);
|
||||
session.busy = true;
|
||||
try {
|
||||
const task = await this.resolveTask(session.taskId);
|
||||
this.assertTaskWorktree(task, session.cwd);
|
||||
const status = await this.apiClient<{ running: boolean; attemptId?: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/status`
|
||||
);
|
||||
if (status.running) {
|
||||
throw new AcpViewError(-32004, 'The scoped Veritas task already has an active turn.', {
|
||||
attemptId: status.attemptId,
|
||||
});
|
||||
}
|
||||
const source = session.attemptId
|
||||
? findTaskAttempt(task, session.attemptId)
|
||||
: latestTaskAttempt(task);
|
||||
const result = source?.conversation
|
||||
? await this.apiClient<{ attemptId: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/conversation/follow-up`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sourceAttemptId: source.id,
|
||||
message,
|
||||
profileId: this.profileId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
: await this.apiClient<{ attemptId: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/conversation/fresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
agent: this.profileId ? undefined : this.agent,
|
||||
profileId: this.profileId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
session.attemptId = result.attemptId;
|
||||
session.cursor = 0;
|
||||
const response = await this.streamUntilTerminal(session);
|
||||
return { result: response };
|
||||
} finally {
|
||||
session.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async streamUntilTerminal(session: ViewSession): Promise<AcpPromptResponse> {
|
||||
for (;;) {
|
||||
if (this.disconnected) {
|
||||
throw new AcpViewError(-32006, 'ACP client disconnected from the durable run.');
|
||||
}
|
||||
const page = await this.readEvents(session);
|
||||
for (const event of page.events) {
|
||||
const terminal = await this.projectEvent(session, event);
|
||||
session.cursor = Math.max(session.cursor, event.sequence);
|
||||
if (terminal) return terminal;
|
||||
}
|
||||
if (page.hasMore) continue;
|
||||
await delay(this.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private async replayAvailable(session: ViewSession): Promise<void> {
|
||||
if (!session.attemptId) return;
|
||||
for (;;) {
|
||||
if (this.disconnected) return;
|
||||
const page = await this.readEvents(session);
|
||||
for (const event of page.events) {
|
||||
await this.projectEvent(session, event);
|
||||
session.cursor = Math.max(session.cursor, event.sequence);
|
||||
}
|
||||
if (!page.hasMore) return;
|
||||
}
|
||||
}
|
||||
|
||||
private readEvents(session: ViewSession): Promise<RunEventPage> {
|
||||
if (!session.attemptId) throw new AcpViewError(-32002, 'ACP session has no Veritas attempt.');
|
||||
const query = new URLSearchParams({
|
||||
afterSequence: String(session.cursor),
|
||||
limit: '250',
|
||||
});
|
||||
return this.apiClient<RunEventPage>(
|
||||
`/api/agents/${encodeURIComponent(session.taskId)}/attempts/${encodeURIComponent(
|
||||
session.attemptId
|
||||
)}/events?${query.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
private async projectEvent(
|
||||
session: ViewSession,
|
||||
event: RunEventEnvelope
|
||||
): Promise<AcpPromptResponse | undefined> {
|
||||
if (event.kind === 'approval.requested') {
|
||||
await this.relayApproval(session, event);
|
||||
return undefined;
|
||||
}
|
||||
const update = eventToSessionUpdate(event);
|
||||
if (update) {
|
||||
this.writeRecord({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: session.sessionId,
|
||||
update,
|
||||
_meta: {
|
||||
'veritas/eventId': event.eventId,
|
||||
'veritas/sequence': event.sequence,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (event.kind === 'run.completed') {
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
...(event.payload.usage ? { usage: event.payload.usage } : {}),
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'run.interrupted') {
|
||||
return {
|
||||
stopReason: 'cancelled',
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'run.failed') {
|
||||
return {
|
||||
stopReason: 'refusal',
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async relayApproval(session: ViewSession, event: RunEventEnvelope): Promise<void> {
|
||||
const approvalId = optionalString(event.payload.approvalId);
|
||||
if (!approvalId) throw new AcpViewError(-32005, 'Approval event is missing its durable ID.');
|
||||
const approval = await this.apiClient<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approvalId)}`
|
||||
);
|
||||
const expiresIn = Math.max(1, Date.parse(approval.expiresAt) - this.now());
|
||||
let response: unknown;
|
||||
let timedOut = false;
|
||||
try {
|
||||
response = await this.requestClient(
|
||||
'session/request_permission',
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: approval.providerRequestId,
|
||||
title: approval.action,
|
||||
name: approval.actionClass,
|
||||
kind: approval.actionClass,
|
||||
status: 'pending',
|
||||
rawInput: {
|
||||
details: approval.details,
|
||||
resourceScope: approval.resourceScope,
|
||||
riskClass: approval.riskClass,
|
||||
policyReason: approval.policyReason,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
},
|
||||
expiresIn
|
||||
);
|
||||
} catch {
|
||||
timedOut = true;
|
||||
}
|
||||
const outcome = optionalRecord(optionalRecord(response).outcome);
|
||||
const selected = optionalString(outcome.outcome);
|
||||
const selectedOption = optionalString(outcome.optionId);
|
||||
const approved = selected === 'selected' && selectedOption === 'allow_once';
|
||||
await this.apiClient(`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: approved ? 'approved' : 'rejected',
|
||||
expectedRevision: approval.revision,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: timedOut
|
||||
? 'ACP client permission request timed out.'
|
||||
: approved
|
||||
? 'ACP client selected allow once.'
|
||||
: 'ACP client denied or cancelled the request.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private requestClient(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
|
||||
const id = `vk-client-${this.nextClientRequestId++}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingClientRequests.delete(id);
|
||||
reject(new Error('ACP client request timed out.'));
|
||||
}, timeoutMs);
|
||||
this.pendingClientRequests.set(id, { resolve, reject, timer });
|
||||
this.writeRecord({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
private acceptClientResponse(record: Record<string, unknown>): void {
|
||||
if (!validId(record.id)) return;
|
||||
const pending = this.pendingClientRequests.get(record.id);
|
||||
if (!pending) return;
|
||||
this.pendingClientRequests.delete(record.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (isRecord(record.error)) {
|
||||
pending.reject(
|
||||
new Error(optionalString(record.error.message) ?? 'ACP client request failed.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.resolve(record.result);
|
||||
}
|
||||
|
||||
private async resolveTask(reference: string): Promise<Task> {
|
||||
const tasks = await this.apiClient<Task[]>('/api/tasks');
|
||||
const exact = tasks.find((task) => task.id === reference);
|
||||
const suffixMatches = exact ? [] : tasks.filter((task) => task.id.endsWith(reference));
|
||||
const task = exact ?? (suffixMatches.length === 1 ? suffixMatches[0] : undefined);
|
||||
if (!task) {
|
||||
throw new AcpViewError(
|
||||
-32003,
|
||||
suffixMatches.length > 1 ? 'Task reference is ambiguous.' : 'Task was not found.'
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private assertTaskWorktree(task: Task, cwd: string): void {
|
||||
const worktree = task.git?.worktreePath;
|
||||
if (!worktree) throw new AcpViewError(-32003, 'Task has no active worktree.');
|
||||
if (path.resolve(worktree) !== path.resolve(cwd)) {
|
||||
throw new AcpViewError(-32003, 'ACP cwd does not match the task worktree.');
|
||||
}
|
||||
}
|
||||
|
||||
private requireSession(sessionId: string): ViewSession {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) throw new AcpViewError(-32002, 'ACP session is not loaded in this process.');
|
||||
return session;
|
||||
}
|
||||
|
||||
private writeError(id: AcpJsonRpcId | null, code: number, message: string, data?: unknown): void {
|
||||
this.writeRecord({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(data === undefined ? {} : { data }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAcpStatus(apiClient: AcpApiClient = api): Promise<AcpStatus> {
|
||||
try {
|
||||
const context = await apiClient<ClientAuthContext>('/api/auth/context');
|
||||
return {
|
||||
schemaVersion: 'veritas-acp-server-status/v1',
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
transport: 'stdio',
|
||||
ready: true,
|
||||
methods: ACP_SERVER_METHODS,
|
||||
durableRuns: true,
|
||||
providerNeutral: true,
|
||||
role: context.role,
|
||||
workspaceId: context.workspaceId,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
schemaVersion: 'veritas-acp-server-status/v1',
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
transport: 'stdio',
|
||||
ready: false,
|
||||
methods: ACP_SERVER_METHODS,
|
||||
durableRuns: true,
|
||||
providerNeutral: true,
|
||||
error: boundedError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcpStdioServer(options: {
|
||||
stdin?: Readable;
|
||||
stdout?: Writable;
|
||||
stderr?: Writable;
|
||||
boundTaskId?: string;
|
||||
agent?: string;
|
||||
profileId?: string;
|
||||
api?: AcpApiClient;
|
||||
pollIntervalMs?: number;
|
||||
}): void {
|
||||
const input = options.stdin ?? process.stdin;
|
||||
const output = options.stdout ?? process.stdout;
|
||||
const errors = options.stderr ?? process.stderr;
|
||||
const server = new AcpServerView({
|
||||
...(options.api ? { api: options.api } : {}),
|
||||
boundTaskId: options.boundTaskId,
|
||||
agent: options.agent,
|
||||
profileId: options.profileId,
|
||||
pollIntervalMs: options.pollIntervalMs,
|
||||
write: (record) => {
|
||||
output.write(`${JSON.stringify(record)}\n`);
|
||||
},
|
||||
});
|
||||
input.setEncoding('utf8');
|
||||
const lines = readline.createInterface({ input });
|
||||
lines.on('line', (line) => {
|
||||
void server.acceptLine(line);
|
||||
});
|
||||
lines.on('error', (error) => {
|
||||
errors.write(`ACP stdio input failed: ${boundedError(error)}\n`);
|
||||
});
|
||||
lines.on('close', () => {
|
||||
server.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAcpCommands(program: Command): void {
|
||||
const acp = program.command('acp').description('Agent Client Protocol server view');
|
||||
acp
|
||||
.command('status')
|
||||
.description('Report ACP server-view readiness')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const status = await readAcpStatus();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
`${status.ready ? 'ready' : 'not ready'}: ACP v${status.protocolVersion} over ${status.transport}`
|
||||
);
|
||||
if (status.error) console.error(status.error);
|
||||
}
|
||||
if (!status.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
acp
|
||||
.command('serve')
|
||||
.description('Serve the provider-neutral Veritas ACP view over stdio')
|
||||
.requiredOption('--stdio', 'Use newline-delimited JSON-RPC over stdio')
|
||||
.option('--task <taskId>', 'Bind this process to one Veritas task')
|
||||
.option('--agent <agent>', 'Agent for a fresh scoped conversation')
|
||||
.option('--profile <profileId>', 'Agent profile for a fresh scoped conversation')
|
||||
.action(
|
||||
(options: { stdio: boolean; task?: string; agent?: string; profile?: string }): void => {
|
||||
runAcpStdioServer({
|
||||
boundTaskId: options.task,
|
||||
agent: options.agent,
|
||||
profileId: options.profile,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function eventToSessionUpdate(event: RunEventEnvelope): Record<string, unknown> | undefined {
|
||||
const summary = eventSummary(event);
|
||||
switch (event.kind) {
|
||||
case 'message.delta':
|
||||
return {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
};
|
||||
case 'reasoning.delta':
|
||||
return {
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
};
|
||||
case 'tool.started':
|
||||
return {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: event.itemId ?? event.eventId,
|
||||
title: summary,
|
||||
kind: optionalString(event.payload.actionClass) ?? 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: event.payload.input,
|
||||
};
|
||||
case 'tool.completed':
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: event.itemId ?? event.eventId,
|
||||
status: event.payload.success === false ? 'failed' : 'completed',
|
||||
content: summary ? [{ type: 'content', content: { type: 'text', text: summary } }] : [],
|
||||
};
|
||||
case 'progress':
|
||||
return {
|
||||
sessionUpdate: 'plan',
|
||||
entries: [{ content: summary, priority: 'medium', status: 'in_progress' }],
|
||||
};
|
||||
case 'approval.resolved':
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: optionalString(event.payload.approvalId) ?? event.eventId,
|
||||
status: event.payload.status === 'approved' ? 'completed' : 'failed',
|
||||
content: summary ? [{ type: 'content', content: { type: 'text', text: summary } }] : [],
|
||||
};
|
||||
case 'run.failed':
|
||||
return summary
|
||||
? {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
}
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function eventSummary(event: RunEventEnvelope): string {
|
||||
return (
|
||||
optionalString(event.payload.summary) ??
|
||||
optionalString(event.payload.message) ??
|
||||
optionalString(event.payload.error) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function promptText(value: unknown): string {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new AcpViewError(-32602, 'session/prompt requires at least one text block.');
|
||||
}
|
||||
const blocks = value as AcpContentBlock[];
|
||||
const unsupported = blocks.find((block) => !isRecord(block) || block.type !== 'text');
|
||||
if (unsupported) {
|
||||
throw new AcpViewError(-32602, 'The Veritas ACP server view accepts text prompts only.');
|
||||
}
|
||||
const text = blocks
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (!text || text.length > 20_000) {
|
||||
throw new AcpViewError(-32602, 'ACP prompt must contain 1 to 20,000 text characters.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function rejectClientMcp(value: unknown): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) throw new AcpViewError(-32602, 'mcpServers must be an array.');
|
||||
if (value.length > 0) {
|
||||
throw new AcpViewError(
|
||||
-32003,
|
||||
'ACP clients cannot override the immutable Veritas run tool catalog.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIdForTask(taskId: string): string {
|
||||
return `vkacp_${Buffer.from(taskId, 'utf8').toString('base64url')}`;
|
||||
}
|
||||
|
||||
function taskIdFromSession(sessionId: string): string {
|
||||
if (!/^vkacp_[A-Za-z0-9_-]+$/.test(sessionId)) {
|
||||
throw new AcpViewError(-32602, 'Invalid Veritas ACP session ID.');
|
||||
}
|
||||
try {
|
||||
const taskId = Buffer.from(sessionId.slice('vkacp_'.length), 'base64url').toString('utf8');
|
||||
if (!taskId || Buffer.byteLength(taskId, 'utf8') > 200) throw new Error('invalid');
|
||||
return taskId;
|
||||
} catch {
|
||||
throw new AcpViewError(-32602, 'Invalid Veritas ACP session ID.');
|
||||
}
|
||||
}
|
||||
|
||||
function latestTaskAttempt(task: Task): TaskAttempt | undefined {
|
||||
const attempts = [task.attempt, ...(task.attempts ?? [])].filter(
|
||||
(attempt): attempt is TaskAttempt => Boolean(attempt)
|
||||
);
|
||||
return attempts.sort((left, right) => {
|
||||
const leftTime = Date.parse(left.started ?? left.ended ?? '') || 0;
|
||||
const rightTime = Date.parse(right.started ?? right.ended ?? '') || 0;
|
||||
return rightTime - leftTime;
|
||||
})[0];
|
||||
}
|
||||
|
||||
function findTaskAttempt(task: Task, attemptId: string): TaskAttempt | undefined {
|
||||
return [task.attempt, ...(task.attempts ?? [])]
|
||||
.filter((attempt): attempt is TaskAttempt => Boolean(attempt))
|
||||
.find((attempt) => attempt.id === attemptId);
|
||||
}
|
||||
|
||||
function requiredRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new AcpViewError(-32602, `${label} must be an object.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const result = optionalString(value);
|
||||
if (!result) throw new AcpViewError(-32602, `${label} must be a non-empty string.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function optionalNonNegativeInteger(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validId(value: unknown): value is AcpJsonRpcId {
|
||||
return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function boundedError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/[\r\n\t]+/g, ' ').slice(0, 1_000);
|
||||
}
|
||||
|
||||
class AcpViewError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static internal(error: unknown): AcpViewError {
|
||||
return new AcpViewError(-32000, boundedError(error));
|
||||
}
|
||||
}
|
||||
409
cli/src/commands/admission.ts
Normal file
409
cli/src/commands/admission.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { Command } from 'commander';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
AdmissionExecutionTreeCancellationResult,
|
||||
AdmissionLaunchSource,
|
||||
AdmissionQueueGetResponse,
|
||||
AdmissionQueueInspectionEntry,
|
||||
AdmissionQueueListResponse,
|
||||
AdmissionQueueState,
|
||||
AdmissionQueuedCancellationResult,
|
||||
AdmissionReservation,
|
||||
AdmissionReservationState,
|
||||
AdmissionScope,
|
||||
ExecutionTreeBudgetSummary,
|
||||
ExecutionTreeControl,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface AdmissionListResponse {
|
||||
generatedAt: string;
|
||||
reservations: AdmissionReservation[];
|
||||
}
|
||||
|
||||
export function registerAdmissionCommands(program: Command): void {
|
||||
const admission = program
|
||||
.command('admission')
|
||||
.description('Inspect durable execution admission reservations');
|
||||
|
||||
const queue = admission.command('queue').description('Inspect the durable admission queue');
|
||||
|
||||
queue
|
||||
.command('list')
|
||||
.description('List queued, leased, dispatched, or terminal admission entries')
|
||||
.option('--workspace <id>', 'Filter by workspace')
|
||||
.option('--root-objective <id>', 'Filter by execution-tree root objective')
|
||||
.option('--node <id>', 'Filter by execution-tree node')
|
||||
.option('--source <sources...>', 'Filter by launch source')
|
||||
.option('--state <states...>', 'Filter by queue state')
|
||||
.option('--priority <level>', 'Filter by raw numeric priority')
|
||||
.option('--limiting-scope <scopes...>', 'Filter by limiting scope')
|
||||
.option('--min-age <milliseconds>', 'Minimum queue age in milliseconds')
|
||||
.option('--max-age <milliseconds>', 'Maximum queue age in milliseconds')
|
||||
.option('--page <number>', 'Result page', '1')
|
||||
.option('--limit <count>', 'Maximum entries per page', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (options.workspace) query.set('workspaceId', options.workspace);
|
||||
if (options.rootObjective) query.set('rootObjectiveId', options.rootObjective);
|
||||
if (options.node) query.set('nodeId', options.node);
|
||||
for (const source of (options.source ?? []) as AdmissionLaunchSource[]) {
|
||||
query.append('source', source);
|
||||
}
|
||||
for (const state of (options.state ?? []) as AdmissionQueueState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
if (options.priority) query.set('priority', options.priority);
|
||||
for (const scope of (options.limitingScope ?? []) as AdmissionScope[]) {
|
||||
query.append('limitingScope', scope);
|
||||
}
|
||||
if (options.minAge) query.set('minAgeMs', options.minAge);
|
||||
if (options.maxAge) query.set('maxAgeMs', options.maxAge);
|
||||
query.set('page', options.page);
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<AdmissionQueueListResponse>(
|
||||
`/api/admission/queue?${query.toString()}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.entries.length === 0) {
|
||||
console.log(chalk.dim('No admission queue entries matched.'));
|
||||
return;
|
||||
}
|
||||
for (const entry of result.entries) printQueueEntry(entry);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Conditional snapshot at ${result.generatedAt}; ${result.depth.global.current}/${result.depth.global.limit} global queue slots used.`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
queue
|
||||
.command('cancel <id>')
|
||||
.description('Cancel one queued launch before provider dispatch')
|
||||
.requiredOption('--reason <text>', 'Operator reason for cancellation')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionQueuedCancellationResult>(
|
||||
`/api/admission/queue/${encodeURIComponent(id)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey: options.idempotencyKey ?? `vk-cli:queue-cancel:${id}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Cancelled queued launch ${result.queueEntry.id}`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`State: ${result.queueEntry.state}; reservation released: ${result.reservationReleased}`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
queue
|
||||
.command('get <id>')
|
||||
.description('Inspect one admission queue entry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionQueueGetResponse>(
|
||||
`/api/admission/queue/${encodeURIComponent(id)}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printQueueEntry(result.entry, true);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Conditional snapshot at ${result.generatedAt}; capacity, policy, arrivals, and leases may change position.`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('list')
|
||||
.description('List active or recently terminal admission reservations')
|
||||
.option('--workspace <id>', 'Filter by workspace')
|
||||
.option('--task <id>', 'Filter by task')
|
||||
.option('--root-task <id>', 'Filter by root task')
|
||||
.option('--provider <provider>', 'Filter by provider')
|
||||
.option('--host <id>', 'Filter by launch host')
|
||||
.option('--workflow-run <id>', 'Filter by workflow run')
|
||||
.option('--workflow-step <id>', 'Filter by workflow step')
|
||||
.option('--root-reservation <id>', 'Filter by workflow root reservation')
|
||||
.option('--root-objective <id>', 'Filter by execution-tree root objective')
|
||||
.option('--node <id>', 'Filter by execution-tree node')
|
||||
.option('--parent-node <id>', 'Filter by execution-tree parent node')
|
||||
.option('--state <states...>', 'Filter by state (active, released, expired)')
|
||||
.option('--limit <count>', 'Maximum records', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (options.workspace) query.set('workspaceId', options.workspace);
|
||||
if (options.task) query.set('taskId', options.task);
|
||||
if (options.rootTask) query.set('rootTaskId', options.rootTask);
|
||||
if (options.provider) query.set('provider', options.provider);
|
||||
if (options.host) query.set('hostId', options.host);
|
||||
if (options.workflowRun) query.set('workflowRunId', options.workflowRun);
|
||||
if (options.workflowStep) query.set('workflowStepId', options.workflowStep);
|
||||
if (options.rootReservation) query.set('rootReservationId', options.rootReservation);
|
||||
if (options.rootObjective) query.set('rootObjectiveId', options.rootObjective);
|
||||
if (options.node) query.set('nodeId', options.node);
|
||||
if (options.parentNode) query.set('parentNodeId', options.parentNode);
|
||||
for (const state of (options.state ?? []) as AdmissionReservationState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<AdmissionListResponse>(`/api/admission?${query.toString()}`);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.reservations.length === 0) {
|
||||
console.log(chalk.dim('No admission reservations matched.'));
|
||||
return;
|
||||
}
|
||||
for (const reservation of result.reservations) printReservation(reservation);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('tree <root-objective-id>')
|
||||
.description('Inspect aggregate usage and reservations for one execution tree')
|
||||
.option('--limit <count>', 'Maximum contributors', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: options.limit });
|
||||
const result = await api<ExecutionTreeBudgetSummary>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}?${query.toString()}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printExecutionTreeSummary(result);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('cancel-tree <root-objective-id>')
|
||||
.description('Cancel queued and verified running work for one execution tree')
|
||||
.requiredOption('--reason <text>', 'Operator reason for cancellation')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionExecutionTreeCancellationResult>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey:
|
||||
options.idempotencyKey ?? `vk-cli:tree-cancel:${rootObjectiveId}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Cancelled execution tree ${result.rootObjectiveId}`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Queued: ${result.queueEntriesCancelled}; interrupted: ${result.interruptedAttempts}; remaining verified runs: ${result.runningAttempts.length}`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('resume-tree <root-objective-id>')
|
||||
.description('Resume an eligible execution tree after its fan-out breaker pauses')
|
||||
.requiredOption('--reason <text>', 'Operator reason for resuming expansion')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const result = await api<ExecutionTreeControl>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}/resume`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey:
|
||||
options.idempotencyKey ?? `vk-cli:tree-resume:${rootObjectiveId}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Resumed execution tree ${result.rootObjectiveId}`));
|
||||
console.log(chalk.dim(`Recorded: ${result.resumedAt}; reason: ${result.resumeReason}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('get <id>')
|
||||
.description('Inspect one admission reservation')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionReservation>(`/api/admission/${encodeURIComponent(id)}`);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printReservation(result, true);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function printQueueEntry(entry: AdmissionQueueInspectionEntry, verbose = false): void {
|
||||
console.log(
|
||||
`${entry.position ?? '-'} ${entry.state} ${chalk.bold(entry.id)} priority=${entry.rawPriority}->${entry.effectivePriority} readiness=${entry.readiness}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` source=${entry.launch.source} target=${entry.launch.target} age=${entry.ageMs}ms lease=${entry.lease.posture}`
|
||||
)
|
||||
);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` retries=${entry.retry.count}/${entry.retry.maximum} available=${entry.retry.availableAt}`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(` conditional=${entry.conditionalStartFactors.join(',') || 'capacity-recheck'}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printReservation(reservation: AdmissionReservation, verbose = false): void {
|
||||
const state =
|
||||
reservation.state === 'active'
|
||||
? chalk.green(reservation.state)
|
||||
: reservation.state === 'released'
|
||||
? chalk.blue(reservation.state)
|
||||
: chalk.yellow(reservation.state);
|
||||
console.log(
|
||||
`${state} ${chalk.bold(reservation.id)} task=${reservation.request.taskId} provider=${reservation.request.provider}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` workspace=${reservation.request.workspaceId} root=${reservation.request.rootTaskId} host=${reservation.request.hostId}`
|
||||
)
|
||||
);
|
||||
if (reservation.request.workflowRunId) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` workflow=${reservation.request.workflowRunId} step=${reservation.request.workflowStepId ?? 'root'} root-reservation=${reservation.request.rootReservationId ?? reservation.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (reservation.request.executionTree) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` objective=${reservation.request.executionTree.rootObjectiveId} node=${reservation.request.executionTree.nodeId} parent=${reservation.request.executionTree.parentNodeId ?? 'root'} edge=${reservation.request.executionTree.edge}`
|
||||
)
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` capacity runs=${reservation.request.requested.runSlots} processes=${reservation.request.requested.processSlots} memory=${reservation.request.requested.estimatedMemoryMb}MB`
|
||||
)
|
||||
);
|
||||
if (verbose || reservation.state === 'active') {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` attempt=${reservation.attemptId ?? 'unbound'} lease=${reservation.lease.expiresAt} revision=${reservation.revision}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (reservation.release) {
|
||||
console.log(
|
||||
chalk.dim(` released=${reservation.release.reason} at ${reservation.release.releasedAt}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printExecutionTreeSummary(summary: ExecutionTreeBudgetSummary): void {
|
||||
console.log(chalk.bold(`Execution tree ${summary.rootObjectiveId}`));
|
||||
if (summary.control) {
|
||||
const color = summary.control.state === 'resumed' ? chalk.green : chalk.red;
|
||||
console.log(
|
||||
color(
|
||||
` control=${summary.control.state} trigger=${summary.control.trigger} recorded=${summary.control.recordedAt}`
|
||||
)
|
||||
);
|
||||
console.log(chalk.dim(` reason=${summary.control.reason}`));
|
||||
if (summary.control.resumedAt) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` resumed=${summary.control.resumedAt} resume-reason=${summary.control.resumeReason}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` committed tokens=${summary.committed.totalTokens} cost=$${summary.committed.costUsd.toFixed(4)} tools=${summary.committed.toolCalls} runtime=${summary.committed.runtimeSeconds}s retries=${summary.committed.retries} fan-out=${summary.committed.fanOut}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` reserved tokens=${summary.reserved.totalTokens} cost=$${summary.reserved.costUsd.toFixed(4)} tools=${summary.reserved.toolCalls} runtime=${summary.reserved.runtimeSeconds}s retries=${summary.reserved.retries} fan-out=${summary.reserved.fanOut}`
|
||||
)
|
||||
);
|
||||
for (const status of summary.policies) {
|
||||
console.log(
|
||||
`${status.blocksNextLaunch ? chalk.red('blocked') : chalk.green('available')} ${status.policy.name} (${status.policy.scope}:${status.policy.scopeId})`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` contributors=${summary.contributorCount}${summary.truncated ? ` (showing ${summary.contributors.length})` : ''}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
|
@ -1,7 +1,190 @@
|
|||
import { Command } from 'commander';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import type {
|
||||
AgentProfileExportResult,
|
||||
AgentProfilePackageFormat,
|
||||
AgentProfilePackageSummary,
|
||||
AgentProfileValidationResult,
|
||||
ConversationLifecycleRecord,
|
||||
ConversationLifecycleResult,
|
||||
PhaseCapabilityEvidence,
|
||||
PhaseTransitionRecord,
|
||||
PhaseTransitionResult,
|
||||
RunApprovalRequest,
|
||||
RunRecoveryRecord,
|
||||
RunLaunchManifestPreview,
|
||||
RunPhaseAuthoritySnapshot,
|
||||
WorkspaceExecutionTrustDecision,
|
||||
WorkspaceExecutionTrustDecisionMode,
|
||||
WorkspaceExecutionTrustScanResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
type ConversationTurnAction = 'resume' | 'follow-up' | 'fork';
|
||||
type ConversationControlAction = 'interrupt' | 'compact' | 'archive' | 'close';
|
||||
|
||||
interface ConversationTurnOptions {
|
||||
sourceAttempt: string;
|
||||
message: string;
|
||||
forkTurn?: string;
|
||||
profile?: string;
|
||||
phase?: string;
|
||||
requireCapability?: string[];
|
||||
commitPolicy?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ConversationControlOptions {
|
||||
attempt: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface PhaseTransitionOptions {
|
||||
attempt: string;
|
||||
operation: string;
|
||||
targetEvidence: string;
|
||||
fromEvidence?: string;
|
||||
manifest?: string;
|
||||
reason: string;
|
||||
approvalId?: string;
|
||||
approvalTtlMs?: string;
|
||||
overrideUntil?: string;
|
||||
overrideReason?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function inferProfileFormat(filePath: string): AgentProfilePackageFormat {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return extension === '.json' ? 'json' : 'yaml';
|
||||
}
|
||||
|
||||
async function resolveTaskId(id: string): Promise<string> {
|
||||
const task = await findTask(id);
|
||||
if (!task) throw new Error(`Task not found: ${id}`);
|
||||
return task.id;
|
||||
}
|
||||
|
||||
function readPhaseEvidence(filePath: string): PhaseCapabilityEvidence {
|
||||
return JSON.parse(readFileSync(path.resolve(filePath), 'utf8')) as PhaseCapabilityEvidence;
|
||||
}
|
||||
|
||||
function printConversationResult(
|
||||
action: string,
|
||||
result: {
|
||||
attemptId: string;
|
||||
delivered?: boolean;
|
||||
note?: string;
|
||||
conversation?: ConversationLifecycleRecord;
|
||||
},
|
||||
json?: boolean
|
||||
): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Conversation ${action}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
if (result.conversation?.conversationId) {
|
||||
console.log(chalk.dim(`Conversation ID: ${result.conversation.conversationId}`));
|
||||
}
|
||||
if (result.note) console.log(chalk.dim(result.note));
|
||||
}
|
||||
|
||||
function phaseIdentityLabel(record: PhaseTransitionRecord): string {
|
||||
return phaseEvidenceIdentityLabel(record.effectiveEvidence);
|
||||
}
|
||||
|
||||
function phaseEvidenceIdentityLabel(evidence: PhaseCapabilityEvidence): string {
|
||||
const identity = evidence.identity;
|
||||
return identity.mode === 'legacy'
|
||||
? 'legacy'
|
||||
: `${identity.phase} (${identity.profileId}@${identity.profileVersion})`;
|
||||
}
|
||||
|
||||
function registerConversationTurnCommand(
|
||||
program: Command,
|
||||
action: ConversationTurnAction,
|
||||
description: string
|
||||
): void {
|
||||
const command = program
|
||||
.command(`agent:${action} <id>`)
|
||||
.description(description)
|
||||
.requiredOption('--source-attempt <attemptId>', 'Terminal attempt with durable conversation')
|
||||
.requiredOption('-m, --message <text>', 'Prompt for the new turn')
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to launch')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--json', 'Output as JSON');
|
||||
|
||||
if (action === 'fork') {
|
||||
command.option('--fork-turn <turnId>', 'Provider turn boundary to fork from');
|
||||
}
|
||||
|
||||
command.action(async (id: string, options: ConversationTurnOptions) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{
|
||||
attemptId: string;
|
||||
conversation?: ConversationLifecycleRecord;
|
||||
}>(`/api/agents/${taskId}/conversation/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sourceAttemptId: options.sourceAttempt,
|
||||
message: options.message,
|
||||
...(action === 'fork' && options.forkTurn ? { forkTurnId: options.forkTurn } : {}),
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
idempotencyKey: `vk-cli:${taskId}:conversation:${action}:${randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
printConversationResult(action, result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerConversationControlCommand(
|
||||
program: Command,
|
||||
action: ConversationControlAction,
|
||||
description: string
|
||||
): void {
|
||||
program
|
||||
.command(`agent:${action} <id>`)
|
||||
.description(description)
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: ConversationControlOptions) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<ConversationLifecycleResult>(
|
||||
`/api/agents/${taskId}/conversation/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: options.attempt }),
|
||||
}
|
||||
);
|
||||
printConversationResult(action, result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
// Start agent on task
|
||||
|
|
@ -13,6 +196,17 @@ export function registerAgentCommands(program: Command): void {
|
|||
'Agent to use (claude-code, amp, copilot, gemini)',
|
||||
'claude-code'
|
||||
)
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to launch')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--parent-attempt <attemptId>', 'Compare launch inputs with a parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -35,13 +229,21 @@ export function registerAgentCommands(program: Command): void {
|
|||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent: options.agent }),
|
||||
body: JSON.stringify({
|
||||
agent: options.profile ? undefined : options.agent,
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
idempotencyKey: `vk-cli:${task.id}:${randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Agent started: ${options.agent}`));
|
||||
console.log(chalk.green(`✓ Agent started: ${options.profile || options.agent}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
console.log(chalk.dim(`Working in: ${task.git.worktreePath}`));
|
||||
}
|
||||
|
|
@ -51,6 +253,309 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('launch-preview <id>')
|
||||
.description('Preview the immutable effective launch manifest without starting an agent')
|
||||
.option('-a, --agent <agent>', 'Agent to use', 'codex')
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to preview')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--parent-attempt <attemptId>', 'Compare launch inputs with a parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
if (!task) throw new Error(`Task not found: ${id}`);
|
||||
const preview = await api<RunLaunchManifestPreview>(
|
||||
`/api/agents/${task.id}/launch-preview`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: options.profile ? undefined : options.agent,
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('Run launch manifest'));
|
||||
console.log(` Digest: ${preview.manifest.digest}`);
|
||||
console.log(` Provider: ${preview.manifest.providerRuntime.provider}`);
|
||||
console.log(` Model: ${preview.manifest.runtime.model ?? 'provider default'}`);
|
||||
console.log(
|
||||
` Phase: ${
|
||||
preview.manifest.phase?.evidence.identity.mode === 'profile'
|
||||
? preview.manifest.phase.evidence.identity.phase
|
||||
: 'legacy'
|
||||
}`
|
||||
);
|
||||
if (preview.manifest.phase) {
|
||||
console.log(` Phase evidence: ${preview.manifest.phase.evidence.digest}`);
|
||||
}
|
||||
console.log(` Workspace trust: ${preview.manifest.workspaceTrust.status}`);
|
||||
console.log(chalk.dim(` ${preview.manifest.workspaceTrust.source}`));
|
||||
console.log(
|
||||
` Enforceable: ${preview.manifest.enforcement.enforceable ? chalk.green('yes') : chalk.red('no')}`
|
||||
);
|
||||
for (const blocker of preview.manifest.enforcement.blockers) {
|
||||
console.log(chalk.red(` Blocker ${blocker.code}: ${blocker.detail}`));
|
||||
}
|
||||
if (preview.drift) {
|
||||
console.log(
|
||||
` Parent drift: ${preview.drift.material ? chalk.yellow('material') : chalk.green('none')}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const workspaceTrust = program
|
||||
.command('workspace-trust')
|
||||
.description('Inspect and manage repository execution trust');
|
||||
|
||||
workspaceTrust
|
||||
.command('scan <id>')
|
||||
.description('Scan repository-controlled instructions and executable configuration')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<WorkspaceExecutionTrustScanResult>(
|
||||
`/api/agents/${taskId}/workspace-trust`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('Workspace execution trust'));
|
||||
console.log(` Identity: ${result.inventory.identity.digest}`);
|
||||
console.log(` Inventory: ${result.inventory.digest}`);
|
||||
console.log(` Project maximum: ${result.inventory.projectPolicy.maximumTrust}`);
|
||||
console.log(` Current decision: ${result.currentDecision?.mode ?? chalk.yellow('none')}`);
|
||||
if (result.inventory.entries.length === 0) {
|
||||
console.log(chalk.dim(' No recognized repository-controlled components found.'));
|
||||
return;
|
||||
}
|
||||
for (const entry of result.inventory.entries) {
|
||||
console.log(
|
||||
` ${entry.posture === 'executable' ? chalk.red('!') : chalk.yellow('•')} ${entry.relativePath}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${entry.kind}; ${entry.posture}; ${entry.requestedCapabilities.join(', ')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('decide <id>')
|
||||
.description('Record trust, restricted, or denied for an exact scanned inventory')
|
||||
.requiredOption('--mode <mode>', 'Decision mode: trusted, restricted, or denied')
|
||||
.requiredOption('--inventory <digest>', 'Exact inventory digest from workspace-trust scan')
|
||||
.requiredOption('--reason <text>', 'Reason for the decision')
|
||||
.option('--expires-at <timestamp>', 'Optional ISO-8601 expiry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const mode = options.mode as WorkspaceExecutionTrustDecisionMode;
|
||||
if (!['trusted', 'restricted', 'denied'].includes(mode)) {
|
||||
throw new Error('Mode must be trusted, restricted, or denied.');
|
||||
}
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/decisions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
expiresAt: options.expiresAt,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Workspace decision recorded: ${decision.mode}`));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('revoke <id>')
|
||||
.description('Revoke the current workspace execution trust decision')
|
||||
.requiredOption('--inventory <digest>', 'Exact current inventory digest')
|
||||
.requiredOption('--reason <text>', 'Reason for revocation')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green('✓ Workspace execution trust decision revoked'));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const profiles = program
|
||||
.command('profiles')
|
||||
.description('Manage reusable agent profile packages');
|
||||
|
||||
profiles
|
||||
.command('list')
|
||||
.description('List imported agent profile packages')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const rows = await api<AgentProfilePackageSummary[]>('/api/config/agent-profiles');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
console.log(chalk.dim('No agent profile packages installed'));
|
||||
return;
|
||||
}
|
||||
for (const profile of rows) {
|
||||
console.log(
|
||||
`${profile.enabled ? chalk.green('●') : chalk.gray('○')} ${chalk.bold(profile.id)} ${chalk.dim(profile.version)}`
|
||||
);
|
||||
console.log(` ${profile.displayName} — ${profile.role}`);
|
||||
console.log(
|
||||
` agent=${profile.runtime.agent}${profile.runtime.model ? ` model=${profile.runtime.model}` : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('validate <file>')
|
||||
.description('Validate an agent profile package YAML or JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<AgentProfileValidationResult>(
|
||||
'/api/config/agent-profiles/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferProfileFormat(file), source: file }),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.valid) {
|
||||
console.log(chalk.green(`✓ Valid profile package: ${result.profile?.id}`));
|
||||
} else {
|
||||
console.log(chalk.red('Invalid profile package'));
|
||||
for (const issue of result.issues) {
|
||||
console.log(chalk.dim(` ${issue.path}: ${issue.message}`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('import <file>')
|
||||
.description('Import or replace an agent profile package')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<{
|
||||
profile: { id: string; displayName: string; version: string };
|
||||
created: boolean;
|
||||
}>('/api/config/agent-profiles/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferProfileFormat(file), source: file }),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✓ ${result.created ? 'Imported' : 'Updated'} ${result.profile.displayName} (${result.profile.id}@${result.profile.version})`
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('export <profileId>')
|
||||
.description('Export an agent profile package')
|
||||
.option('-f, --format <format>', 'yaml or json', 'yaml')
|
||||
.option('-o, --output <file>', 'Write export to a file')
|
||||
.action(async (profileId, options) => {
|
||||
try {
|
||||
const format = options.format === 'json' ? 'json' : 'yaml';
|
||||
const result = await api<AgentProfileExportResult>(
|
||||
`/api/config/agent-profiles/${encodeURIComponent(profileId)}/export?format=${format}`
|
||||
);
|
||||
if (options.output) {
|
||||
writeFileSync(options.output, result.content, 'utf-8');
|
||||
console.log(chalk.green(`✓ Exported ${profileId} to ${options.output}`));
|
||||
} else {
|
||||
process.stdout.write(result.content);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop agent
|
||||
program
|
||||
.command('stop <id>')
|
||||
|
|
@ -65,7 +570,16 @@ export function registerAgentCommands(program: Command): void {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
const status = await api<{ running: boolean; attemptId?: string }>(
|
||||
`/api/agents/${task.id}/status`
|
||||
);
|
||||
if (!status.running || !status.attemptId) {
|
||||
throw new Error('No active agent attempt is available to stop');
|
||||
}
|
||||
await api(`/api/agents/${task.id}/stop`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: status.attemptId }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ stopped: true }));
|
||||
|
|
@ -78,6 +592,302 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:recovery <id>')
|
||||
.description('Show the latest durable retry or fallback decision for a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{ recovery: RunRecoveryRecord | null }>(
|
||||
`/api/agents/${taskId}/recovery`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (!result.recovery) {
|
||||
console.log(chalk.dim('No recovery decision is recorded for this task'));
|
||||
} else {
|
||||
console.log(chalk.yellow(`Recovery: ${result.recovery.state}`));
|
||||
console.log(` Action: ${result.recovery.action}`);
|
||||
console.log(` Attempt: ${result.recovery.parentRunId}`);
|
||||
console.log(` Sequence: ${result.recovery.sequence}`);
|
||||
console.log(` Reason: ${result.recovery.reason}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:phase <id>')
|
||||
.description('Show the active durable phase and transition history for an exact run')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact attempt ID')
|
||||
.option('--limit <count>', 'Maximum transition records', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { attempt: string; limit: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{
|
||||
phase: RunPhaseAuthoritySnapshot | null;
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(
|
||||
`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}&limit=${encodeURIComponent(options.limit)}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (!result.phase) {
|
||||
console.log(chalk.dim('This legacy run has no phase authority evidence'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.cyan(`Phase: ${phaseEvidenceIdentityLabel(result.phase.effectiveEvidence)}`)
|
||||
);
|
||||
console.log(chalk.dim(`Sequence: ${result.phase.transitionSequence}`));
|
||||
console.log(chalk.dim(`Evidence: ${result.phase.effectiveEvidence.digest}`));
|
||||
console.log(chalk.dim(`Manifest: ${result.phase.manifestDigest}`));
|
||||
if (result.current?.emergencyOverride) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Emergency override expires: ${result.current.emergencyOverride.expiresAt}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:transition-phase <id>')
|
||||
.description('Apply or request approval for one compare-and-set phase transition')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.requiredOption('--operation <id>', 'Stable idempotency key for this transition')
|
||||
.requiredOption('--target-evidence <file>', 'Compiled target phase evidence JSON')
|
||||
.requiredOption('--reason <text>', 'Operator reason')
|
||||
.option('--from-evidence <file>', 'Initial phase evidence JSON for the first transition')
|
||||
.option('--manifest <digest>', 'Launch manifest digest for the first transition')
|
||||
.option('--approval-id <id>', 'Exact approval returned by the prior request')
|
||||
.option('--approval-ttl-ms <milliseconds>', 'Approval request lifetime')
|
||||
.option('--override-until <timestamp>', 'Emergency override expiry, at most 24 hours')
|
||||
.option('--override-reason <text>', 'Emergency override justification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: PhaseTransitionOptions): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const state = await api<{
|
||||
phase: RunPhaseAuthoritySnapshot | null;
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}`);
|
||||
const fromEvidence = options.fromEvidence
|
||||
? readPhaseEvidence(options.fromEvidence)
|
||||
: undefined;
|
||||
const priorEvidence =
|
||||
state.phase?.effectiveEvidence ?? state.current?.effectiveEvidence ?? fromEvidence;
|
||||
if (!priorEvidence) {
|
||||
throw new Error('The first transition requires --from-evidence');
|
||||
}
|
||||
const manifestDigest =
|
||||
state.phase?.manifestDigest ?? state.current?.manifestDigest ?? options.manifest;
|
||||
if (!manifestDigest) {
|
||||
throw new Error('The first transition requires --manifest');
|
||||
}
|
||||
if (
|
||||
(options.overrideUntil && !options.overrideReason) ||
|
||||
(!options.overrideUntil && options.overrideReason)
|
||||
) {
|
||||
throw new Error('--override-until and --override-reason must be used together');
|
||||
}
|
||||
const approvalTtlMs =
|
||||
options.approvalTtlMs && /^\d+$/.test(options.approvalTtlMs)
|
||||
? Number(options.approvalTtlMs)
|
||||
: undefined;
|
||||
if (options.approvalTtlMs && !Number.isSafeInteger(approvalTtlMs)) {
|
||||
throw new Error('--approval-ttl-ms must be an integer');
|
||||
}
|
||||
const result = await api<PhaseTransitionResult>(`/api/agents/${taskId}/phase/transitions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: options.attempt,
|
||||
operationId: options.operation,
|
||||
expectedSequence: state.current?.sequence ?? 0,
|
||||
expectedPhaseEvidenceDigest: priorEvidence.digest,
|
||||
expectedManifestDigest: manifestDigest,
|
||||
reason: options.reason,
|
||||
...(state.current ? {} : { fromEvidence: priorEvidence }),
|
||||
targetEvidence: readPhaseEvidence(options.targetEvidence),
|
||||
approvalId: options.approvalId,
|
||||
approvalTtlMs,
|
||||
...(options.overrideUntil && options.overrideReason
|
||||
? {
|
||||
emergencyOverride: {
|
||||
expiresAt: options.overrideUntil,
|
||||
justification: options.overrideReason,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.status === 'approval-required' && result.approval) {
|
||||
console.log(chalk.yellow('Phase expansion requires approval'));
|
||||
console.log(` Approval: ${result.approval.id}`);
|
||||
console.log(` Revision: ${result.approval.revision}`);
|
||||
console.log(` Action hash: ${result.approval.actionHash}`);
|
||||
console.log(
|
||||
chalk.dim('Approve it, then retry this command with the same --operation value.')
|
||||
);
|
||||
} else if (result.record) {
|
||||
console.log(chalk.green(`✓ Phase transitioned to ${phaseIdentityLabel(result.record)}`));
|
||||
console.log(chalk.dim(`Sequence: ${result.record.sequence}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:decide-phase-approval <approvalId>')
|
||||
.description('Approve or reject an exact pending phase transition')
|
||||
.requiredOption('--decision <decision>', 'approve or reject')
|
||||
.option('--note <text>', 'Decision note')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
approvalId: string,
|
||||
options: { decision: string; note?: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!['approve', 'reject'].includes(options.decision)) {
|
||||
throw new Error('--decision must be approve or reject');
|
||||
}
|
||||
const approval = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approvalId)}`
|
||||
);
|
||||
const decided = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: options.decision === 'approve' ? 'approved' : 'rejected',
|
||||
expectedRevision: approval.revision,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: options.note,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decided, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Phase transition approval ${decided.status}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:cancel-recovery <id>')
|
||||
.description('Cancel the exact pending retry or fallback for a task')
|
||||
.requiredOption('--attempt <attemptId>', 'Parent attempt that owns the pending recovery')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options: { attempt: string; json?: boolean }) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{ cancelled: boolean; recovery: RunRecoveryRecord }>(
|
||||
`/api/agents/${taskId}/recovery/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: options.attempt }),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Automatic recovery cancelled'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'resume',
|
||||
'Resume a terminal provider conversation without replaying prior prompts'
|
||||
);
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'follow-up',
|
||||
'Start a provider-native follow-up turn from a terminal attempt'
|
||||
);
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'fork',
|
||||
'Fork provider-native history from a terminal attempt'
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:steer <id>')
|
||||
.description('Steer the exact active provider turn')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.requiredOption('-m, --message <text>', 'Steering message')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: ConversationControlOptions & { message: string }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<ConversationLifecycleResult>(
|
||||
`/api/agents/${taskId}/conversation/steer`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: options.attempt,
|
||||
message: options.message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
printConversationResult('steered', result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'interrupt',
|
||||
'Interrupt the exact active provider turn'
|
||||
);
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'compact',
|
||||
'Compact the active provider conversation'
|
||||
);
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'archive',
|
||||
'Archive the active provider conversation'
|
||||
);
|
||||
registerConversationControlCommand(program, 'close', 'Close the active provider conversation');
|
||||
|
||||
// Get pending agent requests (for Veritas to process)
|
||||
program
|
||||
.command('agents:pending')
|
||||
|
|
@ -142,10 +952,17 @@ export function registerAgentCommands(program: Command): void {
|
|||
.option('-f, --failed', 'Mark as failed')
|
||||
.option('-m, --summary <text>', 'Summary of what was done')
|
||||
.option('-e, --error <text>', 'Error message (if failed)')
|
||||
.requiredOption('--attempt-id <id>', 'Attempt ID that produced this completion')
|
||||
.requiredOption(
|
||||
'--manifest-digest <digest>',
|
||||
'Provider runtime manifest digest bound to the attempt'
|
||||
)
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const success = !options.failed;
|
||||
const body = {
|
||||
attemptId: options.attemptId,
|
||||
providerRuntimeManifestDigest: options.manifestDigest,
|
||||
success,
|
||||
summary: options.summary,
|
||||
error: options.error,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { API_BASE, buildApiHeaders } from '../utils/api.js';
|
||||
import type {
|
||||
CommunicationAdapterHealth,
|
||||
HarnessCompatibilityMatrix,
|
||||
HarnessSupportStatus,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CRITICAL_STATUSES = new Set<DoctorCheck['id']>([
|
||||
|
|
@ -13,7 +18,9 @@ const CRITICAL_STATUSES = new Set<DoctorCheck['id']>([
|
|||
'api-auth',
|
||||
'tasks',
|
||||
'agents',
|
||||
'harness-support',
|
||||
'routing',
|
||||
'buzz',
|
||||
]);
|
||||
|
||||
export type DoctorStatus = 'pass' | 'warn' | 'fail' | 'skip';
|
||||
|
|
@ -538,6 +545,155 @@ function buildCodexCheck(health: CodexHealthResponse | null): DoctorCheck {
|
|||
);
|
||||
}
|
||||
|
||||
function buildBuzzCheck(
|
||||
health: CommunicationAdapterHealth | null,
|
||||
responseStatus: number
|
||||
): DoctorCheck {
|
||||
if (!health) {
|
||||
if (responseStatus === 404) {
|
||||
return check('buzz', 'Buzz compatibility', 'skip', 'Buzz is not configured');
|
||||
}
|
||||
return check(
|
||||
'buzz',
|
||||
'Buzz compatibility',
|
||||
'warn',
|
||||
'Buzz compatibility diagnostics are unavailable',
|
||||
{ status: responseStatus },
|
||||
'Verify settings-read permission and the communication adapter API.'
|
||||
);
|
||||
}
|
||||
|
||||
const details = {
|
||||
status: health.status,
|
||||
reasonCode: health.reasonCode,
|
||||
configured: health.configured,
|
||||
canSend: health.canSend,
|
||||
canReceiveReplies: health.canReceiveReplies,
|
||||
checkedAt: health.checkedAt,
|
||||
expectedCommunity: health.buzz?.expectedCommunity,
|
||||
observedCommunity: health.buzz?.observedCommunity,
|
||||
publicKeyFingerprint: health.buzz?.publicKeyFingerprint,
|
||||
testedRelease: health.buzz?.testedRelease,
|
||||
testedCommit: health.buzz?.testedCommit,
|
||||
probeRevision: health.buzz?.probeRevision,
|
||||
commands: health.buzz?.commands,
|
||||
buzz: health.buzz,
|
||||
};
|
||||
|
||||
if (health.status === 'disabled') {
|
||||
return check('buzz', 'Buzz compatibility', 'skip', health.detail, details, health.remediation);
|
||||
}
|
||||
if (health.status === 'healthy') {
|
||||
return check('buzz', 'Buzz compatibility', 'pass', health.detail, details);
|
||||
}
|
||||
if (health.status === 'degraded' || health.status === 'warning') {
|
||||
return check('buzz', 'Buzz compatibility', 'warn', health.detail, details, health.remediation);
|
||||
}
|
||||
return check('buzz', 'Buzz compatibility', 'fail', health.detail, details, health.remediation);
|
||||
}
|
||||
|
||||
function buildHarnessSupportCheck(statuses: HarnessSupportStatus[] | null): DoctorCheck {
|
||||
if (!statuses) {
|
||||
return check(
|
||||
'harness-support',
|
||||
'Harness support',
|
||||
'skip',
|
||||
'Skipped because harness support evidence was unavailable'
|
||||
);
|
||||
}
|
||||
|
||||
const counts = Object.fromEntries(
|
||||
['detected', 'configured', 'certified', 'degraded', 'unsupported'].map((tier) => [
|
||||
tier,
|
||||
statuses.filter((status) => status.supportTier === tier).length,
|
||||
])
|
||||
) as Record<HarnessSupportStatus['supportTier'], number>;
|
||||
const enabled = statuses.filter((status) => status.enabled);
|
||||
const blocking = enabled.filter(
|
||||
(status) => status.supportTier === 'degraded' || status.supportTier === 'unsupported'
|
||||
);
|
||||
|
||||
if (blocking.length > 0) {
|
||||
return check(
|
||||
'harness-support',
|
||||
'Harness support',
|
||||
'fail',
|
||||
`${blocking.length} enabled harness profile(s) cannot dispatch safely`,
|
||||
{
|
||||
...counts,
|
||||
blocking: blocking.map((status) => ({
|
||||
profileId: status.profileId,
|
||||
adapterId: status.adapterId,
|
||||
tier: status.supportTier,
|
||||
failureClass: status.failureClass,
|
||||
reason: status.reason,
|
||||
diagnosticCommands: status.diagnosticCommands,
|
||||
remediation: status.remediation,
|
||||
})),
|
||||
},
|
||||
'Disable unsupported profiles or follow the profile remediation before dispatch.'
|
||||
);
|
||||
}
|
||||
|
||||
const uncertified = enabled.filter((status) => status.supportTier !== 'certified');
|
||||
if (uncertified.length > 0) {
|
||||
return check(
|
||||
'harness-support',
|
||||
'Harness support',
|
||||
'warn',
|
||||
`${uncertified.length} enabled harness profile(s) are configured but not certified`,
|
||||
counts,
|
||||
'Run the pinned harness conformance fixtures before treating these runtimes as certified.'
|
||||
);
|
||||
}
|
||||
|
||||
return check(
|
||||
'harness-support',
|
||||
'Harness support',
|
||||
'pass',
|
||||
`${enabled.length} enabled harness profile(s) have current certification evidence`,
|
||||
counts
|
||||
);
|
||||
}
|
||||
|
||||
function buildHarnessCompatibilityCheck(matrix: HarnessCompatibilityMatrix | null): DoctorCheck {
|
||||
if (!matrix) {
|
||||
return check(
|
||||
'harness-compatibility',
|
||||
'Harness compatibility',
|
||||
'skip',
|
||||
'Skipped because compatibility evidence was unavailable'
|
||||
);
|
||||
}
|
||||
const stale = matrix.records.filter(
|
||||
(record) => record.certification.status === 'failed' || record.certification.status === 'stale'
|
||||
);
|
||||
return check(
|
||||
'harness-compatibility',
|
||||
'Harness compatibility',
|
||||
stale.length > 0 ? 'warn' : 'pass',
|
||||
stale.length > 0
|
||||
? `${stale.length} reviewed harness certification record(s) are stale or failed`
|
||||
: `${matrix.records.length} reviewed harness profiles share matrix ${matrix.digest.slice(0, 12)}`,
|
||||
{
|
||||
schemaVersion: matrix.schemaVersion,
|
||||
digest: matrix.digest,
|
||||
probeRevision: matrix.probeRevision,
|
||||
records: matrix.records.map((record) => ({
|
||||
profileId: record.profileId,
|
||||
testedVersions: record.testedVersions,
|
||||
testedBuilds: record.testedBuilds,
|
||||
sourceAvailability: record.sourceAvailability,
|
||||
certification: record.certification.status,
|
||||
supportTier: record.supportStatus?.supportTier,
|
||||
})),
|
||||
},
|
||||
stale.length > 0
|
||||
? 'Re-run the pinned deterministic fixtures before treating the affected builds as certified.'
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function runDoctorChecks(
|
||||
input: Partial<DoctorOptions> = {},
|
||||
depsInput: Partial<DoctorDependencies> = {}
|
||||
|
|
@ -630,6 +786,8 @@ export async function runDoctorChecks(
|
|||
);
|
||||
|
||||
let agents: AgentConfigResponse[] | null = null;
|
||||
let harnessSupport: HarnessSupportStatus[] | null = null;
|
||||
let harnessCompatibility: HarnessCompatibilityMatrix | null = null;
|
||||
let routing: RoutingConfigResponse | null = null;
|
||||
let settings: FeatureSettingsResponse | null = null;
|
||||
|
||||
|
|
@ -689,6 +847,16 @@ export async function runDoctorChecks(
|
|||
);
|
||||
agents = agentsResponse.ok ? agentsResponse.data : null;
|
||||
|
||||
const harnessCompatibilityResponse = await requestJson<HarnessCompatibilityMatrix>(
|
||||
deps,
|
||||
options,
|
||||
'/api/config/harness-compatibility'
|
||||
);
|
||||
harnessCompatibility = harnessCompatibilityResponse.ok
|
||||
? harnessCompatibilityResponse.data
|
||||
: null;
|
||||
harnessSupport = harnessCompatibility?.supportStatuses ?? null;
|
||||
|
||||
const routingResponse = await requestJson<RoutingConfigResponse>(
|
||||
deps,
|
||||
options,
|
||||
|
|
@ -735,6 +903,13 @@ export async function runDoctorChecks(
|
|||
'/api/settings/codex/health'
|
||||
);
|
||||
checks.push(buildCodexCheck(codexHealth.ok ? codexHealth.data : null));
|
||||
|
||||
const buzzHealth = await requestJson<CommunicationAdapterHealth>(
|
||||
deps,
|
||||
options,
|
||||
'/api/integrations/communication/adapters/buzz-default/health'
|
||||
);
|
||||
checks.push(buildBuzzCheck(buzzHealth.ok ? buzzHealth.data : null, buzzHealth.status));
|
||||
} else {
|
||||
checks.push(
|
||||
check('api-auth', 'API authentication', 'skip', 'Skipped because API is unreachable')
|
||||
|
|
@ -748,10 +923,26 @@ export async function runDoctorChecks(
|
|||
checks.push(
|
||||
check('codex-health', 'Codex health', 'skip', 'Skipped because API is unreachable')
|
||||
);
|
||||
checks.push(
|
||||
check('harness-support', 'Harness support', 'skip', 'Skipped because API is unreachable')
|
||||
);
|
||||
checks.push(
|
||||
check(
|
||||
'harness-compatibility',
|
||||
'Harness compatibility',
|
||||
'skip',
|
||||
'Skipped because API is unreachable'
|
||||
)
|
||||
);
|
||||
checks.push(check('buzz', 'Buzz compatibility', 'skip', 'Skipped because API is unreachable'));
|
||||
}
|
||||
|
||||
const agentResult = await buildAgentCheck(deps, agents);
|
||||
checks.push(agentResult.check);
|
||||
if (apiReachable) {
|
||||
checks.push(buildHarnessSupportCheck(harnessSupport));
|
||||
checks.push(buildHarnessCompatibilityCheck(harnessCompatibility));
|
||||
}
|
||||
checks.push(
|
||||
buildRoutingCheck(routing, agentResult.availableAgents, agentResult.configuredAgents)
|
||||
);
|
||||
|
|
|
|||
312
cli/src/commands/goals.ts
Normal file
312
cli/src/commands/goals.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
DurableGoalBlocker,
|
||||
DurableGoalCompletionEvidence,
|
||||
DurableGoalCompletionRequirement,
|
||||
DurableGoalContinuationMode,
|
||||
DurableGoalRecord,
|
||||
DurableGoalState,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { DURABLE_GOAL_STATES } from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface GoalListResponse {
|
||||
generatedAt: string;
|
||||
goals: DurableGoalRecord[];
|
||||
}
|
||||
|
||||
interface GoalRolloverResponse {
|
||||
action: string;
|
||||
goal?: DurableGoalRecord;
|
||||
continuation?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
state: string;
|
||||
resultAttemptId?: string;
|
||||
queueId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type GoalBlockerInput = Omit<DurableGoalBlocker, 'id' | 'recordedAt'> & { id?: string };
|
||||
|
||||
const VERIFICATION_KINDS = new Set(['test', 'build', 'artifact', 'operator', 'external', 'other']);
|
||||
|
||||
export function registerGoalCommands(program: Command): void {
|
||||
const goals = program
|
||||
.command('goals')
|
||||
.description('Create, inspect, and control durable objectives');
|
||||
|
||||
goals
|
||||
.command('list')
|
||||
.description('List durable goals in the current workspace')
|
||||
.option('--state <states...>', 'Filter by goal state')
|
||||
.option('--root-task <id>', 'Filter by root task')
|
||||
.option('--root-workflow <id>', 'Filter by root workflow')
|
||||
.option('--limit <count>', 'Maximum goals', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const state of (options.state ?? []) as DurableGoalState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
if (options.rootTask) query.set('rootTaskId', options.rootTask);
|
||||
if (options.rootWorkflow) query.set('rootWorkflowId', options.rootWorkflow);
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<GoalListResponse>(`/api/goals?${query.toString()}`);
|
||||
if (options.json) return printJson(result);
|
||||
if (result.goals.length === 0) {
|
||||
console.log(chalk.dim('No durable goals matched.'));
|
||||
return;
|
||||
}
|
||||
for (const goal of result.goals) printGoal(goal);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('get <id>')
|
||||
.description('Inspect one durable goal')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const goal = await api<DurableGoalRecord>(`/api/goals/${encodeURIComponent(id)}`);
|
||||
if (options.json) return printJson(goal);
|
||||
printGoal(goal, true);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('create')
|
||||
.description('Create an evidence-gated durable goal')
|
||||
.requiredOption('--objective <text>', 'Goal objective')
|
||||
.requiredOption('--acceptance <criteria...>', 'Acceptance criteria')
|
||||
.requiredOption(
|
||||
'--requirement <requirements...>',
|
||||
'Completion requirement as id|kind|description'
|
||||
)
|
||||
.option('--constraint <constraints...>', 'Goal constraints')
|
||||
.option('--root-task <id>', 'Root task identity')
|
||||
.option('--root-workflow <id>', 'Root workflow identity')
|
||||
.option('--task <id>', 'Optional task associated with a root workflow')
|
||||
.option('--mode <mode>', 'Continuation mode: manual or automatic', 'manual')
|
||||
.option('--max-turns <count>', 'Maximum continuation turns')
|
||||
.option('--max-rollovers <count>', 'Maximum conversation rollovers')
|
||||
.option('--compact-after-tokens <count>', 'Compaction threshold')
|
||||
.option('--require-rollover-approval', 'Require approval before rollover')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (Boolean(options.rootTask) === Boolean(options.rootWorkflow)) {
|
||||
throw new Error('Specify exactly one of --root-task or --root-workflow.');
|
||||
}
|
||||
if (!['manual', 'automatic'].includes(options.mode)) {
|
||||
throw new Error('--mode must be manual or automatic.');
|
||||
}
|
||||
const completionRequirements = (options.requirement as string[]).map(
|
||||
parseCompletionRequirement
|
||||
);
|
||||
const root = options.rootTask
|
||||
? { kind: 'task' as const, taskId: options.rootTask }
|
||||
: {
|
||||
kind: 'workflow' as const,
|
||||
workflowId: options.rootWorkflow,
|
||||
...(options.task ? { taskId: options.task } : {}),
|
||||
};
|
||||
const goal = await api<DurableGoalRecord>('/api/goals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objective: options.objective,
|
||||
constraints: options.constraint ?? [],
|
||||
acceptanceCriteria: options.acceptance,
|
||||
root,
|
||||
continuation: {
|
||||
mode: options.mode as DurableGoalContinuationMode,
|
||||
...(options.maxTurns ? { maxTurns: parsePositiveInteger(options.maxTurns) } : {}),
|
||||
...(options.maxRollovers
|
||||
? { maxRollovers: parseNonnegativeInteger(options.maxRollovers) }
|
||||
: {}),
|
||||
...(options.compactAfterTokens
|
||||
? { compactAfterTokens: parsePositiveInteger(options.compactAfterTokens) }
|
||||
: {}),
|
||||
...(options.requireRolloverApproval ? { requireApprovalForRollover: true } : {}),
|
||||
},
|
||||
completionRequirements,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Created durable goal ${goal.id}`));
|
||||
printGoal(goal);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('transition <id>')
|
||||
.description('Apply one compare-and-set goal state transition')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.requiredOption('--state <state>', `New state: ${DURABLE_GOAL_STATES.join(', ')}`)
|
||||
.requiredOption('--reason <text>', 'Operator reason')
|
||||
.option('--blocker-json <json>', 'Actionable blocker JSON for blocked state')
|
||||
.option('--evidence-json <json>', 'Completion evidence JSON array')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
if (!DURABLE_GOAL_STATES.includes(options.state as DurableGoalState)) {
|
||||
throw new Error(`Unknown goal state: ${options.state}`);
|
||||
}
|
||||
const blocker = options.blockerJson
|
||||
? parseJson<GoalBlockerInput>(options.blockerJson, '--blocker-json')
|
||||
: undefined;
|
||||
const completionEvidence = options.evidenceJson
|
||||
? parseJson<
|
||||
Array<Pick<DurableGoalCompletionEvidence, 'requirementId' | 'evidenceId' | 'summary'>>
|
||||
>(options.evidenceJson, '--evidence-json')
|
||||
: undefined;
|
||||
const goal = await api<DurableGoalRecord>(
|
||||
`/api/goals/${encodeURIComponent(id)}/transition`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
state: options.state,
|
||||
reason: options.reason,
|
||||
blocker,
|
||||
completionEvidence,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Goal ${goal.id} is ${goal.state} at revision ${goal.revision}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('link-run <id>')
|
||||
.description('Link one run or continuation to a durable goal')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.requiredOption('--task <id>', 'Run task identity')
|
||||
.option('--attempt <id>', 'Attempt identity')
|
||||
.option('--workflow-run <id>', 'Workflow run identity')
|
||||
.option('--conversation <id>', 'Conversation identity')
|
||||
.option('--parent-attempt <id>', 'Causal parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const goal = await api<DurableGoalRecord>(`/api/goals/${encodeURIComponent(id)}/runs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
taskId: options.task,
|
||||
attemptId: options.attempt,
|
||||
workflowRunId: options.workflowRun,
|
||||
conversationId: options.conversation,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Linked run to goal ${goal.id} at revision ${goal.revision}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('rollover <id>')
|
||||
.description('Approve and dispatch one bounded fresh-conversation rollover')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<GoalRolloverResponse>(
|
||||
`/api/goals/${encodeURIComponent(id)}/rollover`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(result);
|
||||
const attempt = result.continuation?.resultAttemptId;
|
||||
console.log(
|
||||
chalk.green(`✓ Goal ${id} rollover ${result.action}${attempt ? ` as ${attempt}` : ''}`)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseCompletionRequirement(value: string): DurableGoalCompletionRequirement {
|
||||
const [id, verificationKind, ...descriptionParts] = value.split('|');
|
||||
const description = descriptionParts.join('|').trim();
|
||||
if (!id?.trim() || !verificationKind?.trim() || !description) {
|
||||
throw new Error(`Invalid requirement "${value}"; expected id|kind|description.`);
|
||||
}
|
||||
if (!VERIFICATION_KINDS.has(verificationKind)) {
|
||||
throw new Error(`Invalid verification kind "${verificationKind}".`);
|
||||
}
|
||||
return {
|
||||
id: id.trim(),
|
||||
verificationKind: verificationKind as DurableGoalCompletionRequirement['verificationKind'],
|
||||
description,
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`Expected a positive integer.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonnegativeInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Expected a nonnegative integer.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string, option: string): T {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
throw new Error(`${option} must contain valid JSON.`);
|
||||
}
|
||||
}
|
||||
|
||||
function printGoal(goal: DurableGoalRecord, verbose = false): void {
|
||||
const state =
|
||||
goal.state === 'active'
|
||||
? chalk.green(goal.state)
|
||||
: ['complete'].includes(goal.state)
|
||||
? chalk.blue(goal.state)
|
||||
: ['cancelled', 'failed'].includes(goal.state)
|
||||
? chalk.red(goal.state)
|
||||
: chalk.yellow(goal.state);
|
||||
console.log(`${state} ${chalk.bold(goal.id)} revision=${goal.revision}`);
|
||||
console.log(` ${goal.objective}`);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` root=${goal.root.kind === 'task' ? goal.root.taskId : goal.root.workflowId} runs=${goal.continuationChain.length} blockers=${goal.blockers.length} evidence=${goal.completionEvidence.length}/${goal.completionRequirements.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
176
cli/src/commands/queue-monitors.ts
Normal file
176
cli/src/commands/queue-monitors.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
QueueMonitorExplainResult,
|
||||
QueueMonitorHealthResult,
|
||||
QueueMonitorListResponse,
|
||||
QueueMonitorRunResult,
|
||||
QueueMonitorSnapshot,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export function registerQueueMonitorCommands(program: Command): void {
|
||||
const monitors = program
|
||||
.command('queue-monitors')
|
||||
.alias('queue-monitor')
|
||||
.alias('queues')
|
||||
.description('Inspect and run policy-gated GitHub queue intake monitors');
|
||||
|
||||
monitors
|
||||
.command('list')
|
||||
.alias('status')
|
||||
.description('List configured queue intake monitors')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorListResponse>('/api/queue-monitors');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printSummary(result);
|
||||
for (const monitor of result.monitors) printMonitor(monitor);
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
monitors
|
||||
.command('run <monitorId>')
|
||||
.description('Run a queue intake monitor once')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'run', options.json));
|
||||
|
||||
monitors
|
||||
.command('pause <monitorId>')
|
||||
.description('Pause a queue intake monitor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'pause', options.json));
|
||||
|
||||
monitors
|
||||
.command('resume <monitorId>')
|
||||
.description('Resume a queue intake monitor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'resume', options.json));
|
||||
|
||||
monitors
|
||||
.command('health <monitorId>')
|
||||
.description('Show queue monitor health')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorHealthResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/health`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printMonitor(result.monitor);
|
||||
if (result.actionItem) {
|
||||
console.log(chalk.yellow(`Action item: ${result.actionItem.summary}`));
|
||||
console.log(chalk.dim(result.actionItem.remediation));
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
monitors
|
||||
.command('explain <monitorId>')
|
||||
.description('Build a fresh candidate packet and explain the selected action')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorExplainResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/explain`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printMonitor(result.monitor);
|
||||
console.log(chalk.bold('\nSelected'));
|
||||
if (result.packet.selected) {
|
||||
console.log(
|
||||
`${result.packet.selected.repo}#${result.packet.selected.number} ${result.packet.selected.title}`
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.dim('No candidate selected.'));
|
||||
}
|
||||
console.log(chalk.bold('\nAction'));
|
||||
console.log(`${result.action.action}: ${result.action.summary}`);
|
||||
for (const check of result.action.gateChecks) {
|
||||
const color =
|
||||
check.status === 'pass'
|
||||
? chalk.green
|
||||
: check.status === 'warn'
|
||||
? chalk.yellow
|
||||
: chalk.red;
|
||||
console.log(` ${color(check.status)} ${check.name}: ${check.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
monitorId: string,
|
||||
action: 'run' | 'pause' | 'resume',
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await api<QueueMonitorRunResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/${action}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`${action}: ${result.event.summary}`));
|
||||
if (result.packet.selected) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Selected ${result.packet.selected.repo}#${result.packet.selected.number} ${result.packet.selected.title}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (result.action.skippedReasons.length > 0) {
|
||||
console.log(chalk.yellow(`Skipped: ${result.action.skippedReasons.length}`));
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(result: QueueMonitorListResponse): void {
|
||||
console.log(chalk.bold('\nQueue Intake Monitors'));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`total=${result.summary.total} enabled=${result.summary.enabled} due=${result.summary.due} failed=${result.summary.failed} blocked=${result.summary.blocked}`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function printMonitor(monitor: QueueMonitorSnapshot): void {
|
||||
const health =
|
||||
monitor.health === 'healthy'
|
||||
? chalk.green(monitor.health)
|
||||
: monitor.health === 'blocked'
|
||||
? chalk.red(monitor.health)
|
||||
: chalk.yellow(monitor.health);
|
||||
console.log(`${chalk.bold(monitor.id)} ${health}`);
|
||||
console.log(` ${monitor.name}`);
|
||||
console.log(
|
||||
` repo=${monitor.source.repo} mode=${monitor.mode} next=${monitor.nextRunAt ?? 'not set'}`
|
||||
);
|
||||
if (monitor.lastSummary) console.log(chalk.dim(` last=${monitor.lastSummary}`));
|
||||
}
|
||||
|
||||
function printError(err: unknown): never {
|
||||
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
156
cli/src/commands/scheduler.ts
Normal file
156
cli/src/commands/scheduler.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
SchedulerDueRunResult,
|
||||
SchedulerItem,
|
||||
SchedulerListResponse,
|
||||
SchedulerRunResult,
|
||||
SchedulerValidationResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export function registerSchedulerCommands(program: Command): void {
|
||||
const scheduler = program
|
||||
.command('scheduler')
|
||||
.alias('schedule')
|
||||
.description('Inspect and control recurring Veritas work');
|
||||
|
||||
scheduler
|
||||
.command('list')
|
||||
.alias('status')
|
||||
.description('List recurring work scheduler items')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<SchedulerListResponse>('/api/scheduler');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printSummary(result);
|
||||
for (const item of result.items) printItem(item);
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('run <itemId>')
|
||||
.description('Run a scheduler item now')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'run', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('pause <itemId>')
|
||||
.description('Pause a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'pause', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('resume <itemId>')
|
||||
.description('Resume a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'resume', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('validate <itemId>')
|
||||
.description('Validate a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
try {
|
||||
const result = await api<SchedulerValidationResult>(
|
||||
`/api/scheduler/items/${encodeURIComponent(itemId)}/validate`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`Valid: ${itemId}`));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.yellow(`Validation issues: ${itemId}`));
|
||||
for (const issue of result.issues) {
|
||||
console.log(` ${issue.severity}: ${issue.path} - ${issue.message}`);
|
||||
}
|
||||
process.exitCode = result.issues.some((issue) => issue.severity === 'error') ? 1 : 0;
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('run-due')
|
||||
.description('Run all scheduler items due now')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<SchedulerDueRunResult>('/api/scheduler/due/run', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Checked ${result.checked}, executed ${result.executed}, skipped ${result.skipped}, failed ${result.failed}`
|
||||
)
|
||||
);
|
||||
if (result.overlapping) console.log(chalk.yellow('Due runner already active.'));
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runItemAction(
|
||||
itemId: string,
|
||||
action: 'run' | 'pause' | 'resume',
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await api<SchedulerRunResult>(
|
||||
`/api/scheduler/items/${encodeURIComponent(itemId)}/${action}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`${action}: ${result.event.summary}`));
|
||||
if (result.event.sourceRunId) console.log(chalk.dim(`Run: ${result.event.sourceRunId}`));
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(result: SchedulerListResponse): void {
|
||||
console.log(chalk.bold('\nRecurring Work Scheduler'));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`total=${result.summary.total} enabled=${result.summary.enabled} due=${result.summary.due} failed=${result.summary.failed} blocked=${result.summary.blocked}`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function printItem(item: SchedulerItem): void {
|
||||
const status = item.health === 'healthy' ? chalk.green(item.health) : chalk.yellow(item.health);
|
||||
console.log(`${chalk.bold(item.id)} ${status}`);
|
||||
console.log(` ${item.name}`);
|
||||
console.log(` schedule=${item.trigger.description} next=${item.nextRunAt ?? 'not set'}`);
|
||||
if (item.lastSummary) console.log(chalk.dim(` last=${item.lastSummary}`));
|
||||
}
|
||||
|
||||
function printError(err: unknown): never {
|
||||
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -55,11 +55,10 @@ export function registerSetupCommands(program: Command): void {
|
|||
|
||||
// Step 2: Check if server is running
|
||||
let serverRunning = false;
|
||||
let serverVersion = '';
|
||||
try {
|
||||
const health = await api<HealthResponse>('/api/health');
|
||||
serverRunning = health.ok;
|
||||
serverVersion = health.version;
|
||||
const serverVersion = health.version;
|
||||
results.push({
|
||||
step: 'server',
|
||||
status: 'pass',
|
||||
|
|
|
|||
178
cli/src/commands/sqlite.ts
Normal file
178
cli/src/commands/sqlite.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
SqliteJournalOperationStatus,
|
||||
SqliteJournalPolicySummary,
|
||||
SqliteJournalPreview,
|
||||
SqliteJournalTarget,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface StatusResponse {
|
||||
operation?: SqliteJournalOperationStatus;
|
||||
policy?: SqliteJournalPolicySummary;
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function printOperation(operation: SqliteJournalOperationStatus): void {
|
||||
const state = operation.recoveryRequired
|
||||
? chalk.red(operation.state)
|
||||
: operation.state === 'completed'
|
||||
? chalk.green(operation.state)
|
||||
: chalk.yellow(operation.state);
|
||||
console.log(`${chalk.bold(operation.id)} ${state}`);
|
||||
console.log(` ${operation.originalMode} -> ${operation.targetMode}`);
|
||||
console.log(` backup: ${operation.backupAvailable ? 'verified' : 'not created'}`);
|
||||
console.log(` restart required: ${operation.restartRequired ? 'yes' : 'no'}`);
|
||||
if (operation.errorCode) console.log(chalk.red(` error: ${operation.errorCode}`));
|
||||
}
|
||||
|
||||
export function registerSqliteCommands(program: Command): void {
|
||||
const sqlite = program
|
||||
.command('sqlite')
|
||||
.description('SQLite storage diagnostics and maintenance');
|
||||
const journal = sqlite
|
||||
.command('journal')
|
||||
.description('Preview and schedule journal-mode changes');
|
||||
|
||||
journal
|
||||
.command('preview')
|
||||
.requiredOption('--target <mode>', 'Target journal mode: wal or delete')
|
||||
.option('--single-host', 'Acknowledge single-host compatibility mode')
|
||||
.option(
|
||||
'--override-reason <reason>',
|
||||
'Required justification for compatibility or override mode'
|
||||
)
|
||||
.option('--expires-at <timestamp>', 'ISO timestamp when the override expires')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!['wal', 'delete'].includes(options.target)) {
|
||||
throw new Error('--target must be wal or delete');
|
||||
}
|
||||
const preview = await api<SqliteJournalPreview>('/api/maintenance/sqlite/journal/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetMode: options.target as SqliteJournalTarget,
|
||||
singleHost: options.singleHost || undefined,
|
||||
overrideReason: options.overrideReason,
|
||||
expiresAt: options.expiresAt,
|
||||
}),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold(`SQLite journal preview ${preview.id}`));
|
||||
console.log(` mode: ${preview.currentMode} -> ${preview.targetMode}`);
|
||||
console.log(` filesystem: ${preview.filesystemType} (${preview.filesystemPosture})`);
|
||||
console.log(
|
||||
` ownership: ${preview.ownershipState}; active connections: ${preview.activeConnectionCount}`
|
||||
);
|
||||
console.log(` backup: ${preview.backupLocation}`);
|
||||
for (const sidecar of preview.sidecars) {
|
||||
console.log(
|
||||
` ${sidecar.kind}: ${sidecar.present ? `${sidecar.bytes} bytes (${sidecar.fileType})` : 'absent'}`
|
||||
);
|
||||
}
|
||||
console.log(chalk.yellow('Risks:'));
|
||||
for (const risk of preview.risks) console.log(` - ${risk}`);
|
||||
console.log(chalk.bold('\nSchedule after review:'));
|
||||
console.log(
|
||||
` vk sqlite journal apply --preview-id ${preview.id} --preview-token ${preview.token} --confirm ${preview.id} --acknowledge-risks`
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('apply')
|
||||
.requiredOption('--preview-id <id>', 'Preview operation ID')
|
||||
.requiredOption('--preview-token <token>', 'One-time preview token')
|
||||
.requiredOption('--confirm <id>', 'Repeat the preview ID to confirm')
|
||||
.option('--acknowledge-risks', 'Acknowledge the previewed risks')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!options.acknowledgeRisks || options.confirm !== options.previewId) {
|
||||
throw new Error('Apply requires --acknowledge-risks and --confirm matching --preview-id');
|
||||
}
|
||||
const operation = await api<SqliteJournalOperationStatus>(
|
||||
'/api/maintenance/sqlite/journal/apply',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
previewId: options.previewId,
|
||||
previewToken: options.previewToken,
|
||||
confirm: options.confirm,
|
||||
acknowledgeRisks: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(operation, null, 2));
|
||||
else {
|
||||
printOperation(operation);
|
||||
console.log(chalk.yellow('Restart the server to execute the scheduled conversion.'));
|
||||
}
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('status [operationId]')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (operationId, options) => {
|
||||
try {
|
||||
if (operationId) {
|
||||
const operation = await api<SqliteJournalOperationStatus>(
|
||||
`/api/maintenance/sqlite/journal/operations/${encodeURIComponent(operationId)}`
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(operation, null, 2));
|
||||
else printOperation(operation);
|
||||
return;
|
||||
}
|
||||
const status = await api<StatusResponse>('/api/maintenance/sqlite/journal/status');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
if (status.operation) printOperation(status.operation);
|
||||
else console.log(chalk.dim('No SQLite journal operation is scheduled.'));
|
||||
if (status.policy) {
|
||||
console.log(
|
||||
`Policy: ${status.policy.source} ${status.policy.status}; expires ${status.policy.expiresAt}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('override')
|
||||
.description('Manage SQLite journal overrides')
|
||||
.command('revoke')
|
||||
.requiredOption('--reason <reason>', 'Revocation reason')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (String(options.reason).trim().length < 8) {
|
||||
throw new Error('--reason must be at least 8 characters');
|
||||
}
|
||||
const policy = await api<SqliteJournalPolicySummary>(
|
||||
'/api/maintenance/sqlite/journal/override/revoke',
|
||||
{ method: 'POST', body: JSON.stringify({ reason: options.reason }) }
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(policy, null, 2));
|
||||
else console.log(chalk.yellow(`Revoked ${policy.id}; restart is required.`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-d, --description <desc>', 'Task description')
|
||||
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
|
||||
.option('-s, --status <status>', 'Initial status')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (title, options) => {
|
||||
try {
|
||||
|
|
@ -107,6 +108,9 @@ export function registerTaskCommands(program: Command): void {
|
|||
description: options.description || '',
|
||||
priority: options.priority,
|
||||
status: options.status,
|
||||
executionPolicy: options.commitPolicy
|
||||
? { commitPolicy: options.commitPolicy }
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -132,6 +136,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-S, --sprint <sprint>', 'Sprint name or ID')
|
||||
.option('--priority <priority>', 'New priority')
|
||||
.option('--title <title>', 'New title')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -149,6 +154,9 @@ export function registerTaskCommands(program: Command): void {
|
|||
if (options.sprint) updates.sprint = options.sprint;
|
||||
if (options.priority) updates.priority = options.priority;
|
||||
if (options.title) updates.title = options.title;
|
||||
if (options.commitPolicy) {
|
||||
updates.executionPolicy = { commitPolicy: options.commitPolicy };
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.error(chalk.yellow('No updates specified'));
|
||||
|
|
|
|||
230
cli/src/commands/tool-servers.ts
Normal file
230
cli/src/commands/tool-servers.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
RunToolCatalog,
|
||||
ToolInvocationResult,
|
||||
ToolServerDefinition,
|
||||
ToolServerDefinitionInput,
|
||||
ToolServerDiscovery,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export function registerToolServerCommands(program: Command): void {
|
||||
const servers = program
|
||||
.command('tool-servers')
|
||||
.alias('tools')
|
||||
.description('Manage run-scoped MCP and tool servers');
|
||||
|
||||
servers
|
||||
.command('list')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
await execute(async () => {
|
||||
const definitions = await api<ToolServerDefinition[]>('/api/tool-servers');
|
||||
if (options.json) return printJson(definitions);
|
||||
for (const definition of definitions) {
|
||||
console.log(
|
||||
`${chalk.bold(definition.id)} ${definition.enabled ? chalk.green('enabled') : chalk.yellow('disabled')} ${definition.version} ${definition.transport.kind}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('get <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, _options) => {
|
||||
await execute(async () => {
|
||||
const definition = await api<ToolServerDefinition>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`
|
||||
);
|
||||
printJson(definition);
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('create <file>')
|
||||
.description('Create a definition from a JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
await execute(async () => {
|
||||
const input = JSON.parse(readFileSync(file, 'utf8')) as ToolServerDefinitionInput;
|
||||
const definition = await api<ToolServerDefinition>('/api/tool-servers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (options.json) return printJson(definition);
|
||||
console.log(chalk.green(`Created ${definition.id}@${definition.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('update <id> <file>')
|
||||
.description('Replace a definition from a JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, file, options) => {
|
||||
await execute(async () => {
|
||||
const input = JSON.parse(readFileSync(file, 'utf8')) as ToolServerDefinitionInput;
|
||||
const definition = await api<ToolServerDefinition>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(definition);
|
||||
console.log(chalk.green(`Updated ${definition.id}@${definition.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('delete <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const result = await api<{ deleted: string }>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (options.json) return printJson(result);
|
||||
console.log(chalk.green(`Deleted ${result.deleted}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('enable <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const updated = await setDefinitionEnabled(id, true);
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Enabled ${updated.id}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('disable <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const updated = await setDefinitionEnabled(id, false);
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Disabled ${updated.id}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('version <id> <version>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, version, options) => {
|
||||
await execute(async () => {
|
||||
const current = await getDefinition(id);
|
||||
const updated = await replaceDefinition(id, { ...current, version });
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Versioned ${updated.id}@${updated.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('discover <id>')
|
||||
.option('--force', 'Ignore a matching discovery cache entry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const discovery = await api<ToolServerDiscovery>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}/discover`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ force: options.force === true }),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(discovery);
|
||||
console.log(
|
||||
`${discovery.status === 'ready' ? chalk.green('ready') : chalk.red('failed')} ${discovery.serverId}@${discovery.serverVersion} tools=${discovery.tools.length}`
|
||||
);
|
||||
if (discovery.error) console.log(chalk.red(discovery.error));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('catalog <taskId> <attemptId>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, attemptId) => {
|
||||
await execute(async () => {
|
||||
printJson(
|
||||
await api<RunToolCatalog>(
|
||||
`/api/tool-servers/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(attemptId)}/catalog`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('call <taskId> <attemptId> <serverId> <tool>')
|
||||
.requiredOption('--arguments <json>', 'JSON object of tool arguments')
|
||||
.option('--operation-id <id>', 'Stable caller operation ID', randomUUID())
|
||||
.option('--approval-id <id>', 'Approved run approval ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, attemptId, serverId, tool, options) => {
|
||||
await execute(async () => {
|
||||
const result = await api<ToolInvocationResult>('/api/tool-servers/call', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
taskId,
|
||||
attemptId,
|
||||
serverId,
|
||||
tool,
|
||||
arguments: JSON.parse(options.arguments),
|
||||
operationId: options.operationId,
|
||||
approvalId: options.approvalId,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(result);
|
||||
console.log(
|
||||
`${result.isError ? chalk.red('error') : chalk.green('complete')} ${result.serverId}/${result.tool} event=${result.eventId}`
|
||||
);
|
||||
printJson(result.content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
async function getDefinition(id: string): Promise<ToolServerDefinition> {
|
||||
return api<ToolServerDefinition>(`/api/tool-servers/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async function replaceDefinition(
|
||||
id: string,
|
||||
definition: ToolServerDefinition
|
||||
): Promise<ToolServerDefinition> {
|
||||
const {
|
||||
schemaVersion: _schemaVersion,
|
||||
digest: _digest,
|
||||
createdAt: _createdAt,
|
||||
updatedAt: _updatedAt,
|
||||
...input
|
||||
} = definition;
|
||||
return api<ToolServerDefinition>(`/api/tool-servers/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input satisfies ToolServerDefinitionInput),
|
||||
});
|
||||
}
|
||||
|
||||
async function setDefinitionEnabled(id: string, enabled: boolean): Promise<ToolServerDefinition> {
|
||||
const current = await getDefinition(id);
|
||||
return replaceDefinition(id, { ...current, enabled });
|
||||
}
|
||||
201
cli/src/commands/workspaces.ts
Normal file
201
cli/src/commands/workspaces.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
WorkspaceCapabilityDiscoveryResult,
|
||||
WorkspaceCapabilityFormat,
|
||||
WorkspaceCapabilityRegistrationResult,
|
||||
WorkspaceCapabilityValidationResult,
|
||||
WorkspaceDelegatedWorkIntakeResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
function inferFormat(filePath: string): WorkspaceCapabilityFormat {
|
||||
return path.extname(filePath).toLowerCase() === '.json' ? 'json' : 'yaml';
|
||||
}
|
||||
|
||||
function contextField(
|
||||
value: string,
|
||||
previous: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const index = value.indexOf('=');
|
||||
if (index === -1) {
|
||||
throw new Error('Context fields must use key=value format');
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[value.slice(0, index).trim()]: value.slice(index + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWorkspaceCommands(program: Command): void {
|
||||
const workspaces = program
|
||||
.command('workspaces')
|
||||
.alias('workspace')
|
||||
.description('Workspace capability discovery and delegated intake');
|
||||
|
||||
workspaces
|
||||
.command('discover')
|
||||
.description('List local and trusted workspace capability manifests')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<WorkspaceCapabilityDiscoveryResult>(
|
||||
'/api/workspace-capabilities/discover'
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.local) {
|
||||
console.log(chalk.bold(`\nLocal: ${result.local.name}`));
|
||||
console.log(chalk.dim(` ${result.local.workspaceId}`));
|
||||
for (const capability of result.local.capabilities) {
|
||||
console.log(` - ${capability.id}: ${capability.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\nTrusted Workspaces (${result.trusted.length})`));
|
||||
if (result.trusted.length === 0) {
|
||||
console.log(chalk.dim(' No trusted workspace manifests registered.'));
|
||||
}
|
||||
for (const workspace of result.trusted) {
|
||||
console.log(` ${chalk.cyan(workspace.workspaceId)} ${workspace.name}`);
|
||||
for (const capability of workspace.capabilities) {
|
||||
console.log(
|
||||
` - ${capability.id}: ${capability.acceptedTaskTypes.join(', ') || 'any'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('validate <file>')
|
||||
.description('Validate a workspace capability manifest YAML or JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<WorkspaceCapabilityValidationResult>(
|
||||
'/api/workspace-capabilities/manifest/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferFormat(file), source: file }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.valid) {
|
||||
console.log(chalk.green(`Valid workspace manifest: ${result.manifest?.workspaceId}`));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.red('Invalid workspace manifest'));
|
||||
for (const issue of result.issues) {
|
||||
console.log(chalk.dim(` ${issue.path}: ${issue.message}`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('trust <file>')
|
||||
.description('Register a trusted peer workspace manifest')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<WorkspaceCapabilityRegistrationResult>(
|
||||
'/api/workspace-capabilities/trusted',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferFormat(file), source: file }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`${result.created ? 'Registered' : 'Updated'} trusted workspace: ${result.manifest.name}`
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('intake')
|
||||
.description('Create delegated work intake in this workspace')
|
||||
.requiredOption('--source-workspace <id>', 'Source workspace ID')
|
||||
.requiredOption('--capability <id>', 'Target capability ID')
|
||||
.requiredOption('--title <title>', 'Delegated work title')
|
||||
.requiredOption('--context <text>', 'Delegated work context')
|
||||
.option('--source-name <name>', 'Source workspace display name')
|
||||
.option('--source-task <id>', 'Originating task ID')
|
||||
.option('--source-task-url <url>', 'Originating task URL')
|
||||
.option('--repository <repo>', 'Source repository')
|
||||
.option('--issue-url <url>', 'Source issue URL')
|
||||
.option('--type <type>', 'Task type')
|
||||
.option('--project <project>', 'Target project')
|
||||
.option('--priority <priority>', 'Target priority')
|
||||
.option('--label <label...>', 'Delegation labels')
|
||||
.option('--context-field <key=value>', 'Required context field', contextField, {})
|
||||
.option('--requested-by <actor>', 'Requester actor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<WorkspaceDelegatedWorkIntakeResult>(
|
||||
'/api/workspace-capabilities/intake',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source: {
|
||||
workspaceId: options.sourceWorkspace,
|
||||
workspaceName: options.sourceName,
|
||||
taskId: options.sourceTask,
|
||||
taskUrl: options.sourceTaskUrl,
|
||||
repository: options.repository,
|
||||
issueUrl: options.issueUrl,
|
||||
},
|
||||
capabilityId: options.capability,
|
||||
title: options.title,
|
||||
context: options.context,
|
||||
contextFields: options.contextField,
|
||||
labels: options.label,
|
||||
priority: options.priority,
|
||||
project: options.project,
|
||||
type: options.type,
|
||||
requestedBy: options.requestedBy,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`Created delegated task: ${result.taskId}`));
|
||||
console.log(chalk.dim(`Delegation: ${result.record.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -19,6 +19,14 @@ import { registerSprintCommands } from './commands/sprints.js';
|
|||
import { registerDoctorCommand } from './commands/doctor.js';
|
||||
import { registerSnapshotCommand } from './commands/snapshot.js';
|
||||
import { registerPromptCommands } from './commands/prompts.js';
|
||||
import { registerWorkspaceCommands } from './commands/workspaces.js';
|
||||
import { registerSchedulerCommands } from './commands/scheduler.js';
|
||||
import { registerQueueMonitorCommands } from './commands/queue-monitors.js';
|
||||
import { registerSqliteCommands } from './commands/sqlite.js';
|
||||
import { registerToolServerCommands } from './commands/tool-servers.js';
|
||||
import { registerAcpCommands } from './commands/acp.js';
|
||||
import { registerAdmissionCommands } from './commands/admission.js';
|
||||
import { registerGoalCommands } from './commands/goals.js';
|
||||
|
||||
const program = new Command();
|
||||
const packageJson = JSON.parse(
|
||||
|
|
@ -49,5 +57,13 @@ registerSprintCommands(program);
|
|||
registerDoctorCommand(program);
|
||||
registerSnapshotCommand(program);
|
||||
registerPromptCommands(program);
|
||||
registerWorkspaceCommands(program);
|
||||
registerSchedulerCommands(program);
|
||||
registerQueueMonitorCommands(program);
|
||||
registerSqliteCommands(program);
|
||||
registerToolServerCommands(program);
|
||||
registerAcpCommands(program);
|
||||
registerAdmissionCommands(program);
|
||||
registerGoalCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
|
|
|||
|
|
@ -5,5 +5,21 @@ export default defineConfig({
|
|||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.d.ts',
|
||||
'src/__tests__/**',
|
||||
'src/**/__fixtures__/**',
|
||||
'src/**/fixtures/**',
|
||||
'src/**/generated/**',
|
||||
'src/**/*.generated.*',
|
||||
'src/**/types.ts',
|
||||
'src/types/**/*.ts',
|
||||
],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Veritas Kanban Desktop
|
||||
|
||||
This package is the v5 native desktop scaffold. It uses Electron with
|
||||
This package is the v6 native desktop application. It uses Electron with
|
||||
electron-vite, starts the existing Veritas server as the local backend, and
|
||||
loads the existing web UI.
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ can be tested without reusing the default development home.
|
|||
work.
|
||||
- The renderer uses the existing Veritas web app and has no Node, filesystem,
|
||||
process, or secret access.
|
||||
- The preload bridge exposes only typed desktop operations. The current v5
|
||||
- The preload bridge exposes only typed desktop operations. The current v6
|
||||
contract covers app/setup diagnostics, local server lifecycle, connection
|
||||
validation, update status, native command dispatch, upload/import picking,
|
||||
diagnostics bundles, notification actions, work product export, external URL
|
||||
|
|
@ -37,11 +37,20 @@ can be tested without reusing the default development home.
|
|||
desktop bridge contract module so main and preload cannot drift silently.
|
||||
- Dangerous bridge methods require typed request objects and contract validators
|
||||
before native execution. Unsupported native features return explicit
|
||||
placeholder results until their dedicated v5 issues implement the backing
|
||||
placeholder results until their dedicated roadmap issues implement the backing
|
||||
behavior.
|
||||
- Fresh packaged installs store desktop data below the OS app data directory
|
||||
returned by Electron `app.getPath('userData')`, then under
|
||||
`profiles/<profile>/workspaces/<workspace>/`.
|
||||
- Packaged desktop installs detect a populated desktop SQLite database and offer
|
||||
**Use Existing Data** so setup can secure it without replacing board records
|
||||
or imported owner metadata. Do not choose recovery import when the expected
|
||||
records are already present. Packaged installs do not automatically import an
|
||||
external web/source checkout. Use
|
||||
[`docs/WEB-TO-MAC-DESKTOP-MIGRATION.md`](../docs/WEB-TO-MAC-DESKTOP-MIGRATION.md)
|
||||
for the already-populated case or when moving file-backed `tasks/` and
|
||||
`.veritas-kanban/` data from a repo server into the Mac app's SQLite
|
||||
workspace.
|
||||
- Desktop runtime secrets are created through Electron `safeStorage`, which uses
|
||||
the OS credential backend on macOS. The encrypted metadata file lives at
|
||||
`<appHome>/config/desktop-secrets.json`; plaintext admin/JWT secrets are only
|
||||
|
|
@ -51,7 +60,9 @@ can be tested without reusing the default development home.
|
|||
place for manual rollback.
|
||||
- Local development mode disables app auth only for the supervised loopback
|
||||
runtime. Packaged mode keeps auth enabled and uses the keychain-backed
|
||||
bootstrap secrets for admin and JWT signing.
|
||||
bootstrap secrets for admin and JWT signing. Local automation that talks to
|
||||
the packaged app must send `X-API-Key` or `Authorization: Bearer` rather than
|
||||
assuming unauthenticated localhost writes.
|
||||
|
||||
## Recovery Notes
|
||||
|
||||
|
|
@ -86,14 +97,17 @@ metadata, and platform smoke steps are documented in
|
|||
`desktop:package:mac:unsigned` script for Mac release validation. The
|
||||
`desktop:package:linux:unsigned` and `desktop:package:windows:unsigned` scripts
|
||||
produce preview-only, non-GA artifacts for post-GA readiness checks. Use
|
||||
`desktop:release:mac` only for v5 GA; use `desktop:release:linux` or
|
||||
`desktop:release:mac` for stable macOS releases; use `desktop:release:linux` or
|
||||
`desktop:release:windows` only after the platform release prerequisites in the
|
||||
release guide are promoted.
|
||||
|
||||
## Production Scaffold
|
||||
|
||||
`pnpm desktop:build` compiles the Electron main, preload, and fallback renderer.
|
||||
Packaging, signing, notarization, updater metadata, and bundled server/web asset
|
||||
layout are handled by later v5 desktop issues. Packaged mode expects a built
|
||||
server entry at `server/dist/index.js` unless `VERITAS_DESKTOP_SERVER_ENTRY` is
|
||||
provided.
|
||||
The build then verifies that main and preload import Electron from the native
|
||||
runtime and do not contain Electron's npm installer shim. Run the artifact
|
||||
check directly with `pnpm desktop:check:electron-artifacts` when inspecting an
|
||||
existing build. Packaging, signing, notarization, updater metadata, and bundled
|
||||
server/web asset layout follow the release workflow documented above. Packaged
|
||||
mode expects a built server entry at `server/dist/index.js` unless
|
||||
`VERITAS_DESKTOP_SERVER_ENTRY` is provided.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import { resolve } from 'node:path';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
|
||||
const electronRuntimeExternal = ['electron', /^electron\/.+/];
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
define: {
|
||||
__VERITAS_BUILD_SHA__: JSON.stringify(
|
||||
process.env.VERITAS_BUILD_SHA ?? process.env.GITHUB_SHA ?? ''
|
||||
),
|
||||
__VERITAS_RELEASE_CHANNEL__: JSON.stringify(process.env.VERITAS_UPDATE_CHANNEL ?? ''),
|
||||
},
|
||||
build: {
|
||||
// Vite 8 builds with Rolldown. Electron Vite 5 still places its built-in
|
||||
// runtime externals under rollupOptions, which Rolldown does not consume.
|
||||
// Keep Electron explicitly external so the emitted main process receives
|
||||
// Electron's runtime API instead of bundling the npm executable-path shim.
|
||||
rolldownOptions: {
|
||||
external: electronRuntimeExternal,
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
|
|
@ -15,10 +33,24 @@ export default defineConfig({
|
|||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
external: electronRuntimeExternal,
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs',
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,27 +1,28 @@
|
|||
{
|
||||
"name": "@veritas-kanban/desktop",
|
||||
"version": "5.0.0",
|
||||
"version": "6.1.2",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/BradGroux/veritas-kanban",
|
||||
"description": "Veritas Kanban native desktop shell",
|
||||
"author": "Brad Groux <brad@digitalmeld.io>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:fresh": "VERITAS_DESKTOP_PROFILE=fresh electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"build": "electron-vite build && pnpm --dir .. desktop:check:electron-artifacts",
|
||||
"package:prepare": "node ../scripts/prepare-desktop-release.mjs",
|
||||
"package:mac:dir": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --mac dir --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:mac:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:linux:dir": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --linux dir --x64 --publish never",
|
||||
"package:linux:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --linux AppImage deb rpm --x64 --publish never",
|
||||
"package:win:dir": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --win dir --x64 --publish never",
|
||||
"package:win:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --win nsis zip --x64 --publish never",
|
||||
"release:mac": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --mac dmg zip --publish always",
|
||||
"release:linux": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --linux AppImage deb rpm --x64 --publish always",
|
||||
"release:win": "node ../scripts/prepare-desktop-release.mjs && node ./node_modules/electron-builder/cli.js --win nsis zip --x64 --publish always",
|
||||
"package:mac:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dir --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:mac:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:linux:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux dir --x64 --publish never",
|
||||
"package:linux:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish never",
|
||||
"package:win:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win dir --x64 --publish never",
|
||||
"package:win:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish never",
|
||||
"release:mac": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish always",
|
||||
"release:linux": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish always",
|
||||
"release:win": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish always",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src electron.vite.config.ts --ext .ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
|
|
@ -29,23 +30,24 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"electron-updater": "^6.8.3"
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.8.1",
|
||||
"@types/node": "^26.2.0",
|
||||
"electron": "^43.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.1.8"
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"build": {
|
||||
"appId": "io.digitalmeld.veritas-kanban",
|
||||
"productName": "Veritas Kanban",
|
||||
"executableName": "veritas-kanban",
|
||||
"artifactName": "Veritas-Kanban-${version}-${os}-${arch}.${ext}",
|
||||
"asar": true,
|
||||
"afterPack": "../scripts/desktop-after-pack.mjs",
|
||||
"afterPack": "scripts/desktop-after-pack.mjs",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "release"
|
||||
|
|
@ -90,7 +92,7 @@
|
|||
"notarize": true
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false,
|
||||
"sign": true,
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
|
|
@ -168,7 +170,7 @@
|
|||
"provider": "github",
|
||||
"owner": "BradGroux",
|
||||
"repo": "veritas-kanban",
|
||||
"releaseType": "draft"
|
||||
"releaseType": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1
desktop/scripts/desktop-after-pack.mjs
Normal file
1
desktop/scripts/desktop-after-pack.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from '../../scripts/desktop-after-pack.mjs';
|
||||
|
|
@ -93,7 +93,7 @@ function shell(): Shell {
|
|||
}
|
||||
|
||||
function handlers(): DesktopBridgeHandlerMap {
|
||||
return createDesktopBridgeHandlers(runtime(), shell(), false);
|
||||
return createDesktopBridgeHandlers(runtime(), shell(), false, '6.0.1');
|
||||
}
|
||||
|
||||
describe('desktop bridge contracts', () => {
|
||||
|
|
@ -124,7 +124,7 @@ describe('desktop bridge contracts', () => {
|
|||
}),
|
||||
} as unknown as IpcMain;
|
||||
|
||||
registerDesktopBridge(ipcMain, runtime(), shell(), false);
|
||||
registerDesktopBridge(ipcMain, runtime(), shell(), false, '6.0.1');
|
||||
|
||||
expect([...registered.keys()].sort()).toEqual(
|
||||
DESKTOP_BRIDGE_METHOD_NAMES.map((method) => DESKTOP_BRIDGE_METHODS[method].channel).sort()
|
||||
|
|
@ -132,6 +132,19 @@ describe('desktop bridge contracts', () => {
|
|||
expect(registered.size).toBe(DESKTOP_BRIDGE_METHOD_NAMES.length);
|
||||
});
|
||||
|
||||
it('reports the Electron application version through the desktop bridge', () => {
|
||||
const bridgeHandlers = createDesktopBridgeHandlers(runtime(), shell(), true, '6.0.1');
|
||||
|
||||
expect(bridgeHandlers.getAppInfo(undefined)).toMatchObject({
|
||||
name: 'Veritas Kanban',
|
||||
version: '6.0.1',
|
||||
channel: 'stable',
|
||||
arch: process.arch,
|
||||
osVersion: expect.any(String),
|
||||
packaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the preload API method list aligned to invoke and event contracts', () => {
|
||||
expect(DESKTOP_PRELOAD_API_METHODS).toEqual([
|
||||
...DESKTOP_BRIDGE_METHOD_NAMES,
|
||||
|
|
@ -172,7 +185,8 @@ describe('desktop bridge contracts', () => {
|
|||
const bridgeHandlers: DesktopBridgeHandlerMap = createDesktopBridgeHandlers(
|
||||
runtime(),
|
||||
fakeShell,
|
||||
false
|
||||
false,
|
||||
'6.0.1'
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
|
@ -193,7 +207,7 @@ describe('desktop bridge contracts', () => {
|
|||
|
||||
it('validates restart confirmation before restarting the local server', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const bridgeHandlers = createDesktopBridgeHandlers(fakeRuntime, shell(), false);
|
||||
const bridgeHandlers = createDesktopBridgeHandlers(fakeRuntime, shell(), false, '6.0.1');
|
||||
|
||||
expect(() => bridgeHandlers.restartLocalServer({ confirmation: 'restart' } as never)).toThrow(
|
||||
'explicit restart confirmation'
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ describe('desktop command registry', () => {
|
|||
expect(DESKTOP_COMMAND_REGISTRY['new-task'].accelerator).toBe('CommandOrControl+N');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['open-command-center'].accelerator).toBe('CommandOrControl+K');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['open-onboarding'].label).toBe('Setup & Diagnostics');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['reset-layout']).toMatchObject({
|
||||
label: 'Reset Window Layout',
|
||||
nativeAction: 'renderer',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes renderer commands through the menu command event path', async () => {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ function updateStatus(state: DesktopUpdateStatus['state']): DesktopUpdateStatus
|
|||
describe('desktop native menu', () => {
|
||||
it('exposes common actions with keyboard shortcuts', () => {
|
||||
const dispatch = vi.fn();
|
||||
const template = createDesktopMenuTemplate({ status: status(), dispatch });
|
||||
const copyVersionInfo = vi.fn();
|
||||
const template = createDesktopMenuTemplate({ status: status(), dispatch, copyVersionInfo });
|
||||
const labels = template.flatMap((item) =>
|
||||
Array.isArray(item.submenu) ? item.submenu.map((child) => child.label) : []
|
||||
);
|
||||
|
|
@ -54,6 +55,15 @@ describe('desktop native menu', () => {
|
|||
expect(labels).toContain('Search');
|
||||
expect(labels).toContain('Settings');
|
||||
expect(labels).toContain('Restart Local Server');
|
||||
expect(labels).toContain('Reset Window Layout');
|
||||
|
||||
const appMenu = template.find((item) => item.label === 'Veritas Kanban');
|
||||
const appItems = Array.isArray(appMenu?.submenu) ? appMenu.submenu : [];
|
||||
expect(appItems[0]).toMatchObject({ role: 'about', label: 'About Veritas Kanban' });
|
||||
expect(appItems[1]).toMatchObject({ type: 'separator' });
|
||||
const copyVersion = appItems.find((item) => item.label === 'Copy Version Information');
|
||||
copyVersion?.click?.(undefined as never, undefined as never, undefined as never);
|
||||
expect(copyVersionInfo).toHaveBeenCalledOnce();
|
||||
|
||||
const fileMenu = template.find((item) => item.label === 'File');
|
||||
const newTask = Array.isArray(fileMenu?.submenu)
|
||||
|
|
@ -65,10 +75,21 @@ describe('desktop native menu', () => {
|
|||
expect(dispatch).toHaveBeenCalledWith('new-task');
|
||||
});
|
||||
|
||||
it('exposes the native edit menu so macOS text fields receive standard shortcuts', () => {
|
||||
const template = createDesktopMenuTemplate({
|
||||
status: status(),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
});
|
||||
|
||||
expect(template.some((item) => item.role === 'editMenu')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps external delivery test status-aware', () => {
|
||||
const desktopMenu = createDesktopMenuTemplate({
|
||||
status: status('failed'),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
}).find((item) => item.label === 'Desktop');
|
||||
const externalTest = Array.isArray(desktopMenu?.submenu)
|
||||
? desktopMenu.submenu.find((item) => item.label === 'Test External Delivery')
|
||||
|
|
@ -82,6 +103,7 @@ describe('desktop native menu', () => {
|
|||
status: status(),
|
||||
updateStatus: updateStatus('available'),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
}).find((item) => item.label === 'Veritas Kanban');
|
||||
const downloadUpdate = Array.isArray(appMenu?.submenu)
|
||||
? appMenu.submenu.find((item) => item.label === 'Download Update')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import net from 'node:net';
|
||||
|
||||
import { findAvailablePort, isPortAvailable } from '../ports.js';
|
||||
|
|
@ -21,4 +21,102 @@ describe('port selection', () => {
|
|||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps separately selected desktop fallback ports distinct', async () => {
|
||||
const busyServer = net.createServer();
|
||||
await new Promise<void>((resolve) => busyServer.listen(47633, '127.0.0.1', resolve));
|
||||
|
||||
try {
|
||||
const serverPort = await findAvailablePort(47633, '127.0.0.1', 3);
|
||||
const webPort = await findAvailablePort(47633, '127.0.0.1', 3, new Set([serverPort]));
|
||||
|
||||
expect(serverPort).toBeGreaterThan(47633);
|
||||
expect(webPort).toBeGreaterThan(47633);
|
||||
expect(webPort).not.toBe(serverPort);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => busyServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls forward when the preferred port is busy on an IPv6 wildcard', async () => {
|
||||
const server = net.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(47634, '::', resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EAFNOSUPPORT' || code === 'EADDRNOTAVAIL') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
expect(await isPortAvailable(47634)).toBe(false);
|
||||
const port = await findAvailablePort(47634, '127.0.0.1', 3);
|
||||
expect(port).toBeGreaterThan(47634);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects IPv6-busy and excluded ephemeral fallback candidates', async () => {
|
||||
const ipv6Server = net.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ipv6Server.once('error', reject);
|
||||
ipv6Server.listen(0, '::1', resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EAFNOSUPPORT' || code === 'EADDRNOTAVAIL') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ipv6Address = ipv6Server.address();
|
||||
if (!ipv6Address || typeof ipv6Address === 'string') {
|
||||
throw new Error('Unable to resolve IPv6 test listener port');
|
||||
}
|
||||
|
||||
const excludedServer = net.createServer();
|
||||
await new Promise<void>((resolve) => excludedServer.listen(0, '127.0.0.1', resolve));
|
||||
const excludedAddress = excludedServer.address();
|
||||
if (!excludedAddress || typeof excludedAddress === 'string') {
|
||||
throw new Error('Unable to resolve excluded test listener port');
|
||||
}
|
||||
await new Promise<void>((resolve) => excludedServer.close(() => resolve()));
|
||||
|
||||
const originalListen = net.Server.prototype.listen;
|
||||
const forcedPorts = [ipv6Address.port, excludedAddress.port];
|
||||
const listenSpy = vi.spyOn(net.Server.prototype, 'listen').mockImplementation(function (
|
||||
this: net.Server,
|
||||
...args: unknown[]
|
||||
) {
|
||||
if (args[0] === 0 && args[1] === '127.0.0.1' && forcedPorts.length > 0) {
|
||||
return Reflect.apply(originalListen, this, [
|
||||
forcedPorts.shift(),
|
||||
'127.0.0.1',
|
||||
]) as net.Server;
|
||||
}
|
||||
return Reflect.apply(originalListen, this, args) as net.Server;
|
||||
});
|
||||
|
||||
try {
|
||||
const port = await findAvailablePort(47635, '127.0.0.1', 0, new Set([excludedAddress.port]));
|
||||
|
||||
expect(forcedPorts).toHaveLength(0);
|
||||
expect(port).not.toBe(ipv6Address.port);
|
||||
expect(port).not.toBe(excludedAddress.port);
|
||||
expect(await isPortAvailable(port)).toBe(true);
|
||||
} finally {
|
||||
listenSpy.mockRestore();
|
||||
await new Promise<void>((resolve) => ipv6Server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,17 +19,21 @@ describe('desktop status pages CSP', () => {
|
|||
it('allows the generated status page style block by hash instead of unsafe-inline', () => {
|
||||
const html = statusPage('Starting', 'Preparing runtime');
|
||||
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
|
||||
const style = inlineStyle(html);
|
||||
|
||||
expect(csp).toContain(`style-src ${styleHash(inlineStyle(html))}`);
|
||||
expect(csp).toContain(`style-src ${styleHash(style)}`);
|
||||
expect(csp).not.toContain("'unsafe-inline'");
|
||||
expect(style).toContain('-webkit-app-region: drag');
|
||||
});
|
||||
|
||||
it('allows the static renderer startup style block by hash instead of unsafe-inline', () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const html = readFileSync(resolve(testDir, '../../renderer/index.html'), 'utf-8');
|
||||
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
|
||||
const style = inlineStyle(html);
|
||||
|
||||
expect(csp).toContain(`style-src ${styleHash(inlineStyle(html))}`);
|
||||
expect(csp).toContain(`style-src ${styleHash(style)}`);
|
||||
expect(csp).not.toContain("'unsafe-inline'");
|
||||
expect(style).toContain('-webkit-app-region: drag');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
DesktopUpdateService,
|
||||
ElectronAutoUpdaterAdapter,
|
||||
resolveDesktopUpdateChannel,
|
||||
type DesktopUpdateAdapter,
|
||||
type DesktopUpdateAdapterConfigureOptions,
|
||||
|
|
@ -36,7 +37,7 @@ class FakeUpdateAdapter implements DesktopUpdateAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
function service(adapter = new FakeUpdateAdapter()) {
|
||||
function service(adapter = new FakeUpdateAdapter(), currentVersion = '4.3.2') {
|
||||
const emitStatus = vi.fn();
|
||||
return {
|
||||
adapter,
|
||||
|
|
@ -44,7 +45,7 @@ function service(adapter = new FakeUpdateAdapter()) {
|
|||
service: new DesktopUpdateService({
|
||||
adapter,
|
||||
packaged: true,
|
||||
currentVersion: '4.3.2',
|
||||
currentVersion,
|
||||
channel: 'stable',
|
||||
now: () => new Date('2026-05-31T00:00:00.000Z'),
|
||||
emitStatus,
|
||||
|
|
@ -57,6 +58,7 @@ describe('desktop update service', () => {
|
|||
const harness = service();
|
||||
|
||||
expect(harness.adapter.configure).toHaveBeenCalledWith({
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
|
|
@ -90,6 +92,31 @@ describe('desktop update service', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('disables downgrade after assigning the updater channel', () => {
|
||||
const updater = {
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
forceDevUpdateConfig: false,
|
||||
set channel(_value: string | null) {
|
||||
this.allowDowngrade = true;
|
||||
},
|
||||
};
|
||||
const adapter = new ElectronAutoUpdaterAdapter(updater as never);
|
||||
|
||||
adapter.configure({
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: 'stable',
|
||||
forceDevUpdateConfig: false,
|
||||
});
|
||||
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps dev builds unsupported unless force dev update config is enabled', async () => {
|
||||
const adapter = new FakeUpdateAdapter();
|
||||
const updateService = new DesktopUpdateService({
|
||||
|
|
|
|||
80
desktop/src/main/__tests__/version-info.test.ts
Normal file
80
desktop/src/main/__tests__/version-info.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createDesktopAboutPanelOptions,
|
||||
createDesktopAppInfo,
|
||||
formatDesktopVersionInfo,
|
||||
normalizeBuildIdentity,
|
||||
} from '../version-info.js';
|
||||
|
||||
describe('desktop version information', () => {
|
||||
it('formats authoritative packaged version, build, channel, and platform details', () => {
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: 'abc1234',
|
||||
});
|
||||
|
||||
expect(info).toMatchObject({
|
||||
version: '6.0.2',
|
||||
buildIdentity: 'abc1234',
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
packaged: true,
|
||||
});
|
||||
expect(formatDesktopVersionInfo(info)).toBe(
|
||||
['Veritas Kanban 6.0.2', 'Build: abc1234', 'Channel: stable', 'macOS 15.5 · arm64'].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('labels prerelease and development builds without network access', () => {
|
||||
expect(
|
||||
createDesktopAppInfo('6.0.2-beta.1', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: null,
|
||||
}).channel
|
||||
).toBe('beta');
|
||||
|
||||
const development = createDesktopAppInfo('6.0.2', false, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: null,
|
||||
});
|
||||
expect(development.channel).toBe('dev');
|
||||
expect(formatDesktopVersionInfo(development)).toContain('Build: development');
|
||||
});
|
||||
|
||||
it('rejects path-like or unbounded build metadata from support output', () => {
|
||||
expect(normalizeBuildIdentity('/Users/example/private/build')).toBeNull();
|
||||
expect(normalizeBuildIdentity('a'.repeat(65))).toBeNull();
|
||||
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
buildIdentity: '/Users/example/private/build',
|
||||
osVersion: '15.5',
|
||||
});
|
||||
expect(info.buildIdentity).toBeNull();
|
||||
expect(formatDesktopVersionInfo(info)).not.toContain('/Users/');
|
||||
});
|
||||
|
||||
it('builds an offline native About panel from the same app information', () => {
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: 'abc1234',
|
||||
});
|
||||
|
||||
expect(createDesktopAboutPanelOptions(info)).toMatchObject({
|
||||
applicationName: 'Veritas Kanban',
|
||||
applicationVersion: '6.0.2',
|
||||
version: 'Build abc1234',
|
||||
credits: expect.stringContaining('Channel: stable'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,11 +2,10 @@ import type { IpcMain, Shell } from 'electron';
|
|||
import { lookup } from 'node:dns/promises';
|
||||
import { blockedRemoteConnectionDestinationReason } from '@veritas-kanban/shared';
|
||||
|
||||
import { DESKTOP_APP_ID, DESKTOP_APP_NAME } from './app-metadata.js';
|
||||
import type { DesktopCommandDispatcher } from './commands.js';
|
||||
import type { DesktopAppInfo } from './types.js';
|
||||
import type { DesktopRuntime } from './runtime.js';
|
||||
import type { DesktopUpdateService } from './updates.js';
|
||||
import { createDesktopAppInfo } from './version-info.js';
|
||||
import {
|
||||
createDesktopSetupDiagnostics,
|
||||
createDesktopSupportSnapshot,
|
||||
|
|
@ -27,6 +26,7 @@ import {
|
|||
type DesktopBridgeResponse,
|
||||
type DesktopConnectionConfigRequest,
|
||||
type DesktopConnectionValidationResult,
|
||||
type DesktopWindowToggleMaximizeResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
|
@ -37,6 +37,10 @@ export type DesktopBridgeHandlerMap = {
|
|||
) => MaybePromise<DesktopBridgeResponse<Method>>;
|
||||
};
|
||||
|
||||
export interface DesktopWindowControls {
|
||||
toggleMaximize(): DesktopWindowToggleMaximizeResult;
|
||||
}
|
||||
|
||||
async function remoteConnectionDestinationError(serverUrl: string): Promise<string | null> {
|
||||
const parsed = new URL(serverUrl);
|
||||
const directReason = blockedRemoteConnectionDestinationReason(parsed.hostname);
|
||||
|
|
@ -199,16 +203,12 @@ export function createDesktopBridgeHandlers(
|
|||
runtime: DesktopRuntime,
|
||||
shell: Shell,
|
||||
packaged: boolean,
|
||||
appVersion: string,
|
||||
commandDispatcher?: DesktopCommandDispatcher,
|
||||
updateService?: DesktopUpdateService
|
||||
updateService?: DesktopUpdateService,
|
||||
windowControls?: DesktopWindowControls
|
||||
): DesktopBridgeHandlerMap {
|
||||
const appInfo = (): DesktopAppInfo => ({
|
||||
name: DESKTOP_APP_NAME,
|
||||
appId: DESKTOP_APP_ID,
|
||||
version: process.env.npm_package_version || '0.0.0',
|
||||
platform: process.platform,
|
||||
packaged,
|
||||
});
|
||||
const appInfo = () => createDesktopAppInfo(appVersion, packaged);
|
||||
|
||||
return {
|
||||
getAppInfo: appInfo,
|
||||
|
|
@ -237,7 +237,7 @@ export function createDesktopBridgeHandlers(
|
|||
updateService?.snapshot() ?? {
|
||||
state: 'unsupported',
|
||||
currentVersion: appInfo().version,
|
||||
channel: packaged ? 'stable' : 'dev',
|
||||
channel: appInfo().channel,
|
||||
checkedAt: new Date().toISOString(),
|
||||
detail: 'Updater service is not initialized.',
|
||||
},
|
||||
|
|
@ -289,6 +289,7 @@ export function createDesktopBridgeHandlers(
|
|||
await shell.openExternal(url);
|
||||
return undefined;
|
||||
},
|
||||
toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -297,28 +298,33 @@ export function registerDesktopBridge(
|
|||
runtime: DesktopRuntime,
|
||||
shell: Shell,
|
||||
packaged: boolean,
|
||||
appVersion: string,
|
||||
commandDispatcher?: DesktopCommandDispatcher,
|
||||
updateService?: DesktopUpdateService
|
||||
updateService?: DesktopUpdateService,
|
||||
windowControls?: DesktopWindowControls
|
||||
): void {
|
||||
const handlers = createDesktopBridgeHandlers(
|
||||
runtime,
|
||||
shell,
|
||||
packaged,
|
||||
appVersion,
|
||||
commandDispatcher,
|
||||
updateService
|
||||
updateService,
|
||||
windowControls
|
||||
);
|
||||
|
||||
for (const method of DESKTOP_BRIDGE_METHOD_NAMES) {
|
||||
const definition = DESKTOP_BRIDGE_METHODS[method];
|
||||
const handler = handlers[method] as (request: unknown) => MaybePromise<unknown>;
|
||||
const validator = DESKTOP_BRIDGE_METHOD_VALIDATORS[method] as
|
||||
| ((payload: unknown) => unknown)
|
||||
| undefined;
|
||||
((payload: unknown) => unknown) | undefined;
|
||||
|
||||
ipcMain.handle(definition.channel, async (_event, request: unknown) => {
|
||||
try {
|
||||
return await handler(validator ? validator(request) : request);
|
||||
} catch (error) {
|
||||
// Do not attach the original cause to errors crossing the desktop bridge.
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(redactDesktopBridgeError(error));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ function commandLabel(name: DesktopCommandName): string {
|
|||
return 'Settings';
|
||||
case 'open-command-center':
|
||||
return 'Command Center';
|
||||
case 'reset-layout':
|
||||
return 'Reset Window Layout';
|
||||
case 'import-data':
|
||||
return 'Import';
|
||||
case 'export-data':
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import {
|
|||
ElectronAutoUpdaterAdapter,
|
||||
resolveDesktopUpdateChannel,
|
||||
} from './updates.js';
|
||||
import {
|
||||
createDesktopAboutPanelOptions,
|
||||
createDesktopAppInfo,
|
||||
formatDesktopVersionInfo,
|
||||
} from './version-info.js';
|
||||
import {
|
||||
DESKTOP_BRIDGE_EVENTS,
|
||||
redactDesktopBridgeValue,
|
||||
|
|
@ -45,6 +50,24 @@ let quitting = false;
|
|||
let shutdownStarted = false;
|
||||
const pendingDeepLinks: string[] = [];
|
||||
|
||||
function activeMainWindow(): BrowserWindow | null {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function showDesktopError(message: string): void {
|
||||
if (quitting) return;
|
||||
|
||||
const window = activeMainWindow();
|
||||
if (!window) return;
|
||||
|
||||
void window
|
||||
.loadURL(statusPageUrl('Veritas Kanban desktop error', message, runtime?.snapshot()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function isPackagedRuntime(): boolean {
|
||||
return app.isPackaged || process.env.VERITAS_DESKTOP_PRODUCTION === 'true';
|
||||
}
|
||||
|
|
@ -70,7 +93,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||
}
|
||||
|
||||
function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
|
||||
const preloadPath = path.join(__dirname, '../preload/index.mjs');
|
||||
const preloadPath = path.join(__dirname, '../preload/index.cjs');
|
||||
const windowBounds = applyDesktopWindowState(savedState);
|
||||
|
||||
const window = new BrowserWindow({
|
||||
|
|
@ -79,6 +102,7 @@ function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
|
|||
minHeight: DESKTOP_MIN_WINDOW.height,
|
||||
...windowBounds,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 18 } : undefined,
|
||||
backgroundColor: '#111318',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
|
|
@ -98,6 +122,11 @@ function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
|
|||
writeDesktopWindowStateSync(windowStatePaths, captureDesktopWindowState(window));
|
||||
}
|
||||
});
|
||||
window.on('closed', () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void openValidatedExternalUrl(shell, url);
|
||||
return { action: 'deny' };
|
||||
|
|
@ -130,7 +159,7 @@ function handleDeepLink(url: string): void {
|
|||
const deepLink = parseDesktopDeepLink(url);
|
||||
void commandDispatcher.dispatch(deepLink.command);
|
||||
} catch (error) {
|
||||
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.communicationCheck.channel, {
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.communicationCheck.channel, {
|
||||
target: 'external',
|
||||
state: 'failed',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
|
|
@ -150,9 +179,11 @@ function refreshDesktopMenu(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), launchPackaged);
|
||||
configureDesktopMenu({
|
||||
status: runtime.snapshot(),
|
||||
updateStatus: updateService?.snapshot(),
|
||||
copyVersionInfo: () => clipboard.writeText(formatDesktopVersionInfo(appInfo)),
|
||||
dispatch: (command) => {
|
||||
if (commandDispatcher) {
|
||||
dispatchDesktopMenuCommand(commandDispatcher, command);
|
||||
|
|
@ -162,10 +193,11 @@ function refreshDesktopMenu(): void {
|
|||
}
|
||||
|
||||
function updateServiceFallback(packaged: boolean): DesktopUpdateStatus {
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), packaged);
|
||||
return {
|
||||
state: 'unsupported',
|
||||
currentVersion: app.getVersion(),
|
||||
channel: packaged ? 'stable' : 'dev',
|
||||
currentVersion: appInfo.version,
|
||||
channel: appInfo.channel,
|
||||
checkedAt: new Date().toISOString(),
|
||||
detail: 'Updater service is not initialized.',
|
||||
};
|
||||
|
|
@ -176,6 +208,8 @@ async function boot(): Promise<void> {
|
|||
app.setAppUserModelId(DESKTOP_APP_ID);
|
||||
|
||||
const packaged = launchPackaged;
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), packaged);
|
||||
app.setAboutPanelOptions(createDesktopAboutPanelOptions(appInfo));
|
||||
const repoRoot = launchRepoRoot;
|
||||
const profile = launchProfile;
|
||||
const workspace = launchWorkspace;
|
||||
|
|
@ -191,7 +225,12 @@ async function boot(): Promise<void> {
|
|||
const serverPort = await findAvailablePort(
|
||||
Number(process.env.VERITAS_DESKTOP_SERVER_PORT || 3001)
|
||||
);
|
||||
const webPort = await findAvailablePort(Number(process.env.VERITAS_DESKTOP_WEB_PORT || 3000));
|
||||
const webPort = await findAvailablePort(
|
||||
Number(process.env.VERITAS_DESKTOP_WEB_PORT || 3000),
|
||||
'127.0.0.1',
|
||||
50,
|
||||
new Set([serverPort])
|
||||
);
|
||||
|
||||
mainWindow = createMainWindow(await readDesktopWindowState(paths));
|
||||
await mainWindow.loadURL(statusPageUrl('Starting Veritas Kanban', 'Preparing the local app.'));
|
||||
|
|
@ -248,7 +287,7 @@ async function boot(): Promise<void> {
|
|||
),
|
||||
forceDevUpdateConfig: process.env.VERITAS_DESKTOP_UPDATER_FORCE_DEV === 'true',
|
||||
emitStatus: (status) => {
|
||||
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status);
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status);
|
||||
refreshDesktopMenu();
|
||||
},
|
||||
});
|
||||
|
|
@ -258,7 +297,7 @@ async function boot(): Promise<void> {
|
|||
shell,
|
||||
quit: () => app.quit(),
|
||||
sendRendererCommand: (command) => {
|
||||
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command);
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command);
|
||||
},
|
||||
checkForUpdates: () =>
|
||||
updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)),
|
||||
|
|
@ -280,10 +319,32 @@ async function boot(): Promise<void> {
|
|||
},
|
||||
});
|
||||
|
||||
registerDesktopBridge(ipcMain, runtime, shell, packaged, commandDispatcher, updateService);
|
||||
registerDesktopBridge(
|
||||
ipcMain,
|
||||
runtime,
|
||||
shell,
|
||||
packaged,
|
||||
app.getVersion(),
|
||||
commandDispatcher,
|
||||
updateService,
|
||||
{
|
||||
toggleMaximize: () => {
|
||||
const window = activeMainWindow();
|
||||
if (!window) {
|
||||
return { maximized: false };
|
||||
}
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
},
|
||||
}
|
||||
);
|
||||
refreshDesktopMenu();
|
||||
runtime.on('status', (status) => {
|
||||
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status);
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status);
|
||||
refreshDesktopMenu();
|
||||
});
|
||||
|
||||
|
|
@ -346,12 +407,10 @@ app.on('activate', () => {
|
|||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
mainWindow?.loadURL(
|
||||
statusPageUrl('Veritas Kanban desktop error', error.message, runtime?.snapshot())
|
||||
);
|
||||
showDesktopError(error.message);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason);
|
||||
mainWindow?.loadURL(statusPageUrl('Veritas Kanban desktop error', message, runtime?.snapshot()));
|
||||
showDesktopError(message);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
|
||||
export interface ConfigureDesktopMenuOptions {
|
||||
dispatch(command: DesktopCommandName): void;
|
||||
copyVersionInfo(): void;
|
||||
status: DesktopStatusSnapshot;
|
||||
updateStatus?: DesktopUpdateStatus;
|
||||
}
|
||||
|
|
@ -38,6 +39,13 @@ export function createDesktopMenuTemplate(
|
|||
{
|
||||
label: 'Veritas Kanban',
|
||||
submenu: [
|
||||
{ role: 'about', label: 'About Veritas Kanban' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy Version Information',
|
||||
click: () => options.copyVersionInfo(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
command('open-onboarding'),
|
||||
command('open-settings'),
|
||||
command('communication-health'),
|
||||
|
|
@ -58,9 +66,16 @@ export function createDesktopMenuTemplate(
|
|||
command('create-backup'),
|
||||
],
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{
|
||||
label: 'Navigate',
|
||||
submenu: [command('open-command-center'), command('open-search'), command('open-settings')],
|
||||
submenu: [
|
||||
command('open-command-center'),
|
||||
command('open-search'),
|
||||
command('open-settings'),
|
||||
{ type: 'separator' },
|
||||
command('reset-layout'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Desktop',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,39 @@
|
|||
import net from 'node:net';
|
||||
|
||||
export async function isPortAvailable(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||
async function isPortAcceptingConnections(port: number, host: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const socket = net.createConnection({ port, host });
|
||||
const finish = (accepting: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(accepting);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(250, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
|
||||
// macOS can allow a specific-address bind beside an existing IPv6 wildcard
|
||||
// listener. Probe for an accepting server before trusting the bind check.
|
||||
if (await isPortAcceptingConnections(port, host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
if (host === '::1' && (error.code === 'EAFNOSUPPORT' || error.code === 'EADDRNOTAVAIL')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
|
@ -12,31 +41,79 @@ export async function isPortAvailable(port: number, host = '127.0.0.1'): Promise
|
|||
});
|
||||
}
|
||||
|
||||
async function isPortAvailableOnComplementaryHost(port: number, host: string): Promise<boolean> {
|
||||
return host === '127.0.0.1' ? isPortAvailableOnHost(port, '::1') : true;
|
||||
}
|
||||
|
||||
export async function isPortAvailable(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||
if (!(await isPortAvailableOnHost(port, host))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Chromium can reach an IPv6 wildcard listener even when the renderer URL
|
||||
// names 127.0.0.1. Reject the candidate if either loopback family is busy so
|
||||
// the desktop app cannot attach to an unrelated local Veritas/dev server.
|
||||
return isPortAvailableOnComplementaryHost(port, host);
|
||||
}
|
||||
|
||||
export async function findAvailablePort(
|
||||
preferredPort: number,
|
||||
host = '127.0.0.1',
|
||||
maxAttempts = 50
|
||||
maxAttempts = 50,
|
||||
excludedPorts: ReadonlySet<number> = new Set()
|
||||
): Promise<number> {
|
||||
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
||||
const candidate = preferredPort + offset;
|
||||
if (excludedPorts.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (await isPortAvailable(candidate, host)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
server.close(() => {
|
||||
if (typeof address === 'object' && address) {
|
||||
resolve(address.port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Unable to allocate an ephemeral port'));
|
||||
const reservations: net.Server[] = [];
|
||||
try {
|
||||
// Keep rejected reservations open so the OS cannot hand the same
|
||||
// ephemeral candidate back to a later attempt. The extra attempts cover
|
||||
// candidates rejected by the complementary-family collision probe.
|
||||
const fallbackAttempts = excludedPorts.size + 50;
|
||||
for (let attempt = 0; attempt < fallbackAttempts; attempt += 1) {
|
||||
const reservation = net.createServer();
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
reservation.once('error', reject);
|
||||
reservation.once('listening', () => {
|
||||
const address = reservation.address();
|
||||
if (typeof address === 'object' && address) {
|
||||
resolve(address.port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Unable to allocate an ephemeral port'));
|
||||
});
|
||||
reservation.listen(0, host);
|
||||
});
|
||||
});
|
||||
server.listen(0, host);
|
||||
});
|
||||
reservations.push(reservation);
|
||||
|
||||
if (excludedPorts.has(port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The live reservation proves the requested family is available. Probe
|
||||
// the other loopback family with the same connect + bind checks used by
|
||||
// isPortAvailable before releasing the candidate.
|
||||
if (!(await isPortAvailableOnComplementaryHost(port, host))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(
|
||||
reservations.map(
|
||||
(reservation) => new Promise<void>((resolve) => reservation.close(() => resolve()))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unable to allocate an unexcluded ephemeral port');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const STATUS_PAGE_CSS = `
|
|||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
main {
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
|
|
@ -37,6 +38,7 @@ const STATUS_PAGE_CSS = `
|
|||
padding: 16px;
|
||||
color: #d8deea;
|
||||
font-size: 12px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export interface DesktopAppInfo {
|
|||
name: string;
|
||||
appId: string;
|
||||
version: string;
|
||||
buildIdentity: string | null;
|
||||
channel: 'dev' | 'beta' | 'stable';
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
osVersion: string;
|
||||
packaged: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type DesktopUpdateEvent =
|
|||
| 'error';
|
||||
|
||||
export interface DesktopUpdateAdapterConfigureOptions {
|
||||
allowDowngrade: boolean;
|
||||
allowPrerelease: boolean;
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
|
|
@ -48,6 +49,9 @@ export class ElectronAutoUpdaterAdapter implements DesktopUpdateAdapter {
|
|||
this.updater.autoInstallOnAppQuit = options.autoInstallOnAppQuit;
|
||||
this.updater.allowPrerelease = options.allowPrerelease;
|
||||
this.updater.channel = options.channel === 'stable' ? null : options.channel;
|
||||
// electron-updater sets allowDowngrade=true whenever channel is assigned.
|
||||
// Stable checks must never turn older release metadata into an update.
|
||||
this.updater.allowDowngrade = options.allowDowngrade;
|
||||
this.updater.forceDevUpdateConfig = options.forceDevUpdateConfig;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +87,7 @@ export class DesktopUpdateService {
|
|||
: this.createStatus('unsupported', 'Updater checks run only from packaged builds.');
|
||||
|
||||
options.adapter.configure({
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: options.channel !== 'stable',
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
|
|
|
|||
102
desktop/src/main/version-info.ts
Normal file
102
desktop/src/main/version-info.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import os from 'node:os';
|
||||
|
||||
import { DESKTOP_APP_ID, DESKTOP_APP_NAME } from './app-metadata.js';
|
||||
import type { DesktopAppInfo } from './types.js';
|
||||
import { resolveDesktopUpdateChannel } from './updates.js';
|
||||
|
||||
declare const __VERITAS_BUILD_SHA__: string | undefined;
|
||||
declare const __VERITAS_RELEASE_CHANNEL__: string | undefined;
|
||||
|
||||
export interface DesktopAppInfoOverrides {
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
osVersion?: string;
|
||||
requestedChannel?: string;
|
||||
buildIdentity?: string | null;
|
||||
}
|
||||
|
||||
function embeddedBuildIdentity(): string | null {
|
||||
const value = typeof __VERITAS_BUILD_SHA__ === 'string' ? __VERITAS_BUILD_SHA__ : undefined;
|
||||
return normalizeBuildIdentity(value);
|
||||
}
|
||||
|
||||
function embeddedReleaseChannel(): string | undefined {
|
||||
return typeof __VERITAS_RELEASE_CHANNEL__ === 'string' && __VERITAS_RELEASE_CHANNEL__.trim()
|
||||
? __VERITAS_RELEASE_CHANNEL__
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeBuildIdentity(value: string | undefined | null): string | null {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveSystemVersion(platform: NodeJS.Platform): string {
|
||||
if (platform === 'darwin') {
|
||||
const electronProcess = process as NodeJS.Process & { getSystemVersion?: () => string };
|
||||
const systemVersion = electronProcess.getSystemVersion?.();
|
||||
if (systemVersion?.trim()) return systemVersion.trim();
|
||||
}
|
||||
return os.release();
|
||||
}
|
||||
|
||||
export function createDesktopAppInfo(
|
||||
version: string,
|
||||
packaged: boolean,
|
||||
overrides: DesktopAppInfoOverrides = {}
|
||||
): DesktopAppInfo {
|
||||
const platform = overrides.platform ?? process.platform;
|
||||
const buildIdentity =
|
||||
overrides.buildIdentity === undefined
|
||||
? embeddedBuildIdentity()
|
||||
: normalizeBuildIdentity(overrides.buildIdentity);
|
||||
return {
|
||||
name: DESKTOP_APP_NAME,
|
||||
appId: DESKTOP_APP_ID,
|
||||
version,
|
||||
buildIdentity,
|
||||
channel: resolveDesktopUpdateChannel(
|
||||
overrides.requestedChannel ?? embeddedReleaseChannel() ?? process.env.VERITAS_UPDATE_CHANNEL,
|
||||
version,
|
||||
packaged
|
||||
),
|
||||
platform,
|
||||
arch: overrides.arch ?? process.arch,
|
||||
osVersion: overrides.osVersion ?? resolveSystemVersion(platform),
|
||||
packaged,
|
||||
};
|
||||
}
|
||||
|
||||
function platformLabel(platform: NodeJS.Platform): string {
|
||||
if (platform === 'darwin') return 'macOS';
|
||||
if (platform === 'win32') return 'Windows';
|
||||
if (platform === 'linux') return 'Linux';
|
||||
return platform;
|
||||
}
|
||||
|
||||
export function formatDesktopVersionInfo(info: DesktopAppInfo): string {
|
||||
const lines = [`${info.name} ${info.version}`];
|
||||
if (info.buildIdentity) {
|
||||
lines.push(`Build: ${info.buildIdentity}`);
|
||||
} else if (!info.packaged) {
|
||||
lines.push('Build: development');
|
||||
}
|
||||
lines.push(
|
||||
`Channel: ${info.channel}`,
|
||||
`${platformLabel(info.platform)} ${info.osVersion} · ${info.arch}`
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createDesktopAboutPanelOptions(info: DesktopAppInfo) {
|
||||
const supportLines = formatDesktopVersionInfo(info).split('\n').slice(1);
|
||||
return {
|
||||
applicationName: info.name,
|
||||
applicationVersion: info.version,
|
||||
version: info.buildIdentity ? `Build ${info.buildIdentity}` : `Channel ${info.channel}`,
|
||||
credits: supportLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,30 +1,73 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
import type { DesktopAppInfo, DesktopStatusSnapshot } from '../main/types.js';
|
||||
import {
|
||||
createDesktopBridgeEventCleanup,
|
||||
DESKTOP_RESTART_CONFIRMATION,
|
||||
DESKTOP_BRIDGE_EVENTS,
|
||||
DESKTOP_BRIDGE_METHODS,
|
||||
type DesktopBridgeEvent,
|
||||
type DesktopBridgeEventPayload,
|
||||
type DesktopCommandDispatchRequest,
|
||||
type DesktopCommandDispatchResult,
|
||||
type DesktopConnectionConfigRequest,
|
||||
type DesktopConnectionValidationResult,
|
||||
type DesktopDiagnosticsBundleRequest,
|
||||
type DesktopDiagnosticsBundleResult,
|
||||
type DesktopFilePickerRequest,
|
||||
type DesktopFilePickerResult,
|
||||
type DesktopNotificationActionRequest,
|
||||
type DesktopNotificationActionResult,
|
||||
type DesktopSetupDiagnostics,
|
||||
type DesktopSupportSnapshot,
|
||||
type DesktopUpdateStatus,
|
||||
type DesktopWorkProductExportRequest,
|
||||
type DesktopWorkProductExportResult,
|
||||
import type {
|
||||
DesktopBridgeEventPayload,
|
||||
DesktopCommandDispatchRequest,
|
||||
DesktopCommandDispatchResult,
|
||||
DesktopConnectionConfigRequest,
|
||||
DesktopConnectionValidationResult,
|
||||
DesktopDiagnosticsBundleRequest,
|
||||
DesktopDiagnosticsBundleResult,
|
||||
DesktopFilePickerRequest,
|
||||
DesktopFilePickerResult,
|
||||
DesktopNotificationActionRequest,
|
||||
DesktopNotificationActionResult,
|
||||
DesktopSetupDiagnostics,
|
||||
DesktopSupportSnapshot,
|
||||
DesktopUpdateStatus,
|
||||
DesktopWindowToggleMaximizeResult,
|
||||
DesktopWorkProductExportRequest,
|
||||
DesktopWorkProductExportResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
const DESKTOP_RESTART_CONFIRMATION = 'restart-local-server';
|
||||
|
||||
const DESKTOP_BRIDGE_METHODS = {
|
||||
getAppInfo: { channel: 'desktop:get-app-info' },
|
||||
getConnectionStatus: { channel: 'desktop:get-connection-status' },
|
||||
getSetupDiagnostics: { channel: 'desktop:get-setup-diagnostics' },
|
||||
validateConnectionConfig: { channel: 'desktop:validate-connection-config' },
|
||||
restartLocalServer: { channel: 'desktop:restart-local-server' },
|
||||
getSupportSnapshot: { channel: 'desktop:get-support-snapshot' },
|
||||
getUpdateStatus: { channel: 'desktop:get-update-status' },
|
||||
dispatchCommand: { channel: 'desktop:dispatch-command' },
|
||||
pickUploadFiles: { channel: 'desktop:pick-upload-files' },
|
||||
createDiagnosticsBundle: { channel: 'desktop:create-diagnostics-bundle' },
|
||||
performNotificationAction: { channel: 'desktop:perform-notification-action' },
|
||||
exportWorkProduct: { channel: 'desktop:export-work-product' },
|
||||
openExternal: { channel: 'desktop:open-external' },
|
||||
toggleWindowMaximize: { channel: 'desktop:toggle-window-maximize' },
|
||||
} as const;
|
||||
|
||||
const DESKTOP_BRIDGE_EVENTS = {
|
||||
setupProgress: { channel: 'desktop:setup-progress' },
|
||||
communicationCheck: { channel: 'desktop:communication-check' },
|
||||
serverStatus: { channel: 'desktop:server-status' },
|
||||
runProgress: { channel: 'desktop:run-progress' },
|
||||
updateStatus: { channel: 'desktop:update-status' },
|
||||
notificationAction: { channel: 'desktop:notification-action' },
|
||||
menuCommand: { channel: 'desktop:menu-command' },
|
||||
uploadProgress: { channel: 'desktop:upload-progress' },
|
||||
workProductExportProgress: { channel: 'desktop:work-product-export-progress' },
|
||||
externalDeliveryVerification: { channel: 'desktop:external-delivery-verification' },
|
||||
} as const;
|
||||
|
||||
type DesktopBridgeEvent = keyof typeof DESKTOP_BRIDGE_EVENTS;
|
||||
|
||||
function createDesktopBridgeEventCleanup<Handler>(
|
||||
channel: string,
|
||||
handler: Handler,
|
||||
detach: (channel: string, handler: Handler) => void
|
||||
): () => void {
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
detach(channel, handler);
|
||||
};
|
||||
}
|
||||
|
||||
export interface VeritasDesktopApi {
|
||||
getAppInfo(): Promise<DesktopAppInfo>;
|
||||
getConnectionStatus(): Promise<DesktopStatusSnapshot>;
|
||||
|
|
@ -47,6 +90,7 @@ export interface VeritasDesktopApi {
|
|||
request: DesktopWorkProductExportRequest
|
||||
): Promise<DesktopWorkProductExportResult>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
toggleWindowMaximize(): Promise<DesktopWindowToggleMaximizeResult>;
|
||||
onSetupProgress(listener: BridgeEventListener<'setupProgress'>): () => void;
|
||||
onCommunicationCheck(listener: BridgeEventListener<'communicationCheck'>): () => void;
|
||||
onServerStatus(listener: (status: DesktopStatusSnapshot) => void): () => void;
|
||||
|
|
@ -131,6 +175,10 @@ const api: VeritasDesktopApi = {
|
|||
),
|
||||
openExternal: (url: string) =>
|
||||
invokeDesktop<void>(DESKTOP_BRIDGE_METHODS.openExternal.channel, { url }),
|
||||
toggleWindowMaximize: () =>
|
||||
invokeDesktop<DesktopWindowToggleMaximizeResult>(
|
||||
DESKTOP_BRIDGE_METHODS.toggleWindowMaximize.channel
|
||||
),
|
||||
onSetupProgress: (listener) => onDesktopEvent('setupProgress', listener),
|
||||
onCommunicationCheck: (listener) => onDesktopEvent('communicationCheck', listener),
|
||||
onServerStatus: (listener) => onDesktopEvent('serverStatus', listener),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; style-src 'sha256-73uqRU+Tsu2ddgLhX3AMG2sgAbLckUuCsmHkbBY3NnM='; img-src data:; script-src 'none';"
|
||||
content="default-src 'none'; style-src 'sha256-xvSit7g7hrRoyQ9jZajpAVwT+zh5N9+6jMWpB9vorxQ='; img-src data:; script-src 'none';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Veritas Kanban</title>
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
main {
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const DESKTOP_COMMAND_NAMES = [
|
|||
'open-search',
|
||||
'open-settings',
|
||||
'open-command-center',
|
||||
'reset-layout',
|
||||
'import-data',
|
||||
'export-data',
|
||||
'create-backup',
|
||||
|
|
@ -203,6 +204,10 @@ export interface DesktopWorkProductExportResult {
|
|||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DesktopWindowToggleMaximizeResult {
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
export const DESKTOP_BRIDGE_METHODS = {
|
||||
getAppInfo: {
|
||||
capability: 'setup',
|
||||
|
|
@ -290,6 +295,12 @@ export const DESKTOP_BRIDGE_METHODS = {
|
|||
dangerous: true,
|
||||
validator: 'openExternal',
|
||||
},
|
||||
toggleWindowMaximize: {
|
||||
capability: 'shell',
|
||||
channel: 'desktop:toggle-window-maximize',
|
||||
desktopOnly: true,
|
||||
dangerous: false,
|
||||
},
|
||||
} as const satisfies Record<string, DesktopBridgeMethodDefinition>;
|
||||
|
||||
export const DESKTOP_BRIDGE_METHOD_NAMES = [
|
||||
|
|
@ -306,6 +317,7 @@ export const DESKTOP_BRIDGE_METHOD_NAMES = [
|
|||
'performNotificationAction',
|
||||
'exportWorkProduct',
|
||||
'openExternal',
|
||||
'toggleWindowMaximize',
|
||||
] as const;
|
||||
|
||||
export type DesktopBridgeMethod = (typeof DESKTOP_BRIDGE_METHOD_NAMES)[number];
|
||||
|
|
@ -413,6 +425,7 @@ export interface DesktopBridgeRequestMap {
|
|||
performNotificationAction: DesktopNotificationActionRequest;
|
||||
exportWorkProduct: DesktopWorkProductExportRequest;
|
||||
openExternal: OpenExternalRequest;
|
||||
toggleWindowMaximize: undefined;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeResponseMap {
|
||||
|
|
@ -429,6 +442,7 @@ export interface DesktopBridgeResponseMap {
|
|||
performNotificationAction: DesktopNotificationActionResult;
|
||||
exportWorkProduct: DesktopWorkProductExportResult;
|
||||
openExternal: undefined;
|
||||
toggleWindowMaximize: DesktopWindowToggleMaximizeResult;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeEventPayloadMap {
|
||||
|
|
|
|||
|
|
@ -5,5 +5,21 @@ export default defineConfig({
|
|||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['dist/**', 'out/**', 'node_modules/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/__tests__/**',
|
||||
'src/**/__fixtures__/**',
|
||||
'src/**/fixtures/**',
|
||||
'src/**/generated/**',
|
||||
'src/**/*.generated.*',
|
||||
'src/**/types.ts',
|
||||
'src/types/**/*.ts',
|
||||
],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: veritas-kanban-demo
|
||||
# IMPORTANT: Must match Dockerfile WORKDIR (/app/server) for correct path resolution
|
||||
# Kept aligned with the image entrypoint; persistent paths resolve from DATA_DIR.
|
||||
working_dir: /app/server
|
||||
ports:
|
||||
# Demo instance port (do NOT use production 3001)
|
||||
|
|
|
|||
1348
docs/AGENT-PROVIDERS.md
Normal file
1348
docs/AGENT-PROVIDERS.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,7 @@ The Agent Registry is a service discovery and liveness tracking system for AI ag
|
|||
| **Persistence** | File-backed JSON survives server restarts |
|
||||
| **Dashboard** | Live agent cards in the board sidebar |
|
||||
|
||||
**Storage:** `.veritas-kanban/agent-registry.json`
|
||||
**Storage:** `<storage-root>/.veritas-kanban/agent-registry.json`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -84,20 +84,36 @@ Registers a new agent or updates an existing one. Sets status to `online` automa
|
|||
],
|
||||
"version": "2.0.0",
|
||||
"metadata": { "role": "lead", "reference": "Interstellar" },
|
||||
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." },
|
||||
"sessionKey": "optional-openclaw-session-key"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------- | ------ | -------- | ---------------------------------------- |
|
||||
| `id` | string | ✅ | Unique identifier (1-50 chars) |
|
||||
| `name` | string | ✅ | Display name (1-100 chars) |
|
||||
| `model` | string | | Model identifier |
|
||||
| `provider` | string | | Provider name |
|
||||
| `capabilities` | array | | List of `{ name, description? }` objects |
|
||||
| `version` | string | | Agent version or build info |
|
||||
| `metadata` | object | | Freeform key-value data |
|
||||
| `sessionKey` | string | | OpenClaw session key for routing |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
|
||||
| `id` | string | ✅ | Unique identifier (1-50 chars) |
|
||||
| `name` | string | ✅ | Display name (1-100 chars) |
|
||||
| `model` | string | | Model identifier |
|
||||
| `provider` | string | | Provider name |
|
||||
| `capabilities` | array | | List of `{ name, description? }` objects |
|
||||
| `version` | string | | Agent version or build info |
|
||||
| `metadata` | object | | Freeform key-value data |
|
||||
| `providerRuntimeManifest` | object | | Validated runtime capability evidence; see the Provider Runtime Manifest API reference |
|
||||
| `sessionKey` | string | | OpenClaw session key for routing |
|
||||
|
||||
The manifest digest is recomputed on registration. Forged, incomplete, or
|
||||
forward-incompatible manifests, secret-like diagnostic fields, and unknown or
|
||||
misspelled request fields are rejected with `400`. Legacy `provider`,
|
||||
`model`, `capabilities`, and metadata fields remain available for compatibility
|
||||
and display, but they cannot satisfy required runtime capability routing.
|
||||
Authoritative manifest writes require `telemetry:write` plus either a key/token
|
||||
identity matching the target agent ID or `agent:write`. Use a distinct named API
|
||||
key for each self-registering agent; do not share a generic telemetry key for
|
||||
runtime evidence. Once a record contains authoritative evidence, re-registration,
|
||||
heartbeat, replacement, and deregistration remain identity-bound so another
|
||||
telemetry writer cannot refresh, invalidate, or delete it. Re-registering with a
|
||||
changed provider, model, or version and without replacement evidence invalidates
|
||||
the previous manifest.
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
|
|
@ -133,16 +149,21 @@ Updates the agent's last-seen timestamp and optionally changes status or task as
|
|||
"status": "busy",
|
||||
"currentTaskId": "task_20260206_abc123",
|
||||
"currentTaskTitle": "Implement authentication flow",
|
||||
"metadata": { "progress": 0.65 }
|
||||
"metadata": { "progress": 0.65 },
|
||||
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Values | Description |
|
||||
| ------------------ | -------------- | ------------------------ | --------------------------------------- |
|
||||
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
|
||||
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
|
||||
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
|
||||
| `metadata` | object | | Merge additional metadata |
|
||||
| Field | Type | Values | Description |
|
||||
| ------------------------- | -------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
|
||||
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
|
||||
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
|
||||
| `metadata` | object | | Merge additional metadata |
|
||||
| `providerRuntimeManifest` | object | | Replace the validated runtime manifest after a provider/version change |
|
||||
|
||||
Heartbeat manifest replacement uses the same authenticated identity binding as
|
||||
registration. Ordinary telemetry-only heartbeats remain backward compatible.
|
||||
|
||||
**Response:** `200 OK` — Returns updated agent object.
|
||||
|
||||
|
|
@ -385,13 +406,14 @@ The panel reads from the registry API and updates every 30 seconds (plus WebSock
|
|||
| ------------------------- | ------------------ | -------------------------------------------- |
|
||||
| `HEARTBEAT_TIMEOUT_MS` | 300,000 (5 min) | Time before marking agent offline |
|
||||
| `STALE_CHECK_INTERVAL_MS` | 60,000 (1 min) | How often the server checks for stale agents |
|
||||
| `VERITAS_DATA_DIR` | `.veritas-kanban/` | Directory for registry JSON file |
|
||||
| `VERITAS_DATA_DIR` | Project root | Storage root used when `DATA_DIR` is unset |
|
||||
|
||||
---
|
||||
|
||||
## File Format
|
||||
|
||||
The registry is stored as JSON at `.veritas-kanban/agent-registry.json`:
|
||||
The registry is stored as JSON at
|
||||
`<storage-root>/.veritas-kanban/agent-registry.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -417,6 +439,11 @@ The registry is stored as JSON at `.veritas-kanban/agent-registry.json`:
|
|||
|
||||
The service loads this file on startup and persists after every change. You can manually edit this file (with the server stopped) to seed or reset the registry.
|
||||
|
||||
> **Persistence note:** Registry writes are debounced over a 2-second window and
|
||||
> use atomic rename-on-write, so the file is always in a consistent state and
|
||||
> heartbeat bursts do not block the event loop. Pending writes are flushed
|
||||
> before the server shuts down.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
|||
|
|
@ -1,212 +1,279 @@
|
|||
# AGENTS.md Template — Veritas Kanban Self-Reporting Protocol
|
||||
# Agent Guide and `AGENTS.md` Template
|
||||
|
||||
Use this template for agents that integrate with Veritas Kanban. Copy it into your agent's workspace and fill in the sections.
|
||||
Use this guide when an agent needs to work through Veritas Kanban. Start by
|
||||
choosing the correct integration mode. Managed harnesses and external
|
||||
self-reporting agents have different lifecycle responsibilities.
|
||||
|
||||
---
|
||||
## Choose the integration mode
|
||||
|
||||
| Mode | Use it when | Who owns task lifecycle |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| Managed harness | VK launches Buzz Agent, Grok Build, Codex, Claude Code, GitHub Copilot CLI, Hermes, or OpenClaw | VK and the selected adapter |
|
||||
| External self-reporting agent | A separate process registers itself and calls VK APIs directly | The external agent |
|
||||
| Unmanaged MCP client | An assistant only needs typed VK tools and is not being launched as the task runner | The MCP client and its operator |
|
||||
|
||||
Do not combine the managed and self-reporting paths. A managed run must not
|
||||
register itself, send heartbeats, call start/complete endpoints, emit duplicate
|
||||
telemetry, or run `vk begin`/`vk done`. VK already created the attempt and owns
|
||||
its terminal state.
|
||||
|
||||
## Shared protocol for managed harnesses
|
||||
|
||||
Every managed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, and
|
||||
OpenClaw run follows this contract:
|
||||
|
||||
1. Treat the Veritas task envelope as the authority for the objective,
|
||||
acceptance criteria, constraints, worktree, side effects, commit policy,
|
||||
expected outputs, verification gates, and completion evidence.
|
||||
2. Read repository instructions available in the assigned worktree, including
|
||||
`AGENTS.md` and any harness-specific supplement that the adapter exposes.
|
||||
3. Work only in the assigned worktree. Preserve files that existed at launch
|
||||
unless the task explicitly authorizes changing them.
|
||||
4. Use only the tools and MCP servers in the run-scoped catalog. Prefer the
|
||||
provided VK tools over ad hoc HTTP calls.
|
||||
5. Never copy credentials into commands, prompts, files, comments, logs, or
|
||||
final output. Use only the brokered references and provider boot
|
||||
authentication supplied by VK.
|
||||
6. Record durable findings through an available task comment or artifact tool
|
||||
when they affect later work. If no such tool is available, include the
|
||||
finding in the final response.
|
||||
7. Run the smallest verification that proves the requested change. Do not run
|
||||
a full repository suite unless the task or release gate requires it.
|
||||
8. Return a concise final response with the outcome, files or artifacts
|
||||
changed, checks run, remaining risks, and blockers. The harness converts its
|
||||
native terminal result into VK completion evidence.
|
||||
|
||||
### Copy-ready project instruction
|
||||
|
||||
Add this block to a repository's canonical `AGENTS.md` when VK manages its
|
||||
agents:
|
||||
|
||||
```md
|
||||
## Veritas Kanban managed-run protocol
|
||||
|
||||
When Veritas Kanban launches this work:
|
||||
|
||||
1. Treat the supplied task envelope as authoritative.
|
||||
2. Work only in the assigned worktree and obey its commit policy.
|
||||
3. Use only the run-scoped tools and credentials supplied by Veritas.
|
||||
4. Do not register, heartbeat, start, complete, or emit telemetry manually.
|
||||
5. Do not call `vk begin` or `vk done`; Veritas already owns the attempt.
|
||||
6. Run focused verification that matches the requested change.
|
||||
7. Return the outcome, changed files or artifacts, checks, risks, and blockers
|
||||
through the harness's normal final response.
|
||||
```
|
||||
|
||||
## How each managed harness receives VK context
|
||||
|
||||
| Harness | VK transport | Agent-facing behavior |
|
||||
| ------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Buzz Agent | ACP v1 stdio | Receives the immutable task envelope and selected run tools through the ACP session. Session load/resume is unavailable. |
|
||||
| Grok Build | ACP v1 stdio | Receives the immutable task envelope and selected catalog in a dedicated `grok agent --no-leader ... stdio` process. |
|
||||
| GitHub Copilot CLI | ACP v1 stdio | Receives the immutable task envelope and selected catalog with remote, plugins, custom instructions, and experimental features disabled. |
|
||||
| OpenAI Codex CLI/SDK/app-server | Native process, SDK, or app-server stream | Receives the task envelope plus supported run-scoped MCP configuration. The adapter owns terminal capture. |
|
||||
| Claude Code | Supervised bare-mode stream | Receives the task envelope and an explicit run-scoped MCP configuration. It does not inherit arbitrary local plugins, hooks, or MCP servers. |
|
||||
| Hermes | Supervised one-shot process | Reads `AGENTS.md` from the assigned worktree and returns scripted stdout. Resume is unavailable. |
|
||||
| OpenClaw | Gateway tool invocation | Receives the task request through the configured gateway and reports through the attempt-bound callback. |
|
||||
|
||||
The current support tier is determined by runtime evidence, not this table.
|
||||
Before enabling a profile, the operator must run:
|
||||
|
||||
```bash
|
||||
vk doctor --json
|
||||
```
|
||||
|
||||
See [Agent Providers](AGENT-PROVIDERS.md) and
|
||||
[Harness Compatibility](HARNESS-COMPATIBILITY.md) for tested versions,
|
||||
capabilities, authentication, sandbox behavior, limitations, and recovery.
|
||||
|
||||
## Run-scoped VK tools
|
||||
|
||||
Managed adapters receive only the catalog selected for that attempt:
|
||||
|
||||
- A tool with an `allow` decision may be injected natively when the transport
|
||||
can enforce the exact catalog.
|
||||
- An approval-backed or credential-bound tool is available only through the
|
||||
system-owned `veritas-run` bridge.
|
||||
- Tools absent from the catalog are not authorized.
|
||||
- If a required tool is missing or rejected, report the blocker. Do not install
|
||||
another MCP server, inherit a global configuration, or fall back to raw
|
||||
credentials.
|
||||
|
||||
Managed agents do not need a separate global VK MCP configuration. The adapter
|
||||
injects the permitted run-scoped view when supported. The global MCP setup
|
||||
below is for unmanaged clients.
|
||||
|
||||
## External self-reporting agents
|
||||
|
||||
Use this path only when VK does not launch the process through a built-in
|
||||
adapter.
|
||||
|
||||
### Reusable `AGENTS.md` template
|
||||
|
||||
```md
|
||||
# AGENTS.md
|
||||
|
||||
## Identity
|
||||
|
||||
- **Agent ID:** `my-agent-id` _(unique, lowercase, dashes)_
|
||||
- **Name:** My Agent
|
||||
- **Model:** anthropic/claude-sonnet-4-5
|
||||
- **Provider:** anthropic
|
||||
- **Version:** 1.0.0
|
||||
- Agent ID: `my-agent-id`
|
||||
- Name: My Agent
|
||||
- Model: provider/model
|
||||
- Provider: external
|
||||
- Version: 1.0.0
|
||||
|
||||
## Capabilities
|
||||
|
||||
List what this agent can do. Used for task routing.
|
||||
- `code`: Write, review, and refactor code
|
||||
- `research`: Research and analysis
|
||||
- `review`: Code review and validation
|
||||
- `documentation`: Write and maintain documentation
|
||||
|
||||
- `code` — Write, review, and refactor code
|
||||
- `research` — Deep web research and analysis
|
||||
- `review` — Code review and PR feedback
|
||||
- `deploy` — CI/CD and deployment operations
|
||||
- `documentation` — Write and maintain docs
|
||||
## Veritas Kanban external-agent protocol
|
||||
|
||||
## Registration
|
||||
1. Register on startup and send a heartbeat every two to three minutes.
|
||||
2. Treat VK as the source of truth for task and attempt state.
|
||||
3. Start work through the documented task API and retain the returned attempt
|
||||
and runtime-manifest identities.
|
||||
4. Work only inside the assigned repository/worktree boundary.
|
||||
5. Emit progress, token, and completion data once. Do not duplicate events.
|
||||
6. On completion, report the final outcome, verification evidence, and the
|
||||
exact attempt/runtime identities expected by VK.
|
||||
7. Deregister cleanly on shutdown.
|
||||
```
|
||||
|
||||
On startup, register with Veritas Kanban:
|
||||
### Authentication
|
||||
|
||||
Set the VK endpoint and an agent-role API key outside source control:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/agents/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
export VK_API_URL=http://localhost:3001
|
||||
export VK_API_KEY=your-agent-api-key
|
||||
```
|
||||
|
||||
Every protected example below assumes:
|
||||
|
||||
```bash
|
||||
-H "X-API-Key: ${VK_API_KEY}"
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
```bash
|
||||
curl -X POST "${VK_API_URL}/api/agents/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${VK_API_KEY}" \
|
||||
--data '{
|
||||
"id": "my-agent-id",
|
||||
"name": "My Agent",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"provider": "anthropic",
|
||||
"model": "provider/model",
|
||||
"provider": "external",
|
||||
"capabilities": [
|
||||
{"name": "code", "description": "Write and review code"},
|
||||
{"name": "research", "description": "Deep research and analysis"}
|
||||
{"name": "research", "description": "Research and analysis"}
|
||||
],
|
||||
"version": "1.0.0"
|
||||
}'
|
||||
```
|
||||
|
||||
## Heartbeat
|
||||
|
||||
Send periodic heartbeats to stay registered (every 2-3 minutes):
|
||||
### Heartbeat
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/agents/register/my-agent-id/heartbeat \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
curl -X POST "${VK_API_URL}/api/agents/register/my-agent-id/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${VK_API_KEY}" \
|
||||
--data '{
|
||||
"status": "busy",
|
||||
"currentTaskId": "task_20260205_abc123",
|
||||
"currentTaskTitle": "Implement feature X"
|
||||
}'
|
||||
```
|
||||
|
||||
### Status Values
|
||||
Valid states are `online`, `busy`, `idle`, and `offline`. VK marks an agent
|
||||
offline after its configured heartbeat timeout.
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | ------------------------------------------------------ |
|
||||
| `online` | Agent is available for work |
|
||||
| `busy` | Agent is actively working on a task |
|
||||
| `idle` | Agent is running but not doing anything |
|
||||
| `offline` | Agent hasn't sent a heartbeat in 5+ minutes (auto-set) |
|
||||
### Task lifecycle
|
||||
|
||||
## Deregistration
|
||||
For an external agent:
|
||||
|
||||
On shutdown, deregister cleanly:
|
||||
1. Send a busy heartbeat with `currentTaskId`.
|
||||
2. Call `POST /api/agents/:taskId/start`.
|
||||
3. Retain the returned `attemptId` and provider-runtime manifest digest.
|
||||
4. Report token usage to `POST /api/agents/:taskId/tokens` with the active
|
||||
attempt.
|
||||
5. Complete through `POST /api/agents/:taskId/complete` with the same attempt
|
||||
and runtime-manifest digest.
|
||||
6. Send an idle heartbeat and clear the current task.
|
||||
|
||||
Use the exact request and response schemas in
|
||||
[API Reference](API-REFERENCE.md). Do not guess field names from these summary
|
||||
steps.
|
||||
|
||||
### Telemetry
|
||||
|
||||
Managed runs project `run.started`, `run.completed`, and provider-reported token
|
||||
events automatically. External agents must emit them once:
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3001/api/agents/register/my-agent-id
|
||||
curl -X POST "${VK_API_URL}/api/telemetry/events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${VK_API_KEY}" \
|
||||
--data '{"type":"run.started","taskId":"<TASK_ID>","agent":"my-agent-id"}'
|
||||
|
||||
curl -X POST "${VK_API_URL}/api/telemetry/events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${VK_API_KEY}" \
|
||||
--data '{"type":"run.completed","taskId":"<TASK_ID>","agent":"my-agent-id","durationMs":<MS>,"success":true}'
|
||||
```
|
||||
|
||||
## Discovery
|
||||
Do not manually emit these events for a managed provider run.
|
||||
|
||||
### List all agents
|
||||
### Deregistration
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/agents/register
|
||||
curl -X DELETE "${VK_API_URL}/api/agents/register/my-agent-id" \
|
||||
-H "X-API-Key: ${VK_API_KEY}"
|
||||
```
|
||||
|
||||
### Filter by status
|
||||
## Unmanaged MCP clients
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/agents/register?status=online
|
||||
Use the VK MCP server when an assistant needs typed board tools but is not the
|
||||
managed task runner.
|
||||
|
||||
Generic stdio configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"veritas-kanban": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/veritas-kanban/mcp/dist/index.js"],
|
||||
"env": {
|
||||
"VK_API_URL": "http://localhost:3001",
|
||||
"VK_API_KEY": "your-agent-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Filter by capability
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/agents/register?capability=code
|
||||
```
|
||||
|
||||
### Find agents for a capability
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/agents/register/capabilities/research
|
||||
```
|
||||
|
||||
### Registry stats
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/agents/register/stats
|
||||
```
|
||||
|
||||
## Task Integration
|
||||
|
||||
When picking up a task:
|
||||
|
||||
1. Send heartbeat with `status: "busy"` and `currentTaskId`
|
||||
2. Use existing task APIs: `POST /api/agents/:taskId/start`
|
||||
3. Report tokens: `POST /api/agents/:taskId/tokens`
|
||||
4. Complete: `POST /api/agents/:taskId/complete`
|
||||
5. Send heartbeat with `status: "idle"` and clear task
|
||||
|
||||
## OpenAI Codex Notes
|
||||
|
||||
When this template is used by Codex, add these project-specific instructions:
|
||||
|
||||
```md
|
||||
## Veritas Kanban Protocol
|
||||
|
||||
When working on Veritas Kanban tasks:
|
||||
|
||||
1. Treat Veritas Kanban as the source of truth for task state.
|
||||
2. Before implementation, inspect the task, acceptance criteria, worktree, and related docs.
|
||||
3. Move the task to `in-progress` and ensure an attempt is tracked.
|
||||
4. Keep notes in task comments or progress files when findings affect future work.
|
||||
5. Run relevant tests/checks before completion.
|
||||
6. Report final summary, files changed, tests run, risks, and follow-ups.
|
||||
7. For code changes, request cross-model review before final completion.
|
||||
8. Use the Veritas MCP server when available instead of ad hoc HTTP calls.
|
||||
|
||||
For OpenAI product/API questions, use the OpenAI developer documentation MCP server first.
|
||||
```
|
||||
|
||||
Recommended Codex MCP setup:
|
||||
Codex CLI configuration:
|
||||
|
||||
```bash
|
||||
codex mcp add veritas-kanban \
|
||||
--env VK_API_URL=http://localhost:3001 \
|
||||
--env VK_API_KEY=your-agent-api-key \
|
||||
-- node /absolute/path/to/veritas-kanban/mcp/dist/index.js
|
||||
|
||||
codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp
|
||||
```
|
||||
|
||||
See [SOP-codex-integration.md](SOP-codex-integration.md) for the full Codex workflow.
|
||||
Restart the MCP client after changing its configuration. Verify read and write
|
||||
permissions with the smoke procedure in the
|
||||
[MCP Server Guide](mcp/README.md).
|
||||
|
||||
## Telemetry Emission (MANDATORY)
|
||||
## References
|
||||
|
||||
The dashboard's **Success Rate**, **Token Usage**, and **Average Run Duration** graphs require `run.*` telemetry events. These are **NOT auto-captured** — your agent must emit them.
|
||||
|
||||
> Add these to your `AGENTS.md`. Without them, the dashboard graphs go blank.
|
||||
|
||||
### When Starting a Task
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/telemetry/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"run.started","taskId":"<TASK_ID>","agent":"my-agent-id"}'
|
||||
```
|
||||
|
||||
### When Completing a Task
|
||||
|
||||
```bash
|
||||
# Report run result (success or failure)
|
||||
curl -X POST http://localhost:3001/api/telemetry/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"run.completed","taskId":"<TASK_ID>","agent":"my-agent-id","durationMs":<MS>,"success":true}'
|
||||
|
||||
# Report token usage (powers Token Usage + Monthly Budget)
|
||||
curl -X POST http://localhost:3001/api/telemetry/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"run.tokens","taskId":"<TASK_ID>","agent":"my-agent-id","model":"<MODEL>","inputTokens":<N>,"outputTokens":<N>,"cacheTokens":<N>,"cost":<N>}'
|
||||
```
|
||||
|
||||
### On Failure
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/telemetry/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"run.completed","taskId":"<TASK_ID>","agent":"my-agent-id","durationMs":<MS>,"success":false}'
|
||||
```
|
||||
|
||||
### What's auto-captured vs. manual
|
||||
|
||||
| Event Type | Auto? | Source |
|
||||
| --------------------- | ----- | --------------- |
|
||||
| `task.created` | ✅ | VK server |
|
||||
| `task.status_changed` | ✅ | VK server |
|
||||
| `task.archived` | ✅ | VK server |
|
||||
| `run.started` | ❌ | Agent must POST |
|
||||
| `run.completed` | ❌ | Agent must POST |
|
||||
| `run.tokens` | ❌ | Agent must POST |
|
||||
|
||||
## Multi-Agent Coordination
|
||||
|
||||
The registry enables agents to discover each other:
|
||||
|
||||
```bash
|
||||
# Find who can help with code review
|
||||
curl http://localhost:3001/api/agents/register/capabilities/review
|
||||
|
||||
# Check if a specific agent is available
|
||||
curl http://localhost:3001/api/agents/register/codex-1
|
||||
```
|
||||
|
||||
This is the foundation for multi-agent task assignment (#29) and @mention notifications (#30).
|
||||
- [Agent Providers](AGENT-PROVIDERS.md)
|
||||
- [Harness Compatibility](HARNESS-COMPATIBILITY.md)
|
||||
- [Buzz Integration](BUZZ-INTEGRATION.md)
|
||||
- [MCP Server Guide](mcp/README.md)
|
||||
- [CLI Guide](CLI-GUIDE.md)
|
||||
- [API Reference](API-REFERENCE.md)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,13 +13,14 @@
|
|||
3. [Workflow Runs](#workflow-runs)
|
||||
4. [Gate Operations](#gate-operations)
|
||||
5. [Tool Policies](#tool-policies)
|
||||
6. [Task Dependencies](#task-dependencies) (NEW — v3.3)
|
||||
7. [Crash-Recovery Checkpointing](#crash-recovery-checkpointing) (NEW — v3.3)
|
||||
8. [Observational Memory](#observational-memory) (NEW — v3.3)
|
||||
9. [Agent Filter](#agent-filter) (NEW — v3.3)
|
||||
10. [WebSocket Events](#websocket-events)
|
||||
11. [TypeScript Interfaces](#typescript-interfaces)
|
||||
12. [Error Responses](#error-responses)
|
||||
6. [Sandbox Policies](#sandbox-policies)
|
||||
7. [Task Dependencies](#task-dependencies) (NEW — v3.3)
|
||||
8. [Crash-Recovery Checkpointing](#crash-recovery-checkpointing) (NEW — v3.3)
|
||||
9. [Observational Memory](#observational-memory) (NEW — v3.3)
|
||||
10. [Agent Filter](#agent-filter) (NEW — v3.3)
|
||||
11. [WebSocket Events](#websocket-events)
|
||||
12. [TypeScript Interfaces](#typescript-interfaces)
|
||||
13. [Error Responses](#error-responses)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -63,21 +64,13 @@ curl http://localhost:3001/api/workflows
|
|||
"id": "feature-dev",
|
||||
"name": "Feature Development Workflow",
|
||||
"version": 2,
|
||||
"description": "End-to-end feature development pipeline",
|
||||
"agentCount": 4,
|
||||
"stepCount": 7,
|
||||
"createdAt": "2026-02-09T12:00:00Z",
|
||||
"updatedAt": "2026-02-09T14:30:00Z"
|
||||
"description": "End-to-end feature development pipeline"
|
||||
},
|
||||
{
|
||||
"id": "security-audit",
|
||||
"name": "Security Audit & Remediation",
|
||||
"version": 1,
|
||||
"description": "Scan, prioritize, and fix security issues",
|
||||
"agentCount": 3,
|
||||
"stepCount": 5,
|
||||
"createdAt": "2026-02-09T10:00:00Z",
|
||||
"updatedAt": "2026-02-09T10:00:00Z"
|
||||
"description": "Scan, prioritize, and fix security issues"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
|
@ -172,6 +165,49 @@ X-Resource-Revision: 2
|
|||
|
||||
---
|
||||
|
||||
### GET /api/workflows/:id/access
|
||||
|
||||
Resolve workflow-specific actions and provenance for the current identity. Clients use this server-owned result to distinguish editable user workflows from built-in or shared read-only definitions.
|
||||
|
||||
**Request**:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/workflows/feature-dev/access
|
||||
```
|
||||
|
||||
**Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "feature-dev",
|
||||
"canView": true,
|
||||
"canEdit": false,
|
||||
"canExecute": true,
|
||||
"canDuplicate": true,
|
||||
"readOnlyReason": "Built-in workflows are read-only. Duplicate this workflow to customize it.",
|
||||
"provenance": {
|
||||
"kind": "built-in",
|
||||
"owner": "system",
|
||||
"createdBy": "system",
|
||||
"updatedBy": "system",
|
||||
"createdAt": "2026-02-09T12:00:00Z",
|
||||
"updatedAt": "2026-02-09T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`provenance.kind` is `built-in`, `user-owned`, or `shared`. `canEdit` and `canExecute` combine the authenticated request permissions with the workflow ACL decision. `canDuplicate` reflects whether the authenticated request may create workflows. A read-only response includes an actionable reason suitable for the workflow browser.
|
||||
|
||||
**Status Codes**:
|
||||
|
||||
- `200 OK` — Access and provenance resolved
|
||||
- `403 Forbidden` — No view permission
|
||||
- `404 Not Found` — Workflow not found
|
||||
|
||||
**Permissions**: Requires `workflow:read` and workflow-level `view` permission.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/workflows
|
||||
|
||||
Create a new workflow.
|
||||
|
|
@ -492,6 +528,21 @@ curl -X POST http://localhost:3001/api/workflows/feature-dev/runs \
|
|||
clientMode?: "local" | "remote" | "cloud"; // Optional: workflow skill gate mode
|
||||
[key: string]: unknown;
|
||||
};
|
||||
budget?: {
|
||||
enabled?: boolean;
|
||||
limits?: {
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
toolCalls?: number;
|
||||
runtimeSeconds?: number;
|
||||
idleRuntimeSeconds?: number;
|
||||
retries?: number;
|
||||
fanOut?: number;
|
||||
};
|
||||
softThresholdPercent?: number;
|
||||
hardAction?: "pause" | "require-approval" | "downgrade" | "cancel";
|
||||
downgradeModel?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -501,6 +552,19 @@ The run context includes the resulting `skillAudit` summary when execution is
|
|||
allowed. Workflows with `pipeline` metadata also include `context.pipeline`,
|
||||
which rolls subagent role status and time/token telemetry into the run record.
|
||||
|
||||
Run budgets are merged with workspace, workflow, and workflow-agent defaults
|
||||
using the strictest positive limit. Soft thresholds write `budget-policy`
|
||||
governance traces. Hard thresholds pause/block, require approval, downgrade the
|
||||
model route, or cancel according to the effective policy.
|
||||
|
||||
The workflow root reserves durable admission capacity before the run becomes
|
||||
active. Every provider-backed step then obtains a child reservation against
|
||||
its resolved provider and selected host before its attempt becomes running.
|
||||
The response exposes the root binding and the latest step decision without
|
||||
including prompts or credentials. Use `/api/admission?workflowRunId=<run-id>`
|
||||
or `vk admission list --workflow-run <run-id>` for current lease and limiting
|
||||
policy details.
|
||||
|
||||
**Response**:
|
||||
|
||||
```json
|
||||
|
|
@ -1203,6 +1267,59 @@ curl -X POST http://localhost:3001/api/tool-policies/planner/validate \
|
|||
|
||||
---
|
||||
|
||||
## Sandbox Policies
|
||||
|
||||
Workflow agents can set `sandboxPresetId` to select a sandbox policy preset for
|
||||
that role. The workflow executor dry-runs the preset against the selected
|
||||
provider before launching the step. Required unsupported controls block the
|
||||
step before execution and write a `sandbox-policy` governance trace; advisory
|
||||
unsupported controls continue with warnings.
|
||||
|
||||
Use `/api/sandbox-policies/validate` to preflight a workflow agent's preset in
|
||||
the authoring UI or custom automation. Public preflights must include a
|
||||
`providerRuntimeManifestDigest` currently registered by a connected agent host;
|
||||
the API rejects stale, disconnected, unknown, or provider-mismatched manifests.
|
||||
Presets can also be assigned visually in the workflow authoring panel.
|
||||
|
||||
Sandbox policies are complementary to tool policies:
|
||||
|
||||
- Tool policies decide which Veritas/OpenClaw tools a workflow role may use.
|
||||
- Sandbox policies decide what the underlying provider process may access at
|
||||
launch time: filesystem paths, network egress, environment variables, and
|
||||
credentials.
|
||||
|
||||
---
|
||||
|
||||
## Budget Policies
|
||||
|
||||
Workflow definitions can set `config.budget` for workflow-wide defaults and
|
||||
`agents[].budget` for stricter role-specific caps. Launch callers can also pass
|
||||
a stricter `budget` override to `POST /api/workflows/:id/runs`.
|
||||
|
||||
Supported budget limits:
|
||||
|
||||
- `totalTokens`, `inputTokens`, and `outputTokens`
|
||||
- `costUsd`
|
||||
- `toolCalls`
|
||||
- `runtimeSeconds` and `idleRuntimeSeconds`
|
||||
- `retries`
|
||||
- `fanOut`
|
||||
|
||||
Budget evaluation is policy enforcement, not dashboard-only analytics. Soft
|
||||
thresholds create visible warnings and `budget-policy` governance traces. Hard
|
||||
thresholds enforce the configured action:
|
||||
|
||||
- `pause` or `require-approval` blocks the workflow run for operator review.
|
||||
- `downgrade` records a routed decision and applies `downgradeModel` to Codex
|
||||
workflow steps.
|
||||
- `cancel` fails the run immediately.
|
||||
|
||||
The run record includes `budget.usage`, `budget.thresholdEvents`, `budget.traceIds`,
|
||||
and `budget.modelOverride` so run detail, timelines, and completion packets can
|
||||
show exactly what happened.
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies
|
||||
|
||||
### GET /api/tasks/:id/dependencies
|
||||
|
|
@ -1896,6 +2013,7 @@ export interface WorkflowConfig {
|
|||
fresh_session_default?: boolean;
|
||||
progress_file?: string;
|
||||
telemetry_tags?: string[];
|
||||
budget?: AgentBudgetPolicy;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1906,6 +2024,8 @@ export interface WorkflowAgent {
|
|||
id: string;
|
||||
name: string;
|
||||
role: string; // maps to tool policy
|
||||
sandboxPresetId?: string; // maps to a sandbox policy preset
|
||||
budget?: AgentBudgetPolicy; // stricter workflow-agent budget
|
||||
model?: string; // default model for this agent
|
||||
description: string;
|
||||
tools?: string[]; // tool restrictions (overrides role policy)
|
||||
|
|
@ -2021,15 +2141,18 @@ export interface ParallelSubStep {
|
|||
|
||||
```typescript
|
||||
export type WorkflowRunStatus = 'pending' | 'running' | 'blocked' | 'completed' | 'failed';
|
||||
export type WorkflowAdmissionState = 'waiting' | 'dispatching' | 'active' | 'terminal';
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: string; // run_<timestamp>_<nanoid>
|
||||
workflowId: string;
|
||||
workflowVersion: number;
|
||||
taskId?: string; // optional task association
|
||||
admission?: WorkflowRootAdmissionBinding; // durable root reservation or queue identity
|
||||
status: WorkflowRunStatus;
|
||||
currentStep?: string; // current step ID
|
||||
context: Record<string, unknown>; // shared context across steps
|
||||
budget?: AgentBudgetState; // effective budget, usage, threshold events, traces
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
lastCheckpoint?: string; // last state persistence timestamp
|
||||
|
|
@ -2054,6 +2177,7 @@ export interface StepRun {
|
|||
retries: number;
|
||||
output?: string; // path to output file
|
||||
error?: string;
|
||||
admission?: WorkflowStepAdmissionBinding; // latest executable attempt or queue decision
|
||||
|
||||
// Loop-specific state
|
||||
loopState?: {
|
||||
|
|
@ -2065,6 +2189,12 @@ export interface StepRun {
|
|||
}
|
||||
```
|
||||
|
||||
When root or step capacity is temporarily unavailable, the corresponding
|
||||
admission binding has `state: "waiting"` and a durable `queueEntryId`. The run
|
||||
and step remain `pending`; provider execution is not marked active. A claimed
|
||||
entry briefly uses `dispatching` while Veritas transfers durable ownership to
|
||||
workflow recovery, then becomes `active` before provider execution.
|
||||
|
||||
### ToolPolicy
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ Codify what works (and what burns us) when running Veritas Kanban with humans +
|
|||
|
||||
## Do This
|
||||
|
||||
1. **Always track time**
|
||||
- Start timers with `vk begin` the moment you pick up a task.
|
||||
1. **Track time through the correct lifecycle owner**
|
||||
- Humans and external agents start timers with `vk begin` when they pick up a task.
|
||||
- Managed harness runs let VK and the adapter own timing automatically.
|
||||
- If you forgot, add a manual entry with reason. Time data fuels estimation and billing.
|
||||
|
||||
2. **Use subtasks as living checklists**
|
||||
|
|
@ -26,8 +27,9 @@ Codify what works (and what burns us) when running Veritas Kanban with humans +
|
|||
6. **Update SOP files after every lesson**
|
||||
- Mistake → update AGENTS.md/CLAUDE.md + Lessons Learned field.
|
||||
|
||||
7. **Respect cross-model review**
|
||||
- Treat it like CI. No code ships without the opposite model’s signoff.
|
||||
7. **Respect configured review requirements**
|
||||
- Run independent or cross-model review only when the task, governance
|
||||
policy, issue owner, or release owner requires it.
|
||||
|
||||
8. **Mirror important artifacts to Brain/knowledge base**
|
||||
- Use `scripts/brain-write.sh` or equivalent so humans can find deliverables later.
|
||||
|
|
@ -52,7 +54,8 @@ Codify what works (and what burns us) when running Veritas Kanban with humans +
|
|||
- “Implement feature + write docs + shoot video” belongs in separate tasks.
|
||||
|
||||
4. **Auto-piloting agents without supervision**
|
||||
- Always read summaries, review diffs, enforce cross-model review.
|
||||
- Read summaries, inspect diffs, and enforce the review policy selected for
|
||||
the task.
|
||||
|
||||
5. **Letting prompts drift**
|
||||
- Keep prompt registry updated or agents will regress.
|
||||
|
|
|
|||
471
docs/BUZZ-INTEGRATION.md
Normal file
471
docs/BUZZ-INTEGRATION.md
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
# Buzz Communication Adapter
|
||||
|
||||
Veritas Kanban can map a Buzz community channel to Squad Chat and move signed
|
||||
root messages and replies in both directions. The integration uses Buzz's
|
||||
native Nostr HTTP and WebSocket contracts. It does not spawn `buzz`,
|
||||
`buzz-acp`, or `buzz-agent` for delivery.
|
||||
|
||||
Buzz is a communication adapter, not an `AgentProvider`. It does not create a
|
||||
Veritas task, start an ACP agent, synchronize DMs or forums, or read Buzz
|
||||
Desktop's internal state. An operator may separately materialize selected
|
||||
public Buzz persona and team definitions as disabled Veritas profiles and
|
||||
roster members. Definition import is data-only and never starts a process.
|
||||
|
||||
Task execution is a separate seam. A disabled-by-default `buzz-agent`
|
||||
configuration uses provider `acp-stdio` and the generic ACP client. It never
|
||||
turns relay delivery into task completion and never launches `buzz-acp`.
|
||||
Selected task tools are exposed only through the provider-neutral
|
||||
`veritas-run` bridge, with an opaque task/attempt/catalog/manifest binding;
|
||||
native server credentials and the global Veritas MCP inventory are not passed
|
||||
to Buzz.
|
||||
See [Buzz Agent ACP](AGENT-PROVIDERS.md#buzz-agent-acp).
|
||||
|
||||
## Supported contract
|
||||
|
||||
The adapter is fixture-pinned to:
|
||||
|
||||
- Buzz release `0.4.24`
|
||||
- Buzz commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`
|
||||
- Veritas probe revision `1`
|
||||
- Relay software identity `https://github.com/block/buzz`
|
||||
- Required NIPs `11`, `29`, and `42`
|
||||
- Optional enforced relay membership advertised as NIP `43`
|
||||
|
||||
The initial event projection is:
|
||||
|
||||
| Buzz surface | Veritas behavior |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Kind `9` root message | Creates one Squad Chat message in the mapped target. |
|
||||
| Kind `9` reply | Creates one threaded Squad Chat reply using Buzz root/reply tags. |
|
||||
| Veritas root | Signs and publishes one kind `9` event with the mapped `h` channel tag. |
|
||||
| Veritas reply | Publishes a direct or nested reply with the exact Buzz root/reply markers. |
|
||||
| Kind `40003` edit | Records bounded audit metadata. Existing Squad Chat text is unchanged. |
|
||||
| Kind `9005` or NIP-09 kind `5` delete | Records bounded deletion metadata. Local content is not removed. |
|
||||
| Unknown or malformed kind | Ignores it with a redacted delivery audit entry. |
|
||||
| Reactions, files, canvas, forums, DMs | Not projected. |
|
||||
|
||||
An unknown Buzz version is `unsupported`. Veritas may still read public NIP-11
|
||||
metadata, but it will not connect the worker or send messages until the pinned
|
||||
compatibility contract passes.
|
||||
|
||||
## Identity and least privilege
|
||||
|
||||
Use a dedicated Buzz/Nostr identity. Add that public identity only to the
|
||||
community and channels that Veritas must bridge. Veritas does not need channel
|
||||
creation, moderation, desktop storage, or broad community administration.
|
||||
|
||||
Keep the private key in the Veritas server environment and store only its
|
||||
environment-variable reference:
|
||||
|
||||
```dotenv
|
||||
BUZZ_PRIVATE_KEY=<set outside source control>
|
||||
BUZZ_AUTH_TAG=<optional NIP-OA owner attestation>
|
||||
```
|
||||
|
||||
The signing key may be 64-character private-key hex or `nsec`. It must match
|
||||
the configured 64-character public-key hex. `BUZZ_AUTH_TAG` is needed only
|
||||
when an agent identity receives membership through a NIP-OA owner.
|
||||
|
||||
Never put an `nsec`, private-key hex, auth tag, token, authorization header, or
|
||||
raw signed event in a Settings field, API response, task, log, screenshot, or
|
||||
support packet.
|
||||
|
||||
## Configure and map a channel
|
||||
|
||||
In **Settings -> Notifications -> Buzz Connection**, configure:
|
||||
|
||||
- Relay HTTP URL, such as `https://community.example.com`
|
||||
- Optional matching WebSocket URL; Veritas derives it when omitted
|
||||
- Expected community host and optional non-default port
|
||||
- Buzz channel UUID to map to Squad Chat
|
||||
- Public-key hex
|
||||
- `env:BUZZ_PRIVATE_KEY`
|
||||
- Optional `env:BUZZ_AUTH_TAG`
|
||||
- Explicit localhost/private-network allowances when required
|
||||
- Optional `buzz`, `buzz-acp`, or `buzz-agent` executable for version
|
||||
diagnostics
|
||||
|
||||
HTTP and WebSocket endpoints must have the same host, port, path, and TLS
|
||||
posture. Credentials, query strings, and fragments are rejected. A configured
|
||||
path and non-default port are preserved because Buzz binds the community to
|
||||
the request authority.
|
||||
|
||||
The Settings save writes the reference-only adapter first, then writes the
|
||||
Squad Chat channel mapping. Changing a channel disables the old mapping before
|
||||
enabling the new one. Conflicting enabled mappings for the same target are
|
||||
rejected.
|
||||
|
||||
## API setup
|
||||
|
||||
`settings:write` is required to configure, map, send, reconcile, disable, or
|
||||
disconnect. `settings:read` can read adapters, mappings, health, and delivery
|
||||
history.
|
||||
|
||||
Configure the connection:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3001/api/integrations/communication/adapters/buzz-default \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"kind": "buzz",
|
||||
"displayName": "Buzz",
|
||||
"enabled": true,
|
||||
"relayHttpUrl": "https://community.example.com",
|
||||
"expectedCommunity": "community.example.com",
|
||||
"publicKey": "<64-hex-public-key>",
|
||||
"credentialRef": "env:BUZZ_PRIVATE_KEY",
|
||||
"authTagRef": "env:BUZZ_AUTH_TAG"
|
||||
}'
|
||||
```
|
||||
|
||||
Map one Buzz channel to Squad Chat:
|
||||
|
||||
```bash
|
||||
CHANNEL_ID=123e4567-e89b-42d3-a456-426614174000
|
||||
|
||||
curl -X PUT \
|
||||
"http://localhost:3001/api/integrations/communication/adapters/buzz-default/buzz/channels/${CHANNEL_ID}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"target": { "kind": "squad" },
|
||||
"enabled": true,
|
||||
"actor": "operator"
|
||||
}'
|
||||
```
|
||||
|
||||
Run the compatibility probe:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
http://localhost:3001/api/integrations/communication/adapters/buzz-default/health \
|
||||
-H 'X-API-Key: <veritas-api-key>'
|
||||
|
||||
vk doctor --json
|
||||
```
|
||||
|
||||
`POST .../buzz-default/test` runs the same read-only probe. It never sends a
|
||||
message.
|
||||
|
||||
## Import persona and team definitions
|
||||
|
||||
In **Settings -> Agents -> Buzz Persona and Team Definitions**, an operator can
|
||||
list, preview, and explicitly import public Buzz definition heads:
|
||||
|
||||
- kind `30175` persona definitions keyed by `(author, kind, d tag)`;
|
||||
- kind `30176` team definitions keyed the same way; and
|
||||
- the deterministic NIP-33 head with the greatest `created_at`, using the
|
||||
lowest event ID to break a tie.
|
||||
|
||||
The importer reuses the signed, DNS-pinned Buzz `/query` transport and requires
|
||||
current healthy compatibility evidence. Every candidate has bounded Nostr
|
||||
shape, tags, content, JSON depth, arrays, strings, and batch size. Its
|
||||
signature is reconstructed and verified before it can appear in Settings.
|
||||
Invalid envelopes contribute only to a rejected count. A signature-valid
|
||||
current head with rejected content appears as a non-importable coordinate and
|
||||
field-level validation reason, so Veritas never silently falls back to an older
|
||||
definition. Unsafe source values are not echoed into the UI or logs.
|
||||
|
||||
Preview classifies each field before mutation:
|
||||
|
||||
| Buzz definition field | Import behavior |
|
||||
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Persona `display_name` | Source-owned profile display name. |
|
||||
| Persona `system_prompt` | Source-owned profile prompt when present. |
|
||||
| Persona `avatar_url` | Validated public metadata only. Veritas does not fetch it. |
|
||||
| Persona `runtime`, `model`, `provider` | Source preferences only, never runtime evidence or active provider configuration. |
|
||||
| Persona `name_pool` | Bounded source metadata only. |
|
||||
| Persona reserved response fields | Source-only. Veritas does not apply them. |
|
||||
| Team `name`, `description` | Source-owned roster fields for create or refresh. |
|
||||
| Team `persona_ids` | Same-author persona slugs resolved to linked profiles and disabled roster members. |
|
||||
| Unknown fields | Ignored with a field-level forward-compatibility explanation. |
|
||||
| Secrets, environment, commands, paths, managed process state, MCP, hooks, skills, engrams | Rejected. |
|
||||
|
||||
The available actions are:
|
||||
|
||||
- `create`: use the deterministic `buzz-<slug>` profile ID or
|
||||
`buzz-team-<slug>` roster ID;
|
||||
- `link`: attach provenance to an explicitly selected existing profile or
|
||||
roster without replacing local fields;
|
||||
- `refresh`: replace only the saved source-owned fields after a new preview;
|
||||
and
|
||||
- `skip`: record no local mutation.
|
||||
|
||||
Create, link, and refresh require collision-free preview. A preview returns an
|
||||
optimistic local revision and exact source event ID. Import rejects a changed
|
||||
local target or replaced source, so the operator must review the current diff
|
||||
instead of overwriting concurrent edits. Native profile/roster fields remain
|
||||
authoritative; refresh preserves local-only fields and existing routing rules.
|
||||
|
||||
New persona profiles, new rosters, and imported roster members are disabled.
|
||||
Import never launches, enables, routes, installs, fetches, or writes back to
|
||||
Buzz. Removing or replacing a Buzz definition changes its linked-source status
|
||||
to `missing` or `changed`; it does not delete the materialized local object.
|
||||
|
||||
Definition API:
|
||||
|
||||
```text
|
||||
GET /api/integrations/communication/adapters/:adapterId/buzz/definitions
|
||||
GET /api/integrations/communication/adapters/:adapterId/buzz/definitions/links
|
||||
POST /api/integrations/communication/adapters/:adapterId/buzz/definitions/preview
|
||||
POST /api/integrations/communication/adapters/:adapterId/buzz/definitions/import
|
||||
```
|
||||
|
||||
Reads and preview require `settings:read`. Import requires `settings:write`.
|
||||
There is no continuous synchronization and no Buzz write-back endpoint.
|
||||
|
||||
## Trigger a Veritas workflow
|
||||
|
||||
A `buzz-workflow-trigger/v1` rule can bind one active channel mapping to one
|
||||
Veritas workflow. The first supported event is a root kind `9`
|
||||
`message.posted`. Replies, edits, deletes, reactions, adapter-originated
|
||||
echoes, disabled rules, and predicate mismatches do not launch a run.
|
||||
|
||||
Create a rule with the mapping ID returned by the channel-mapping API:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
http://localhost:3001/api/integrations/communication/adapters/buzz-default/buzz/workflow-triggers \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"mappingId": "buzz_map_example",
|
||||
"workflowId": "triage-external-request",
|
||||
"contentIncludes": "help"
|
||||
}'
|
||||
```
|
||||
|
||||
An exact 64-character author public key may be supplied as `authorPubkey`.
|
||||
Content matching is a bounded, case-insensitive substring check. There is no
|
||||
regex, expression, code, shell, or arbitrary Nostr-kind filter.
|
||||
|
||||
Rule creation requires `settings:write` and execute permission on the
|
||||
destination workflow. The mapping alone does not grant workflow or task
|
||||
mutation rights.
|
||||
|
||||
Veritas persists the accepted causal key
|
||||
`buzz:{community}:{eventId}:{ruleId}` before dispatch through the
|
||||
provider-neutral `workflow.pre-external-trigger` hook. Workflow context
|
||||
retains the community, channel, event, author, mapping, rule, and sanitized
|
||||
message. A replay returns the existing run. After restart, Veritas searches
|
||||
the destination workflow's run context for the causal key before launching
|
||||
another run.
|
||||
|
||||
List or disable rules and inspect bounded disposition history:
|
||||
|
||||
```text
|
||||
GET /api/integrations/communication/adapters/:adapterId/buzz/workflow-triggers
|
||||
POST /api/integrations/communication/adapters/:adapterId/buzz/workflow-triggers/:ruleId/disable
|
||||
GET /api/integrations/communication/adapters/:adapterId/buzz/workflow-trigger-audits
|
||||
```
|
||||
|
||||
Disabling a rule retains its prior audits and linked workflow runs.
|
||||
|
||||
## Send roots and replies
|
||||
|
||||
Send a root and associate it with a local Squad Chat message:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
http://localhost:3001/api/integrations/communication/adapters/buzz-default/send \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"target": {
|
||||
"kind": "squad",
|
||||
"squadMessageId": "msg_local_root"
|
||||
},
|
||||
"message": "Root message from Veritas",
|
||||
"actor": "VERITAS"
|
||||
}'
|
||||
```
|
||||
|
||||
Send a reply by identifying both the new local message and the local parent:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
http://localhost:3001/api/integrations/communication/adapters/buzz-default/send \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-Key: <veritas-api-key>' \
|
||||
--data '{
|
||||
"target": {
|
||||
"kind": "squad",
|
||||
"squadMessageId": "msg_local_reply"
|
||||
},
|
||||
"replyToSquadMessageId": "msg_local_root",
|
||||
"message": "Reply from Veritas",
|
||||
"actor": "VERITAS"
|
||||
}'
|
||||
```
|
||||
|
||||
The parent must already have a durable Buzz event mapping. A missing parent is
|
||||
blocked instead of publishing a detached root.
|
||||
|
||||
Each outbound event includes the mapped `h` tag, a `client=veritas-kanban`
|
||||
marker, and a stable `veritas-id` delivery marker. Veritas persists the signed
|
||||
event and event ID before submitting it to `/events`.
|
||||
|
||||
## Inbound subscription and replay
|
||||
|
||||
One supervised WebSocket worker runs per enabled, compatible Buzz connection.
|
||||
It:
|
||||
|
||||
1. resolves and pins the configured relay address through the outbound network
|
||||
policy;
|
||||
2. answers the NIP-42 challenge with kind `22242` and the optional NIP-OA auth
|
||||
tag;
|
||||
3. subscribes only to enabled mapped channel UUIDs and kinds `9`, `40003`,
|
||||
`9005`, and `5`;
|
||||
4. resumes from the persisted cursor with a five-second overlap;
|
||||
5. verifies each Nostr event signature, channel, kind, timestamp, and size;
|
||||
6. projects and audits the Squad Chat message; and
|
||||
7. commits the total-order cursor `(created_at, event_id)` only after the
|
||||
projection and audit are durable.
|
||||
|
||||
Deduplication uses `(community, event_id)`, never timestamp alone. Squad Chat
|
||||
uses the deterministic local ID `msg_buzz_<event-id>`, so a crash after the
|
||||
chat write but before adapter-state persistence replays safely. The original
|
||||
Buzz author public key, source timestamp, event kind, channel, community,
|
||||
event ID, and `buzz://message` link remain attached as external metadata.
|
||||
|
||||
An out-of-order reply is persisted but does not advance the cursor past its
|
||||
missing root. When the root arrives, queued replies are replayed in
|
||||
`(created_at, event_id)` order. A root that never arrives remains bounded
|
||||
pending state rather than becoming a detached Squad Chat message.
|
||||
|
||||
Adapter-originated event IDs are retained and ignored when echoed by the
|
||||
subscription, preventing a reply loop.
|
||||
|
||||
## Ambiguous delivery recovery
|
||||
|
||||
Network failure after a write can leave delivery status unknown. Veritas does
|
||||
not blindly retry:
|
||||
|
||||
1. the delivery remains visible as `delivery_unknown`;
|
||||
2. `POST .../buzz-default/poll` queries `/query` by the signed event ID;
|
||||
3. if the event exists, the original delivery becomes `success`;
|
||||
4. if the relay definitively reports absence, Veritas resubmits the exact
|
||||
persisted signed event; and
|
||||
5. if the query is inconclusive, the delivery remains unresolved.
|
||||
|
||||
Before query or resubmission, the persisted event is re-verified against its
|
||||
signature, configured public identity, mapped community/channel, and event ID.
|
||||
A corrupted record is failed and never retried.
|
||||
|
||||
## Health and audit
|
||||
|
||||
Health separates:
|
||||
|
||||
- compatibility and authorization checks;
|
||||
- relay transport connection;
|
||||
- active subscription state;
|
||||
- mapped-channel count;
|
||||
- reconnect attempts and last connection;
|
||||
- last inbound event;
|
||||
- cursor lag;
|
||||
- last send time and status; and
|
||||
- the latest redacted worker error.
|
||||
|
||||
Compatibility can be healthy while runtime status is `degraded`, such as when
|
||||
no channel is mapped or the subscription is still connecting. `canSend`
|
||||
requires an enabled adapter, current healthy compatibility evidence, and at
|
||||
least one mapped channel. `canReceiveReplies` additionally requires an active
|
||||
subscription.
|
||||
|
||||
Delivery history exposes `queued`, `success`, `delivery_unknown`, `replayed`,
|
||||
`ignored`, `failed`, `blocked`, and `skipped`. It retains bounded coordinates
|
||||
and redacted details, not credentials or raw authorization material.
|
||||
|
||||
## Network policy
|
||||
|
||||
Public HTTPS/WSS is the default. Plain HTTP/WS requires an explicit localhost
|
||||
or private-network allowance. Localhost and RFC1918/IPv6 ULA ranges are denied
|
||||
unless their matching setting is enabled. Link-local, cloud metadata, and
|
||||
CGNAT ranges remain blocked. DNS is resolved and pinned, redirects are
|
||||
disabled, payloads are bounded, and requests have fixed timeouts.
|
||||
|
||||
Enable only the narrow network class required by the relay.
|
||||
|
||||
## Credential-free release gate
|
||||
|
||||
Run the composed Buzz gate from the repository root:
|
||||
|
||||
```bash
|
||||
pnpm test:buzz:compatibility
|
||||
```
|
||||
|
||||
This command runs the existing credential-free fixtures for:
|
||||
|
||||
- pinned relay/community/identity compatibility and secret non-retention;
|
||||
- signed root/reply communication, reconnect replay, dedupe, loop prevention,
|
||||
and ambiguous delivery recovery;
|
||||
- `buzz-agent` through generic ACP;
|
||||
- the opaque run-scoped Veritas MCP bridge;
|
||||
- persona/team preview, import, refresh, provenance, and unsafe-source
|
||||
rejection;
|
||||
- the typed root-message workflow trigger and causal replay; and
|
||||
- the machine-readable compatibility record.
|
||||
|
||||
The canonical support record is still
|
||||
`GET /api/config/harness-compatibility`. Its Buzz entry pins release `0.4.24`,
|
||||
commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`, `buzz-agent 0.1.0`,
|
||||
provider probe revision, fixture revision, and every seam fixture path. A
|
||||
provider build, protocol, capability, probe, configuration, or fixture change
|
||||
invalidates prior certification.
|
||||
|
||||
The green aggregate gate means only the following:
|
||||
|
||||
| Capability | Disposition |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| Relay/community/identity diagnostics | Supported at the pinned baseline |
|
||||
| Mapped roots/replies, replay, dedupe, and loop prevention | Supported |
|
||||
| `buzz-agent` through generic ACP | Supported at the pinned ACP contract |
|
||||
| Run-scoped Veritas MCP | Supported through the provider-neutral bridge |
|
||||
| Public persona/team import | Supported as explicit one-way materialization |
|
||||
| One typed root message to a Veritas workflow | Supported |
|
||||
| `buzz-acp` as a Veritas provider | Rejected; it is the inverse Buzz-owned harness |
|
||||
| Buzz workflow definition execution and cross-system approvals | Deferred |
|
||||
| NIP-AE memory sync and automatic NIP-34 task mirroring | Deferred or rejected |
|
||||
| Desktop internals, DMs, forums, canvas, moderation, huddles, and mobile | Not implied by this gate |
|
||||
|
||||
An unknown or changed Buzz build remains unsupported or degraded until the
|
||||
baseline, evidence digest, fixtures, and documentation are explicitly
|
||||
updated.
|
||||
|
||||
### Optional live smoke
|
||||
|
||||
Live smoke is supplemental and must target the exact candidate build with a
|
||||
dedicated least-privilege identity. It is not normal CI:
|
||||
|
||||
1. Run `vk doctor --json` and retain only redacted public build/status fields.
|
||||
2. Publish one Veritas root to a dedicated mapped test channel.
|
||||
3. Reply from Buzz and verify the reply appears in the correct Squad Chat
|
||||
thread, not merely as an HTTP success or ACP `end_turn`.
|
||||
4. Trigger one allowlisted test workflow and verify its causal event/run link.
|
||||
5. Disable the test mapping, rule, and profile.
|
||||
|
||||
Do not upload raw events, private messages, authorization headers, auth tags,
|
||||
private keys, provider keys, or unredacted logs as evidence.
|
||||
|
||||
## Disable, upgrade, and rollback
|
||||
|
||||
Disabling a channel mapping closes and rebuilds the worker without deleting
|
||||
the mapping, cursor, event coordinates, or delivery audit. Disconnecting the
|
||||
adapter closes the worker and disables delivery while retaining reference-only
|
||||
configuration and recovery state.
|
||||
|
||||
Remove environment secrets separately only when retiring the identity.
|
||||
Veritas never removes relay membership, changes a Buzz community, or modifies
|
||||
Buzz Desktop state.
|
||||
|
||||
After a Buzz upgrade, run `vk doctor --json`. A version/build change
|
||||
invalidates prior compatibility evidence and must pass the pinned contract
|
||||
and `pnpm test:buzz:compatibility` before workers, sends, or ACP dispatch
|
||||
resume. A baseline change updates the release/commit constants, matrix
|
||||
evidence digest, fixtures, and this guide together.
|
||||
|
||||
If a candidate fails, keep the prior baseline and report the failing facet.
|
||||
Disable the affected adapter, mapping, profile, or trigger rule without
|
||||
deleting redacted configuration, mappings, cursors, import provenance, trigger
|
||||
audits, or workflow-run evidence.
|
||||
|
|
@ -19,7 +19,11 @@ Comprehensive guide to the `vk` command-line tool for Veritas Kanban.
|
|||
- [Agent Status](#agent-status)
|
||||
- [Project Management](#project-management)
|
||||
- [Agent Commands](#agent-commands)
|
||||
- [Admission Commands](#admission-commands)
|
||||
- [Durable Goal Commands](#durable-goal-commands)
|
||||
- [Automation Commands](#automation-commands)
|
||||
- [Scheduler Commands](#scheduler-commands)
|
||||
- [Queue Monitor Commands](#queue-monitor-commands)
|
||||
- [GitHub Sync](#github-sync)
|
||||
- [Utilities](#utilities)
|
||||
- [Workflow Commands Deep Dive](#workflow-commands-deep-dive)
|
||||
|
|
@ -112,8 +116,9 @@ vk begin task_20260201_abc123
|
|||
3. Updates agent status to `working` (auto-fetches task title)
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | --------------------- |
|
||||
| `--json` | Output result as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -134,8 +139,9 @@ vk done task_20260201_abc123 "Added OAuth2 with Google and GitHub providers"
|
|||
4. Updates agent status to `idle`
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | --------------------- |
|
||||
| `--json` | Output result as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -154,8 +160,9 @@ vk block task_20260201_abc123 "Waiting on API credentials from client"
|
|||
2. Adds a comment with the block reason
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | --------------------- |
|
||||
| `--json` | Output result as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -174,8 +181,9 @@ vk unblock task_20260201_abc123
|
|||
2. Restarts the time tracker
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | --------------------- |
|
||||
| `--json` | Output result as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -200,12 +208,13 @@ vk list --json # JSON output
|
|||
**Aliases:** `ls`
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| ----------- | ---------------------------------------- |
|
||||
| `--status` | Filter by status (todo, in-progress, blocked, done) |
|
||||
| `--type` | Filter by task type |
|
||||
| `--project` | Filter by project name |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
| Flag | Description |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `--status` | Filter by status (todo, in-progress, blocked, done) |
|
||||
| `--type` | Filter by task type |
|
||||
| `--project` | Filter by project name |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -220,8 +229,9 @@ vk show abc123 --json
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | -------------- |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -233,15 +243,18 @@ Create a new task.
|
|||
```bash
|
||||
vk create "Implement OAuth login"
|
||||
vk create "Fix button alignment" --type code --priority high --project my-app
|
||||
vk create "Audit without commits" --commit-policy forbidden
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| ------------ | -------------------------------------------- |
|
||||
| `--type` | Task type (code, research, content, etc.) |
|
||||
| `--priority` | Priority level (low, medium, high) |
|
||||
| `--project` | Project name |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
| Flag | Description |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| `--type` | Task type (code, research, content, etc.) |
|
||||
| `--priority` | Priority level (low, medium, high) |
|
||||
| `--project` | Project name |
|
||||
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -252,17 +265,20 @@ Update task fields.
|
|||
```bash
|
||||
vk update abc123 --status review
|
||||
vk update abc123 --title "New title" --priority high
|
||||
vk update abc123 --commit-policy required
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| ------------ | ------------------------------ |
|
||||
| `--status` | New status |
|
||||
| `--title` | New title |
|
||||
| `--priority` | New priority |
|
||||
| `--type` | New type |
|
||||
| `--project` | New project |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
| Flag | Description |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| `--status` | New status |
|
||||
| `--title` | New title |
|
||||
| `--priority` | New priority |
|
||||
| `--type` | New type |
|
||||
| `--project` | New project |
|
||||
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -300,11 +316,12 @@ vk time entry abc123 1800 "Code review"
|
|||
```
|
||||
|
||||
**Arguments:**
|
||||
| Argument | Description |
|
||||
| ------------- | ----------------------------------- |
|
||||
| `<id>` | Task ID (supports partial matching) |
|
||||
| `<seconds>` | Duration in seconds |
|
||||
| `"description"` | Description of the work done |
|
||||
|
||||
| Argument | Description |
|
||||
| --------------- | ----------------------------------- |
|
||||
| `<id>` | Task ID (supports partial matching) |
|
||||
| `<seconds>` | Duration in seconds |
|
||||
| `"description"` | Description of the work done |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -320,8 +337,9 @@ vk time show abc123 --json
|
|||
**Output includes:** total time, whether a timer is currently running, and individual time entries with descriptions.
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| -------- | ------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| -------- | -------------- |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
|
@ -338,10 +356,11 @@ vk comment abc123 "Completed OAuth integration" --author Veritas
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| ---------- | --------------------------------------- |
|
||||
|
||||
| Flag | Description |
|
||||
| ---------- | ------------------------------- |
|
||||
| `--author` | Author name (default: CLI user) |
|
||||
| `--json` | Output as JSON |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -415,11 +434,12 @@ vk project create "rubicon" --color "#7c3aed" --description "Main product"
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
| Flag | Description |
|
||||
| --------------- | ------------------------ |
|
||||
| `--color` | Project color (hex) |
|
||||
|
||||
| Flag | Description |
|
||||
| --------------- | ------------------- |
|
||||
| `--color` | Project color (hex) |
|
||||
| `--description` | Project description |
|
||||
| `--json` | Output as JSON |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -427,14 +447,304 @@ vk project create "rubicon" --color "#7c3aed" --description "Main product"
|
|||
|
||||
Manage AI agents on code tasks.
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------------- | --------------------------------------------------- |
|
||||
| `vk start <id>` | Start an agent on a code task (`--agent` to choose) |
|
||||
| `vk stop <id>` | Stop a running agent |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s` | Mark agent complete (success) |
|
||||
| `vk agents:complete <id> -f` | Mark agent complete (failure) |
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `vk start <id> [--phase <phase>]` | Start an agent; optionally bind an execution phase |
|
||||
| `vk launch-preview <id> [--phase <phase>]` | Preview effective launch inputs, blockers, and drift |
|
||||
| `vk workspace-trust scan <id>` | Inventory repository-controlled execution configuration |
|
||||
| `vk workspace-trust decide <id> --mode <mode> --inventory <digest> --reason <text>` | Authorize or deny one exact inventory |
|
||||
| `vk workspace-trust revoke <id> --inventory <digest> --reason <text>` | Revoke the current exact-inventory decision |
|
||||
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
|
||||
| `vk agent:recovery <id>` | Inspect the latest retry or fallback decision |
|
||||
| `vk agent:cancel-recovery <id> --attempt <id>` | Cancel the exact pending recovery parent |
|
||||
| `vk agent:phase <id> --attempt <id>` | Read effective launch phase, sources, and transition history |
|
||||
| `vk agent:transition-phase <id> ...` | Apply or request approval for one exact phase transition |
|
||||
| `vk agent:decide-phase-approval <approvalId> ...` | Approve or reject an exact pending phase expansion |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text> [--phase <phase>]` | Resume the exact persisted provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text> [--phase <phase>]` | Start a provider-native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text> [--phase <phase>]` | Fork provider history without mutating its source |
|
||||
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
|
||||
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active provider turn |
|
||||
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
|
||||
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
|
||||
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
|
||||
| `vk acp status --json` | Check ACP server-view API and permission readiness |
|
||||
| `vk acp serve --stdio [--task <id>]` | Expose a Veritas-managed task to an ACP v1 client |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |
|
||||
| `vk agents:complete <id> -f --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (failure) |
|
||||
|
||||
Require one or more capabilities before launch:
|
||||
|
||||
```bash
|
||||
vk start TASK-001 --agent codex \
|
||||
--require-capability tool.mcp output.structured \
|
||||
--commit-policy allowed \
|
||||
--json
|
||||
```
|
||||
|
||||
Preview without dispatching, or compare a new launch with a parent attempt:
|
||||
|
||||
```bash
|
||||
vk launch-preview TASK-001 --agent codex \
|
||||
--phase verify \
|
||||
--parent-attempt attempt_parent \
|
||||
--json
|
||||
vk start TASK-001 --agent codex \
|
||||
--phase verify \
|
||||
--parent-attempt attempt_parent
|
||||
```
|
||||
|
||||
Preview output includes the immutable run-launch digest, redacted command and
|
||||
argument plan, phase evidence and source references, per-field origins,
|
||||
enforcement blockers, and material drift. It applies the same readiness gate
|
||||
and override rules as start. A parent phase binding is material; attempt IDs and
|
||||
probe timestamps alone do not count as material drift.
|
||||
|
||||
`--phase` accepts `explore`, `plan`, `implement`, `verify`, or `publish`.
|
||||
Explicit phases fail closed before attempt mutation when the selected runtime,
|
||||
sandbox, host, or tool policy cannot prove every required dimension. Omitting
|
||||
the flag creates an explicit legacy phase for a new launch. Resume, follow-up,
|
||||
fork, retry, fallback, and provider changes inherit and intersect the exact
|
||||
parent phase, so a descendant cannot widen authority by changing providers or
|
||||
omitting the flag. In the current staged v6.x delivery, use
|
||||
`launch-preview --phase ...` to inspect the evidence; explicit task or workflow
|
||||
starts remain blocked until #1033 supplies command and external-action
|
||||
enforcement.
|
||||
|
||||
Inspect workspace execution trust before launching a newly cloned or changed
|
||||
repository:
|
||||
|
||||
```bash
|
||||
vk workspace-trust scan TASK-001
|
||||
vk workspace-trust decide TASK-001 \
|
||||
--mode restricted \
|
||||
--inventory sha256:... \
|
||||
--reason "Reviewed instructions; keep the run read-only"
|
||||
```
|
||||
|
||||
Decision modes are `trusted`, `restricted`, and `denied`. The exact inventory
|
||||
digest from `scan` is required, and stale content is rejected. Decision and
|
||||
revocation commands require an administrator. `launch-preview` reports the
|
||||
effective trust status and any resulting enforcement blocker.
|
||||
|
||||
`--require-capability <capabilities...>` is additive to the baseline launch,
|
||||
profile, sandbox, and budget requirements. The server returns a structured
|
||||
conflict and the CLI exits non-zero when any capability is unsupported,
|
||||
unknown, missing, or backed by an invalid/failed manifest.
|
||||
|
||||
`--commit-policy <forbidden|allowed|required>` sets the policy for this run.
|
||||
It overrides a task default and the legacy auto-commit setting. Omitting the
|
||||
flag keeps existing tasks compatible: commits are allowed but not required
|
||||
unless a task or legacy setting explicitly requires one.
|
||||
|
||||
Lifecycle commands fail closed from the persisted runtime manifest. Resume
|
||||
requires the exact source worktree; fork permits a compatible worktree at the
|
||||
same repository and base revision. `--fork-turn <id>` selects an optional
|
||||
provider-native history boundary. Unsupported controls preserve the server's
|
||||
reason, and a recorded operator message is never reported as delivered unless
|
||||
the adapter executed a verified native steering operation.
|
||||
|
||||
Use `vk agents:status TASK-001 --json` to inspect the persisted manifest and
|
||||
capability-derived `controls` set. `vk stop` does not infer support from the
|
||||
agent name. It resolves the current `attemptId` from status and includes it in
|
||||
the stop request, so a replacement run fails a delayed stop closed. The CLI
|
||||
preserves the server's reason when `run.stop` is unavailable or the active and
|
||||
persisted manifest digests do not match.
|
||||
|
||||
Automatic recovery is separate from stopping an active provider. Use
|
||||
`vk agent:recovery TASK-001 --json` to inspect its classification, backoff,
|
||||
route, causal parent, manifest evidence, and cumulative budget. A cancellation
|
||||
must include the exact parent attempt:
|
||||
|
||||
```bash
|
||||
vk agent:cancel-recovery TASK-001 --attempt attempt_parent --json
|
||||
```
|
||||
|
||||
The server rejects stale parent IDs, recoveries that already launched, and
|
||||
recoveries that are already terminal.
|
||||
|
||||
Inspect and transition the exact active phase:
|
||||
|
||||
```bash
|
||||
vk agent:phase TASK-001 --attempt attempt_123 --json
|
||||
vk agent:transition-phase TASK-001 \
|
||||
--attempt attempt_123 \
|
||||
--operation move-to-implement \
|
||||
--target-evidence ./implement-evidence.json \
|
||||
--reason "The approved plan is ready to implement." \
|
||||
--json
|
||||
```
|
||||
|
||||
`agent:phase` reports the launch phase even before the first transition. Human
|
||||
output distinguishes parent, agent-profile, sandbox, tool-catalog, and launch
|
||||
policy sources; `--json` returns the server-owned snapshot unchanged.
|
||||
|
||||
The first transition also requires `--from-evidence <file>` and
|
||||
`--manifest <sha256:...>`. Later requests read the current journal record and
|
||||
automatically bind its sequence, evidence digest, and manifest. Narrowing
|
||||
applies immediately. Expansion returns an exact approval; decide it with
|
||||
`vk agent:decide-phase-approval <id> --decision approve`, then retry the same
|
||||
transition with the same `--operation` and `--approval-id`. Emergency expansion
|
||||
requires `--override-until` and `--override-reason`; the authenticated caller
|
||||
must be an administrator and the expiry cannot exceed 24 hours.
|
||||
|
||||
---
|
||||
|
||||
### Admission Commands
|
||||
|
||||
Inspect the capacity reservation that must exist before a provider can start:
|
||||
|
||||
```bash
|
||||
vk admission list
|
||||
vk admission list --state active --provider codex-cli --json
|
||||
vk admission list --workflow-run run_20260725_abc123 --json
|
||||
vk admission list --workflow-run run_20260725_abc123 --workflow-step implement
|
||||
vk admission list --root-reservation admission_0123456789abcdef
|
||||
vk admission list --root-objective objective_0123456789abcdef --json
|
||||
vk admission get admission_0123456789abcdef --json
|
||||
vk admission tree objective_0123456789abcdef
|
||||
vk admission tree objective_0123456789abcdef --limit 25 --json
|
||||
vk admission queue list
|
||||
vk admission queue list --state queued requeued --source workflow --min-age 60000 --json
|
||||
vk admission queue get admission_queue_0123456789abcdef
|
||||
vk admission queue get admission_queue_0123456789abcdef --json
|
||||
vk admission queue cancel admission_queue_0123456789abcdef \
|
||||
--reason "Operator cancelled the queued launch." \
|
||||
--idempotency-key operator-queue-cancel-20260725
|
||||
vk admission cancel-tree objective_0123456789abcdef \
|
||||
--reason "Operator stopped runaway child-agent expansion." \
|
||||
--idempotency-key operator-tree-cancel-20260725
|
||||
vk admission resume-tree objective_0123456789abcdef \
|
||||
--reason "Operator confirmed fan-out pressure cleared." \
|
||||
--idempotency-key operator-tree-resume-20260725
|
||||
```
|
||||
|
||||
`admission list` filters by workspace, task, root task, provider, host, state,
|
||||
workflow run, workflow step, root reservation, root objective, node, parent
|
||||
node, and result limit. Active records
|
||||
show the lease expiry and requested run, process, and estimated-memory
|
||||
capacity. Workflow roots use provider `workflow-control`; executable child
|
||||
steps show their resolved provider, selected host, and root reservation.
|
||||
Released records retain the terminal reason and idempotency identity for
|
||||
operator diagnosis. `admission tree` reports committed and reserved tokens,
|
||||
cost, tool calls, runtime, retries, fan-out, policy availability, and bounded
|
||||
contributors without double counting descendant totals. It also shows durable
|
||||
cancellation or circuit-breaker control state when present. Inspection
|
||||
commands are read-only and require `agent:read`.
|
||||
|
||||
`admission queue list` filters by workspace, root objective, node, launch
|
||||
source, queue state, raw numeric priority, limiting scope, age window, page,
|
||||
and result limit. `admission queue get` shows one entry with position,
|
||||
priority aging, readiness, lease posture, redacted launch identity, limiting
|
||||
policy, conditional start factors, selection evidence, and safe navigation
|
||||
identifiers. Human output labels the snapshot as conditional and never presents
|
||||
an ETA or exact start time. Use `--json` for the complete versioned REST shape.
|
||||
|
||||
`admission queue cancel` stops one queued or leased launch before provider
|
||||
dispatch and releases its reservation. `admission cancel-tree` records
|
||||
cancellation on the root first, drains queued descendants, releases unbound
|
||||
reservations, and asks the local supervisor to interrupt verified running
|
||||
attempts. `admission resume-tree` re-evaluates durable breaker evidence and
|
||||
resumes a paused tree only after every blocking signal clears. All three
|
||||
commands require `admin:manage`, require an operator reason, and accept a stable
|
||||
`--idempotency-key` for safe retries. If omitted, the CLI generates a new key.
|
||||
Use `--json` to inspect the complete control or any verified running attempts
|
||||
that still require reconciliation. A blocked resume returns
|
||||
`EXECUTION_TREE_RESUME_BLOCKED`; do not retry it in a loop without changing the
|
||||
reported pressure. A cancelled root rejects late resume, retry, fallback,
|
||||
workflow-step, and child-agent launches before provider dispatch.
|
||||
|
||||
The Operations admission panel exposes the same controls. Queue rows can cancel
|
||||
one pending launch or its entire tree. Durable tree-control cards show bounded
|
||||
breaker signals and observed descendant/depth evidence, then allow an
|
||||
administrator to resume or cancel the tree with an audited reason.
|
||||
|
||||
---
|
||||
|
||||
### Durable Goal Commands
|
||||
|
||||
Create and control one evidence-gated objective across multiple runs:
|
||||
|
||||
```bash
|
||||
vk goals create \
|
||||
--objective "Deliver the provider migration." \
|
||||
--acceptance "All provider fixtures pass" "Operator docs are current" \
|
||||
--requirement "provider-tests|test|Focused provider fixtures pass." \
|
||||
--requirement "operator-docs|artifact|Operator documentation is reviewed." \
|
||||
--root-task task_0123456789abcdef \
|
||||
--mode automatic \
|
||||
--max-turns 20 \
|
||||
--max-rollovers 2 \
|
||||
--compact-after-tokens 120000 \
|
||||
--require-rollover-approval \
|
||||
--json
|
||||
|
||||
vk goals list --state active blocked awaiting-approval --json
|
||||
vk goals get goal_0123456789abcdef --json
|
||||
|
||||
vk goals transition goal_0123456789abcdef \
|
||||
--revision 3 \
|
||||
--state paused \
|
||||
--reason "Operator paused before external coordination." \
|
||||
--json
|
||||
|
||||
vk goals transition goal_0123456789abcdef \
|
||||
--revision 4 \
|
||||
--state complete \
|
||||
--reason "All configured evidence is verified." \
|
||||
--evidence-json '[{"requirementId":"provider-tests","evidenceId":"ci-30182450098","summary":"Focused provider fixtures passed."},{"requirementId":"operator-docs","evidenceId":"review-20260726","summary":"Operator docs reviewed."}]' \
|
||||
--json
|
||||
|
||||
vk goals link-run goal_0123456789abcdef \
|
||||
--revision 2 \
|
||||
--task task_0123456789abcdef \
|
||||
--attempt attempt_0123456789abcdef \
|
||||
--conversation conversation_0123456789abcdef \
|
||||
--json
|
||||
|
||||
vk goals rollover goal_0123456789abcdef \
|
||||
--revision 6 \
|
||||
--json
|
||||
```
|
||||
|
||||
`goals create` requires exactly one `--root-task` or `--root-workflow`.
|
||||
Completion requirements use `id|kind|description`; supported kinds are
|
||||
`test`, `build`, `artifact`, `operator`, `external`, and `other`. At least one
|
||||
required evidence item must exist, and a transition to `complete` fails until
|
||||
every required item has verified evidence.
|
||||
|
||||
Every mutation requires the current `--revision`. A stale revision returns a
|
||||
conflict instead of overwriting a newer operator or supervisor decision.
|
||||
Blocked transitions use `--blocker-json` for the exact blocker, attempt count,
|
||||
next safe action, and required authority or external state change. The server
|
||||
derives the transition actor, workspace, verification timestamp, and evidence
|
||||
verifier from authenticated context; caller-supplied identity fields are
|
||||
rejected. Use `--json` for stable automation output.
|
||||
|
||||
`goals get --json` includes the deduplicated `usageEvents`, full
|
||||
`continuationChain`, and restart-safe `continuationAttempts`. Automatic goals
|
||||
continue only after the prior completion is durable and required evidence,
|
||||
blockers, turn limits, and aggregate budgets are evaluated. A continuation is
|
||||
persisted as `planned` before it enters the normal admission path, then becomes
|
||||
`dispatched` with its attempt or queue identity. On restart, the same admission
|
||||
idempotency key is reused; an already-created child attempt is linked without a
|
||||
duplicate launch.
|
||||
|
||||
Manual goals pause after an incomplete run. Automatic goals block when the
|
||||
provider has no verified continuation handle and no rollover allowance, or
|
||||
when admission fails. They enter `usage-limited` or `budget-limited` at their
|
||||
configured boundary. A configured `--compact-after-tokens` threshold starts a
|
||||
fresh conversation only while `--max-rollovers` still has capacity. Each
|
||||
rollover persists `kind: "rollover"` before dispatch and carries a bounded
|
||||
goal contract with objective, constraints, acceptance criteria, verified
|
||||
evidence links, remaining requirements, recent state decisions, and aggregate
|
||||
usage. If `--require-rollover-approval` is set, the goal enters
|
||||
`awaiting-approval`; run `goals rollover` with the current revision to approve
|
||||
and dispatch that exact handoff. Resume other limited states explicitly after
|
||||
addressing the reported condition; do not build a second client-side
|
||||
continuation loop.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -468,6 +778,61 @@ Manage automation tasks.
|
|||
|
||||
---
|
||||
|
||||
### Scheduler Commands
|
||||
|
||||
Inspect and control recurring work from the terminal.
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------------- | ------------------------------------------------ |
|
||||
| `vk scheduler list` | List recurring scheduler items and recent events |
|
||||
| `vk scheduler run-due` | Run all due scheduler items |
|
||||
| `vk scheduler run <id>` | Run one scheduler item now |
|
||||
| `vk scheduler pause <id>` | Pause one scheduler item |
|
||||
| `vk scheduler resume <id>` | Resume one scheduler item |
|
||||
| `vk scheduler validate <id>` | Validate one scheduler item |
|
||||
|
||||
Item IDs include a source prefix: `scheduled-deliverable:<id>`, `workflow:<id>`, or `queue-monitor:<id>`.
|
||||
|
||||
---
|
||||
|
||||
### Queue Monitor Commands
|
||||
|
||||
Inspect and run policy-gated GitHub queue intake monitors.
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------------- | ----------------------------------------------- |
|
||||
| `vk queue-monitors list` | List queue monitors, health, and recent events |
|
||||
| `vk queue-monitors run <id>` | Run one monitor now |
|
||||
| `vk queue-monitors explain <id>` | Build a fresh candidate packet without mutation |
|
||||
| `vk queue-monitors health <id>` | Show monitor health and action item state |
|
||||
| `vk queue-monitors pause <id>` | Pause one monitor |
|
||||
| `vk queue-monitors resume <id>` | Resume one monitor |
|
||||
|
||||
Every queue monitor command supports `--json`. `run` requires `workflow:execute`; list, health, and explain require `workflow:read`.
|
||||
|
||||
---
|
||||
|
||||
### SQLite Journal Maintenance
|
||||
|
||||
Preview and schedule safe journal-mode conversion for the configured
|
||||
authoritative database.
|
||||
|
||||
| Command | Permission | Description |
|
||||
| -------------------------------------------- | -------------- | --------------------------------------------------- |
|
||||
| `vk sqlite journal preview --target <mode>` | `backup:write` | Show filesystem, sidecars, ownership, backup, risks |
|
||||
| `vk sqlite journal apply ...` | `admin:manage` | Schedule the confirmed preview for the next restart |
|
||||
| `vk sqlite journal status [operationId]` | `backup:read` | Show operation and policy state |
|
||||
| `vk sqlite journal override revoke --reason` | `admin:manage` | Revoke active compatibility/override policy |
|
||||
|
||||
`apply` requires `--preview-id`, the one-time `--preview-token`, a matching
|
||||
`--confirm`, and `--acknowledge-risks`. It does not convert the live database;
|
||||
restart the server once so bootstrap can run before any SQLite connection opens.
|
||||
All commands support `--json`. `delete` mode also requires explicit single-host
|
||||
environment posture and bounded override metadata; see
|
||||
[Maintenance Center](MAINTENANCE-CENTER.md).
|
||||
|
||||
---
|
||||
|
||||
### GitHub Sync
|
||||
|
||||
Manage GitHub Issues bidirectional sync.
|
||||
|
|
@ -487,10 +852,51 @@ Manage GitHub Issues bidirectional sync.
|
|||
| --------------------- | ------------------------------------------------------------------------------ |
|
||||
| `vk summary` | Project stats: status counts, project progress, high-priority items |
|
||||
| `vk summary standup` | Daily standup summary (`--yesterday`, `--date YYYY-MM-DD`, `--json`, `--text`) |
|
||||
| `vk doctor` | Validate API, routing, executable, and harness support readiness (`--json`) |
|
||||
| `vk notify <message>` | Create a notification (`--type`, `--title`, `--task` options) |
|
||||
| `vk notify:check` | Check for tasks that need notifications |
|
||||
| `vk notify:pending` | Get pending notifications formatted for Teams |
|
||||
|
||||
`vk doctor` reads the same redacted harness support projection shown in
|
||||
Settings. Enabled `degraded` or `unsupported` profiles fail the doctor check;
|
||||
enabled `configured` profiles warn until their installed build has current
|
||||
certification evidence. Use `vk doctor --json` for support-safe automation and
|
||||
diagnostics, including redacted readiness reasons, safe probe commands, and
|
||||
remediation.
|
||||
|
||||
For Claude Code, doctor reports the bounded version, auth-status, and agent
|
||||
discovery probes, plus separate bare-mode authentication readiness. A
|
||||
successful interactive OAuth status is diagnostic only because Veritas
|
||||
launches Claude Code with `--bare`; configure an explicit supported
|
||||
environment credential before enabling the profile.
|
||||
|
||||
For Codex app-server, doctor requires the exact `codex-cli 0.145.0` executable,
|
||||
`codex login status`, the system-owned strict-stdio launch contract, and the
|
||||
current provider build/probe evidence. Custom app-server arguments or version
|
||||
drift degrade the profile and block dispatch. `codex-cli`, `codex-sdk`, and
|
||||
`codex-app-server` are reported as separate profiles with separate capability
|
||||
manifests.
|
||||
|
||||
### Run-scoped Tool Servers
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `vk tool-servers list [--json]` | List registered definitions |
|
||||
| `vk tool-servers get <id> --json` | Read one definition |
|
||||
| `vk tool-servers create <definition.json>` | Create a validated definition |
|
||||
| `vk tool-servers update <id> <definition.json>` | Replace a definition |
|
||||
| `vk tool-servers delete <id>` | Delete a definition |
|
||||
| `vk tool-servers enable <id>` / `disable <id>` | Change launch eligibility |
|
||||
| `vk tool-servers version <id> <version>` | Change version identity and invalidate discovery |
|
||||
| `vk tool-servers discover <id> [--force] [--json]` | Refresh version-bound discovery |
|
||||
| `vk tool-servers catalog <taskId> <attemptId> --json` | Read an immutable run catalog |
|
||||
| `vk tool-servers call <taskId> <attemptId> <serverId> <tool> ...` | Invoke through policy, approval, and event gates |
|
||||
|
||||
Calls require `--arguments '<json-object>'`. Use a stable `--operation-id`
|
||||
when retrying transport failures. If the tool requires approval, the command
|
||||
returns the exact approval identity; retry with `--approval-id` after that
|
||||
request is approved. The `tools` command is an alias for `tool-servers`.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Commands Deep Dive
|
||||
|
|
|
|||
210
docs/CLICK-THROUGH-TUTORIALS.md
Normal file
210
docs/CLICK-THROUGH-TUTORIALS.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# Click-through Tutorials Roadmap
|
||||
|
||||
## For future VERITAS
|
||||
|
||||
This roadmap tracks issue #693: product-native click-through tutorials for Veritas Kanban. The goal is not a one-off onboarding modal. The goal is a reusable guided-tour system that works in the macOS desktop app and web/PWA surfaces, respects product modes, remains keyboard/screen-reader accessible, and can be extended as v5 features grow.
|
||||
|
||||
## Goals
|
||||
|
||||
- Teach the first useful workflow in under five minutes.
|
||||
- Reduce “where do I click next?” friction for new desktop and web users.
|
||||
- Support Board Only, Team/Remote, and Advanced product modes without showing irrelevant agent/admin steps.
|
||||
- Use real UI surfaces instead of screenshots where possible.
|
||||
- Keep tutorials resumable, skippable, resettable, and safe for demo data.
|
||||
- Give support/docs a stable tour ID vocabulary for screenshots, videos, and bug reports.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not block app access behind mandatory training.
|
||||
- Do not build a marketing carousel.
|
||||
- Do not rely on brittle DOM selectors with no product-owned anchor IDs.
|
||||
- Do not teach every feature in one mega-tour.
|
||||
- Do not expose admin/security steps to users who lack permission.
|
||||
|
||||
## Recommended implementation shape
|
||||
|
||||
### 1. Tour registry
|
||||
|
||||
Add a registry that describes tours as data:
|
||||
|
||||
```ts
|
||||
interface GuidedTourDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
productModes: ProductMode[];
|
||||
requiredPermissions?: string[];
|
||||
entryCommand?: string;
|
||||
steps: GuidedTourStep[];
|
||||
}
|
||||
|
||||
interface GuidedTourStep {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
target?: string; // data-tour-id value
|
||||
placement?: 'top' | 'right' | 'bottom' | 'left' | 'center';
|
||||
route?: string;
|
||||
actionHint?: string;
|
||||
completion?: 'next-click' | 'target-click' | 'route-visible' | 'manual';
|
||||
}
|
||||
```
|
||||
|
||||
Use `data-tour-id` anchors on stable product elements instead of CSS selectors. Missing anchors should fail gracefully by showing a centered step with a “Go there” action when possible.
|
||||
|
||||
### 2. Tour runtime
|
||||
|
||||
Add a small guided-tour runtime responsible for:
|
||||
|
||||
- tour state: not started, active, completed, dismissed;
|
||||
- current tour/step persistence in local storage initially;
|
||||
- route-aware step navigation;
|
||||
- focus trap and escape handling;
|
||||
- reduced-motion support;
|
||||
- spotlight/overlay rendering;
|
||||
- analytics event hooks, if enabled;
|
||||
- reset/replay from Help/Command Palette.
|
||||
|
||||
A lightweight in-house runtime is preferable to adopting a heavy tour library unless we need complex positioning. If a library is considered later, evaluate bundle cost, accessibility, keyboard handling, React 19 compatibility, and Mantine integration first.
|
||||
|
||||
### 3. Tour launcher surfaces
|
||||
|
||||
Expose tours from:
|
||||
|
||||
- desktop menu command: Help → Tutorials / Setup & Diagnostics adjacent;
|
||||
- Command Palette: “Start board basics tour,” “Start agent workflow tour,” etc.;
|
||||
- first-run desktop onboarding final step;
|
||||
- empty states: board, templates, agents, workflows;
|
||||
- Help/keyboard shortcut dialog.
|
||||
|
||||
### 4. Product-mode awareness
|
||||
|
||||
Tours should filter by current `features.productMode.selectedMode`:
|
||||
|
||||
| Mode | Default tours |
|
||||
| ----------- | --------------------------------------------------------------- |
|
||||
| Board Only | Board basics, task detail, search/filter, backup/export |
|
||||
| Team/Remote | Board basics, comments/activity, access model, notifications |
|
||||
| Advanced | Board basics, agents, workflows, evidence/timeline, maintenance |
|
||||
|
||||
Existing users default to Advanced, but tutorials should not assume the user wants every advanced surface.
|
||||
|
||||
## Initial tour set
|
||||
|
||||
### Tour 1 — Board basics
|
||||
|
||||
Audience: everyone.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Board columns and task cards.
|
||||
2. Create a task.
|
||||
3. Open task detail.
|
||||
4. Move status.
|
||||
5. Add comment/checklist-style progress.
|
||||
6. Use search/filter.
|
||||
7. Finish with “replay from Help anytime.”
|
||||
|
||||
### Tour 2 — From task to agent work
|
||||
|
||||
Audience: Advanced mode; hidden unless agents are enabled or user has agent permissions.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Open a task.
|
||||
2. Add enough context for an agent.
|
||||
3. Select/run an agent or workflow.
|
||||
4. Watch run timeline/status.
|
||||
5. Review evidence/output.
|
||||
6. Mark done with validation notes.
|
||||
|
||||
### Tour 3 — Desktop first-run safety
|
||||
|
||||
Audience: macOS desktop.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Local server/profile location.
|
||||
2. Recovery key/password reminder.
|
||||
3. Settings → Maintenance health.
|
||||
4. Backup/import/export.
|
||||
5. Update channel/status.
|
||||
|
||||
### Tour 4 — Workflows and templates
|
||||
|
||||
Audience: Advanced mode.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Templates vs workflows.
|
||||
2. Apply a task template.
|
||||
3. Open workflow authoring.
|
||||
4. Run a sample workflow.
|
||||
5. Inspect run/evidence timeline.
|
||||
|
||||
### Tour 5 — Admin/security essentials
|
||||
|
||||
Audience: owner/admin only.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Product modes.
|
||||
2. Agent/provider settings.
|
||||
3. Tool policies and enforcement.
|
||||
4. Shared resources/skills.
|
||||
5. Maintenance center/debug bundle.
|
||||
|
||||
## Accessibility requirements
|
||||
|
||||
- Every tour action must be keyboard reachable.
|
||||
- `Esc` dismisses the current tour with confirmation or an undo snackbar.
|
||||
- Focus returns to the launching control after exit.
|
||||
- Screen readers receive step title, body, position, and target context.
|
||||
- Spotlight/overlay cannot be the only instruction; text must be sufficient.
|
||||
- Respect `prefers-reduced-motion`.
|
||||
- Maintain color contrast in dark and light themes.
|
||||
|
||||
## Persistence and privacy
|
||||
|
||||
Initial persistence can be local-only:
|
||||
|
||||
```json
|
||||
{
|
||||
"guidedTours": {
|
||||
"completed": { "board-basics": "2026-06-08T00:00:00.000Z" },
|
||||
"dismissed": { "agent-workflow": "2026-06-08T00:00:00.000Z" },
|
||||
"active": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not sync tutorial progress to a remote server unless there is a clear multi-device UX need and the user/account model can explain it.
|
||||
|
||||
## Implementation backlog
|
||||
|
||||
1. **Add tour anchor IDs** to board, task card, create task, task detail, search/filter, settings, maintenance, agents, workflows, and command palette surfaces.
|
||||
2. **Create guided-tour registry** with product-mode and permission filters.
|
||||
3. **Create guided-tour runtime/provider** with overlay, focus handling, persistence, and route-aware navigation.
|
||||
4. **Add Command Palette + Help launchers** for available tours.
|
||||
5. **Wire desktop menu command** to open the tutorial launcher.
|
||||
6. **Implement Board basics tour** and tests first.
|
||||
7. **Implement Desktop first-run safety tour** integrated with existing desktop onboarding.
|
||||
8. **Implement Advanced agent/workflow tours** after anchor/runtime stability.
|
||||
9. **Add Playwright or component tests** for launch, next/back, skip, missing anchor fallback, keyboard escape, and product-mode filtering.
|
||||
10. **Update docs/screenshots/video script** once the runtime is stable.
|
||||
|
||||
## Suggested task split
|
||||
|
||||
- `guided-tour-anchors`: add `data-tour-id` anchors and tests around critical surfaces.
|
||||
- `guided-tour-runtime`: provider, overlay, persistence, keyboard/a11y behavior.
|
||||
- `guided-tour-launchers`: Command Palette, Help dialog, desktop menu command.
|
||||
- `tour-board-basics`: first product tour, docs, and tests.
|
||||
- `tour-desktop-safety`: desktop setup/maintenance/update tour.
|
||||
- `tour-agent-workflow`: advanced task-to-agent tour.
|
||||
|
||||
## Acceptance criteria for #693
|
||||
|
||||
- A checked-in roadmap/design doc exists.
|
||||
- The issue has a concrete implementation sequence.
|
||||
- Follow-up issues/tasks can be created without rediscovering product constraints.
|
||||
- The first implementation PR can start with Board basics rather than architecture debate.
|
||||
|
|
@ -8,7 +8,7 @@ Companion docs:
|
|||
|
||||
- [SOP: OpenAI Codex Integration](SOP-codex-integration.md)
|
||||
- [Codex Workflow Examples](EXAMPLES-codex-workflows.md)
|
||||
- [SOP: Cross-Model Code Review](SOP-cross-model-code-review.md)
|
||||
- [Optional Independent Code Review](SOP-cross-model-code-review.md)
|
||||
- [AGENTS.md Template](AGENTS-TEMPLATE.md)
|
||||
|
||||
## Product Goal
|
||||
|
|
@ -34,6 +34,12 @@ Recommended default shape:
|
|||
codex exec --cwd <task-worktree> --sandbox workspace-write --json <prompt>
|
||||
```
|
||||
|
||||
When a Codex agent profile or workflow agent has `sandboxPresetId`, Veritas
|
||||
dry-runs the sandbox policy before launching the CLI process. The resulting
|
||||
filesystem mode, network posture, and environment passthrough are applied where
|
||||
the provider supports them. Required unsupported controls fail closed before
|
||||
execution and write a redacted `sandbox-policy` governance trace.
|
||||
|
||||
### Codex SDK Provider
|
||||
|
||||
The SDK path supports long-lived local Codex threads, resumable session IDs, and richer follow-up workflows. Veritas starts `@openai/codex-sdk` threads in the task worktree, streams SDK events into attempt logs, emits token telemetry, and persists the SDK `threadId` on the active/completed attempt.
|
||||
|
|
@ -49,6 +55,11 @@ const thread = codex.startThread({
|
|||
});
|
||||
```
|
||||
|
||||
The SDK provider also honors sandbox policy presets. This is the preferred path
|
||||
for deny-by-default network policies because the SDK capability check supports
|
||||
network disablement, whereas the CLI path is limited to the controls exposed by
|
||||
`codex exec`.
|
||||
|
||||
### Codex Cloud Delegation
|
||||
|
||||
Cloud delegation starts through GitHub-native workflows: Veritas can create or comment on GitHub issues/PRs with scoped `@codex` prompts, then sync links and outcomes back into the task. If official cloud APIs become available, they can be added behind the same provider boundary.
|
||||
|
|
@ -69,7 +80,17 @@ POST /api/github/codex/delegate
|
|||
|
||||
## Architecture Direction
|
||||
|
||||
v4.3 uses an explicit provider adapter contract inside the agent service. `codex` agents resolve to the local Codex CLI runner, `codex-sdk` agents resolve to the SDK session runner, `codex-cloud` uses GitHub-native delegation, and existing agents keep the OpenClaw request-file behavior.
|
||||
Executable task providers resolve through the dedicated
|
||||
`AgentProviderAdapterRegistry`. The registry owns exact provider selection,
|
||||
task-envelope rendering, runtime probing, run-event mapping, start dispatch,
|
||||
and stop semantics. `ClawdbotAgentService` supplies shared admission,
|
||||
supervision, journaling, budget, and completion effects without selecting an
|
||||
implicit fallback adapter.
|
||||
|
||||
`codex` agents resolve to the local Codex CLI runner, `codex-sdk` agents resolve
|
||||
to the SDK session runner, and `codex-cloud` uses GitHub-native delegation.
|
||||
OpenClaw task dispatch uses the gateway `sessions_spawn` path and persists the
|
||||
returned session identity on the active attempt.
|
||||
|
||||
Expected long-term provider capabilities:
|
||||
|
||||
|
|
@ -82,7 +103,7 @@ Expected long-term provider capabilities:
|
|||
- optional `review`
|
||||
- optional `cloudDelegate`
|
||||
|
||||
The provider abstraction should support:
|
||||
The provider adapter interface supports:
|
||||
|
||||
- OpenClaw compatibility through an OpenClaw provider adapter.
|
||||
- Codex CLI through a local process provider.
|
||||
|
|
@ -114,6 +135,7 @@ Workflow agent steps execute through provider-aware step handling. Codex-backed
|
|||
- step output files
|
||||
- retry and failure handling through the workflow runner
|
||||
- tool-policy hints in prompt/config where direct enforcement is unavailable
|
||||
- per-agent `sandboxPresetId` launch guardrails
|
||||
|
||||
Workflow agents can opt into Codex with provider metadata:
|
||||
|
||||
|
|
@ -155,7 +177,10 @@ Settings exposes Codex readiness through a dedicated health check:
|
|||
GET /api/settings/codex/health
|
||||
```
|
||||
|
||||
The response reports Codex CLI install/version/auth state, SDK import availability, Codex agent profile readiness, enabled Codex profiles, and recommendations.
|
||||
The response reports Codex CLI install/version/auth state, the installed Codex
|
||||
SDK version and import availability, Codex agent profile readiness, enabled
|
||||
Codex profiles, and recommendations. Veritas Kanban currently validates its
|
||||
stream adapter against `@openai/codex-sdk` 0.144.1 event contracts.
|
||||
|
||||
## MCP And Project Instructions
|
||||
|
||||
|
|
|
|||
180
docs/CREDENTIAL-BROKER.md
Normal file
180
docs/CREDENTIAL-BROKER.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Credential Broker
|
||||
|
||||
Veritas Kanban keeps task credentials out of provider processes by separating
|
||||
metadata, leases, and value resolution.
|
||||
|
||||
The v6 foundation includes:
|
||||
|
||||
- `credential-definition/v1` metadata records;
|
||||
- `credential-lease/v1` run-bound leases;
|
||||
- opaque provider-safe handles persisted only as hashes;
|
||||
- exact task, attempt, immutable launch-manifest, scope, and action binding;
|
||||
- TTL, maximum-use, refresh, revocation, expiry, and reconciliation state;
|
||||
- metadata-only audit events; and
|
||||
- a controlled in-process callback that is the only API allowed to receive the
|
||||
resolved value.
|
||||
- `run-launch-credential-plan/v1` evidence that classifies provider boot
|
||||
authentication, task integration references, and high-risk compatibility
|
||||
passthrough without storing values.
|
||||
|
||||
The tool control plane compiles value-free credential boundary evidence into a
|
||||
run catalog and consumes leases only inside mediated tool calls. A handle in a
|
||||
prompt or provider environment is not itself a credential boundary:
|
||||
credential-bound native server injection remains omitted. A system-owned
|
||||
`veritas-run` MCP bridge receives only an opaque, in-memory authority bound to
|
||||
the exact task, attempt, catalog, launch manifest, and catalog/call methods.
|
||||
The provider never receives the task credential value.
|
||||
|
||||
## Credential classes
|
||||
|
||||
Treat these as separate:
|
||||
|
||||
1. **Harness boot authentication** starts the provider itself, such as native
|
||||
login state or a model-provider key required by the provider executable.
|
||||
2. **Task integration credentials** authorize a bounded HTTP, tool, or MCP
|
||||
action during a run. These are the broker target.
|
||||
3. **Compatibility passthrough** explicitly places a raw value in the provider
|
||||
environment. It is high risk and never counts as brokered.
|
||||
|
||||
Every newly compiled run launch manifest records those classes, delivery
|
||||
posture, boundary posture, and provider-runtime evidence in a deterministic
|
||||
credential plan. Known native provider authentication keys are classified as
|
||||
boot authentication. Unknown credential-like environment keys are classified
|
||||
as high-risk compatibility passthrough. Broker definition IDs are classified
|
||||
as task integration credentials and block launch while their controlled
|
||||
boundary is unavailable. Probe timestamp-only refreshes do not create material
|
||||
drift, but provider build, classification, mode, reference, delivery, boundary,
|
||||
or risk changes do.
|
||||
|
||||
## Register a definition
|
||||
|
||||
Definitions are admin-only:
|
||||
|
||||
```http
|
||||
POST /api/credential-broker
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-token",
|
||||
"name": "GitHub token",
|
||||
"enabled": true,
|
||||
"source": {
|
||||
"kind": "environment",
|
||||
"reference": "VK_GITHUB_TOKEN"
|
||||
},
|
||||
"scope": {
|
||||
"dispatchTypes": ["http"],
|
||||
"hosts": ["api.github.com"],
|
||||
"tools": [],
|
||||
"destinations": ["https://api.github.com"],
|
||||
"methods": ["GET"],
|
||||
"actions": ["issues.read"],
|
||||
"pathPrefixes": ["/repos/"]
|
||||
},
|
||||
"lease": {
|
||||
"ttlSeconds": 60,
|
||||
"maxUses": 1,
|
||||
"renewable": false
|
||||
},
|
||||
"approval": "not-required"
|
||||
}
|
||||
```
|
||||
|
||||
`source.reference` is an environment key name or external manager path, never a
|
||||
value. The initial local source can resolve an environment key at the internal
|
||||
dispatch boundary. Production deployments should use a future external
|
||||
secret-manager adapter instead of treating process environment as a vault.
|
||||
|
||||
Metadata that resembles an embedded token, authorization header, or
|
||||
`name=value` credential is rejected.
|
||||
|
||||
## Lease lifecycle
|
||||
|
||||
The internal broker issues a lease only when:
|
||||
|
||||
- the task has the requested active attempt;
|
||||
- the immutable run launch manifest digest matches;
|
||||
- that manifest declares the definition reference;
|
||||
- the definition is enabled;
|
||||
- the exact action is inside every configured scope; and
|
||||
- any required approval verifier authorizes the same action fingerprint.
|
||||
|
||||
The raw handle is returned once to the internal caller. Persistence contains
|
||||
only its SHA-256 hash. The lease records definition, scope, action, run, expiry,
|
||||
use-count, SHA-256 fingerprints of caller-supplied operation IDs, and optional
|
||||
approval fingerprints. Raw operation IDs are never persisted or audited.
|
||||
|
||||
Use is compare-and-set serialized. A consumer must present the same task,
|
||||
attempt, launch manifest, handle, canonical action, and a unique operation ID.
|
||||
A changed host, destination, method, path, tool, action, or arguments digest
|
||||
fails closed. Reusing an operation ID is rejected instead of replaying a
|
||||
credential-bearing action or refresh. Source resolution happens only after the
|
||||
use is claimed. Missing sources and callbacks that return, throw, or conceal
|
||||
credential material in accessors, custom objects, cycles, or excessively deep
|
||||
results produce credential-free errors. Binary callback results are rejected
|
||||
entirely because backing buffers can expose bytes outside a visible slice or
|
||||
mutate after inspection.
|
||||
|
||||
Completion, failure, interruption, and cancellation revoke the matching run
|
||||
leases after the terminal result is durably persisted. Duplicate terminal
|
||||
delivery retries revocation, so a transient broker failure can heal without
|
||||
rewriting the terminal result. Startup and one-minute periodic reconciliation:
|
||||
|
||||
- expires leases past their TTL;
|
||||
- blocks leases whose source is unavailable;
|
||||
- revokes leases whose definition changed or was disabled;
|
||||
- revokes leases whose run or manifest binding disappeared; and
|
||||
- leaves only currently valid active leases usable.
|
||||
|
||||
Manifest declarations and sandbox `brokerRefs` are exact definition IDs. Values
|
||||
such as `github-token=...` are invalid and never normalize to a valid reference.
|
||||
The broker state writer publishes complete owner-token lock metadata atomically
|
||||
and never auto-deletes an existing lock. Dead, malformed, or otherwise
|
||||
unverifiable ownership fails closed because portable filesystems cannot compare
|
||||
and unlink ownership atomically. After confirming that no Veritas process owns
|
||||
the state file, an operator may remove the adjacent `.lock` file and let
|
||||
reconciliation retry.
|
||||
|
||||
## Audit record
|
||||
|
||||
The broker stores bounded metadata events for definition changes, issue, use,
|
||||
denial, refresh, revoke, expiry, and reconciliation. Events contain IDs,
|
||||
fingerprints, decision reasons, and timestamps. They do not contain headers,
|
||||
request bodies, URLs with query strings, credential values, or callback errors.
|
||||
|
||||
The causal run-event journal will later project this metadata into the unified
|
||||
run stream. Broker correctness does not depend on that projection.
|
||||
|
||||
## Fail-closed provider posture
|
||||
|
||||
A required brokered sandbox preset needs `credential.broker: supported`.
|
||||
`advisory`, externally delegated, unknown, stale, or bypassable evidence is
|
||||
treated as unsupported and blocks launch.
|
||||
|
||||
Current executable providers classify their launch credentials consistently,
|
||||
but classification alone does not make them broker-capable. Controlled HTTP
|
||||
consumption belongs to the run-scoped egress gateway; controlled MCP/tool
|
||||
consumption belongs to the tool-server control plane. A task reference is
|
||||
reported as brokered only when an immutable run catalog contains the matching
|
||||
credential-definition and scope digests. Uncovered references still block
|
||||
launch. During a mediated call, the server-owned manifest digest and exact
|
||||
catalog action issue a lease; its source value exists only inside the
|
||||
downstream dispatch callback.
|
||||
|
||||
## Rotation and revocation
|
||||
|
||||
- Change the external source value without changing the definition to rotate
|
||||
future resolution.
|
||||
- Update or disable a definition to revoke its active leases.
|
||||
- Revoke a lease explicitly for an operator stop.
|
||||
- Do not delete a definition while an active lease exists; disable it first.
|
||||
- Never fall back from a failed brokered lease to raw environment passthrough.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable brokered sandbox selection and revoke active leases. Keep metadata-only
|
||||
definitions for operator review or delete them after no active leases remain.
|
||||
Rollback never copies a value into app configuration and never weakens a
|
||||
required preset into implicit passthrough.
|
||||
|
|
@ -30,22 +30,23 @@ customer data is processed.
|
|||
|
||||
## Data Classes
|
||||
|
||||
| Data class | Primary tables | Default retention | Export/delete policy |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Workspace identity and membership | `workspaces`, `users`, `workspace_memberships`, `workspace_invitations` | Until admin removal or workspace archival | Scoped exports include selected workspace, memberships, invitations, and member users only. |
|
||||
| Tasks and task metadata | `tasks` | Active/backlog until archived or deleted; archived until cleanup | Preview linked artifacts before deletion. Included in full and scoped exports. |
|
||||
| Task comments and discussion | `tasks` JSON | Follows parent task | Included with task export. Cleanup follows parent task unless comment-level delete is used. |
|
||||
| Uploads and attachment metadata | `task_attachments` | While linked to a task unless cleanup-eligible | Preview parent task, path, size, MIME type, and orphan status before deletion. |
|
||||
| Work products and versions | `work_products`, `work_product_versions`, `task_deliverables` | Until task/workspace cleanup or version retention | Preview source task/run, status, version count, and redaction state. |
|
||||
| Telemetry and metrics events | `telemetry_events` | 30 days by default | Purge only by explicit range/workspace preview. |
|
||||
| Workflow definitions, runs, and scheduled snapshots | `workflow_definitions`, `workflow_acls`, `workflow_audit_events`, `workflow_runs`, `scheduled_deliverables`, `scheduled_deliverable_runs` | Definitions until deleted; runs/snapshots by admin retention | Never delete active runs or current scheduled state silently. |
|
||||
| Notifications and subscriptions | `notifications`, `thread_subscriptions` | Until read/dismissed history cleanup | Preview delivered/read state, target category, source task, and age. |
|
||||
| Chat and squad messages | `chat_sessions`, `chat_messages`, `squad_messages` | Until session/workspace cleanup | Preview session, task link, message count, agents, and age. |
|
||||
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `governance_decision_traces`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
|
||||
| Device sessions and API tokens | excluded from backup table exports | Until expiration or revocation | Revoke before deletion. Never print secret values or hashes. |
|
||||
| Configuration and registries | `app_config_documents`, `managed_list_items`, `task_templates`, `prompt_templates`, `prompt_versions`, `prompt_usage` | Until changed or deleted | Full exports include them. Scoped exports exclude global app config. |
|
||||
| Backups, imports, and exports | filesystem bundles and manifests | Until admin removes files | Every export includes a manifest with data classes, row counts, and redaction state. |
|
||||
| Diagnostics and debug bundles | filesystem bundle output | Generated on demand | Redacted by default and includes an included-category manifest. |
|
||||
| Data class | Primary tables | Default retention | Export/delete policy |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workspace identity and membership | `workspaces`, `users`, `workspace_memberships`, `workspace_invitations` | Until admin removal or workspace archival | Scoped exports include selected workspace, memberships, invitations, and member users only. |
|
||||
| Tasks and task metadata | `tasks` | Active/backlog until archived or deleted; archived until cleanup | Preview linked artifacts before deletion. Included in full and scoped exports. |
|
||||
| Task comments and discussion | `tasks` JSON | Follows parent task | Included with task export. Cleanup follows parent task unless comment-level delete is used. |
|
||||
| Uploads and attachment metadata | `task_attachments` | While linked to a task unless cleanup-eligible | Preview parent task, path, size, MIME type, and orphan status before deletion. |
|
||||
| Work products and versions | `work_products`, `work_product_versions`, `task_deliverables` | Until task/workspace cleanup or version retention | Preview source task/run, status, version count, and redaction state. |
|
||||
| Telemetry and metrics events | `telemetry_events` | 30 days by default | Purge only by explicit range/workspace preview. |
|
||||
| Workflow definitions, runs, and scheduled snapshots | `workflow_definitions`, `workflow_acls`, `workflow_audit_events`, `workflow_runs`, `scheduled_deliverables`, `scheduled_deliverable_runs` | Definitions until deleted; runs/snapshots by admin retention | Never delete active runs or current scheduled state silently. |
|
||||
| Notifications and subscriptions | `notifications`, `thread_subscriptions` | Until read/dismissed history cleanup | Preview delivered/read state, target category, source task, and age. |
|
||||
| Chat and squad messages | `chat_sessions`, `chat_messages`, `squad_messages` | Until session/workspace cleanup | Preview session, task link, message count, agents, and age. |
|
||||
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `governance_decision_traces`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
|
||||
| Device sessions and API tokens | excluded from backup table exports | Until expiration or revocation | Revoke before deletion. Never print secret values or hashes. |
|
||||
| Credential broker metadata | `.veritas-kanban/credential-broker/state.json` | Definitions until deleted; lease records retained; audit ring capped at 5,000 events | Metadata only. Excluded from workspace SQLite exports until repository migration. Never export raw handles or credential values. |
|
||||
| Configuration and registries | `app_config_documents`, `managed_list_items`, `task_templates`, `prompt_templates`, `prompt_versions`, `prompt_usage` | Until changed or deleted | Full exports include them. Scoped exports exclude global app config. |
|
||||
| Backups, imports, and exports | filesystem bundles and manifests | Until admin removes files | Every export includes a manifest with data classes, row counts, and redaction state. |
|
||||
| Diagnostics and debug bundles | filesystem bundle output | Generated on demand | Redacted by default and includes an included-category manifest. |
|
||||
|
||||
The canonical machine-readable policy is exposed by:
|
||||
|
||||
|
|
@ -53,6 +54,11 @@ The canonical machine-readable policy is exposed by:
|
|||
GET /api/v1/sqlite/lifecycle-policy
|
||||
```
|
||||
|
||||
That endpoint describes the SQLite-backed data classes used by backup,
|
||||
retention, export, and delete workflows. The file-backed credential broker row
|
||||
above is an explicit exception until its repository migrates to SQLite, so it is
|
||||
not represented in the endpoint response or SQLite export manifests.
|
||||
|
||||
## Backup Manifest
|
||||
|
||||
SQLite export manifests use `formatVersion: 2` and include:
|
||||
|
|
@ -90,6 +96,15 @@ rules:
|
|||
telemetry ranges, notifications, and workflow snapshots
|
||||
- require explicit confirmation for destructive cleanup
|
||||
- preserve active worktrees and current run state by default
|
||||
- treat `.veritas-kanban/worktree-manifests/*.json` as ownership and recovery
|
||||
records, not disposable cache; a clean worktree is not proof that it is
|
||||
pushed, merged, unowned, or safe to remove
|
||||
- preview stale worktree candidates before removal and retain the exact blocked
|
||||
reasons; only reasoned overrides may bypass dirty, untracked, unpushed,
|
||||
unmerged, or external-hold checks, while an active run remains
|
||||
non-overrideable
|
||||
- preserve credential broker state during general runtime cleanup; revoke active
|
||||
leases before intentional broker-state removal
|
||||
- emit audit/activity records for admin cleanup, export, import, restore, and
|
||||
retention-setting changes
|
||||
|
||||
|
|
|
|||
|
|
@ -67,17 +67,39 @@ Data is persisted in a Docker named volume (`kanban-data`), so it survives conta
|
|||
|
||||
### Dockerfile Overview
|
||||
|
||||
The multi-stage Dockerfile produces a minimal production image (< 200 MB):
|
||||
The multi-stage Dockerfile enforces architecture-specific production image budgets:
|
||||
|
||||
| Stage | Purpose |
|
||||
| -------------- | --------------------------------------- |
|
||||
| `deps` | Install all pnpm workspace dependencies |
|
||||
| `build-shared` | Compile the shared TypeScript package |
|
||||
| `build-web` | Build the React frontend with Vite |
|
||||
| `build-server` | Compile the Express server TypeScript |
|
||||
| `production` | Minimal Node.js 22 Alpine runtime |
|
||||
| Architecture | Maximum compressed image size | 6.1.2 implementation baseline |
|
||||
| ------------ | ----------------------------- | ----------------------------- |
|
||||
| `arm64` | 200,000,000 bytes | 195,910,880 bytes |
|
||||
| `amd64` | 600,000,000 bytes | 571,590,173 bytes |
|
||||
|
||||
The production stage runs as a non-root user (`veritas`, UID 1001) for security.
|
||||
The final release candidate is remeasured at the release milestone; these
|
||||
implementation baselines are not substituted for final artifact evidence.
|
||||
|
||||
| Stage | Purpose |
|
||||
| -------------- | ------------------------------------------------------------------------ |
|
||||
| `deps` | Install all pnpm workspace dependencies |
|
||||
| `build-shared` | Compile the shared TypeScript package |
|
||||
| `build-web` | Build the React frontend with Vite |
|
||||
| `build-server` | Compile the server and deploy its production dependency closure |
|
||||
| `production` | Copy only the server closure and built web assets into Node.js 22 Alpine |
|
||||
|
||||
The production stage does not contain npm, pnpm, the root workspace/lockfile,
|
||||
CLI dependencies, or MCP dependencies. It retains only the server and shared
|
||||
package identity manifests required for module resolution and version health.
|
||||
It runs as the non-root `veritas` user (UID 1001).
|
||||
|
||||
The `amd64` image is larger because the Linux Codex runtime bundled by `@openai/codex-sdk`
|
||||
occupies about 302 MB of its unpacked filesystem, including a roughly 245 MB executable.
|
||||
Retaining it keeps the `codex-sdk` provider functional without an operator-supplied binary.
|
||||
The budgets leave about 2% headroom on `arm64` and 5% on `amd64`, so material dependency growth
|
||||
still fails the contract instead of being normalized by one loose cross-platform ceiling.
|
||||
|
||||
CI builds the production target and runs `pnpm check:docker-image`. The contract fails when the
|
||||
image reaches its architecture budget or when the runtime smoke cannot prove non-root execution,
|
||||
SQLite startup, API authentication, static web serving, health checks, and the native `bcrypt`
|
||||
module. `VERITAS_DOCKER_MAX_BYTES` can set an explicit budget for another architecture.
|
||||
|
||||
**Path Resolution (v2.1.3):** All services use the shared `paths.ts` utility for consistent path resolution. The resolution priority is: `DATA_DIR` / `VERITAS_DATA_DIR` env var → auto-discovery of monorepo root (walks up from cwd looking for `pnpm-workspace.yaml`) → fallback to cwd. A filesystem root guard prevents silent `/` resolution, which previously caused `EACCES: permission denied` errors in Docker. The production image uses `WORKDIR /app/server` for backwards compatibility.
|
||||
|
||||
|
|
@ -137,6 +159,15 @@ mkdir -p ./data
|
|||
chown 1001:1001 ./data
|
||||
```
|
||||
|
||||
When `VERITAS_STORAGE=sqlite`, the bind mount must resolve to durable local
|
||||
storage on the Docker host. Do not place the authoritative database on NFS,
|
||||
SMB/CIFS, FUSE, WebDAV, a NAS mount, a synchronized cloud folder, or an
|
||||
ephemeral container overlay. Detected unsafe or unverified filesystem posture
|
||||
refuses startup before SQLite opens the file. Cloud-sync folders may still look
|
||||
like ordinary local storage to the operating system, so the operator must keep
|
||||
them out of the authoritative path. See
|
||||
[SQLite Filesystem Safety Posture](SQLITE-SCHEMA.md#filesystem-safety-posture).
|
||||
|
||||
---
|
||||
|
||||
## NODE_ENV & Docker
|
||||
|
|
@ -223,16 +254,16 @@ If you need to debug inside a container, use `docker exec` to inspect — don't
|
|||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
| ----------- | ------- |
|
||||
| Node.js | 22.0.0+ |
|
||||
| pnpm | 9.0.0+ |
|
||||
| Requirement | Version |
|
||||
| ----------- | --------------- |
|
||||
| Node.js | 22.22.1+ |
|
||||
| pnpm | 11.1.1 (pinned) |
|
||||
|
||||
Install pnpm if not present:
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
corepack prepare pnpm@11.1.1 --activate
|
||||
```
|
||||
|
||||
### Build Steps
|
||||
|
|
@ -425,18 +456,15 @@ sends API requests to `/kanban/api/...`.
|
|||
> or the equivalent changes to `vite.config.ts`, `web/src/lib/config.ts`, and
|
||||
> `web/src/lib/api/helpers.ts`.
|
||||
|
||||
**Docker volumes for sub-path:** When using Docker with sub-path deployment, ensure both
|
||||
the task data and the config directory are on persistent volumes:
|
||||
**Docker volumes for sub-path:** One volume at `DATA_DIR` persists tasks and runtime state:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- kanban-data:/app/data # Task files
|
||||
- kanban-config:/app/.veritas-kanban # Config, sprints, enforcement gates
|
||||
- kanban-data:/app/data # tasks/ plus .veritas-kanban/
|
||||
```
|
||||
|
||||
Without a config volume, settings (enforcement gates, transition hooks, sprints) are lost
|
||||
on every container rebuild because `.veritas-kanban/` lives on the overlay filesystem, not
|
||||
on the data volume.
|
||||
Do not mount a second volume at `/app/.veritas-kanban`; that is a legacy location used only
|
||||
as a read-only source during startup migration.
|
||||
|
||||
### systemd Service
|
||||
|
||||
|
|
@ -526,13 +554,14 @@ All variables are set in `server/.env` (or passed as environment variables in Do
|
|||
|
||||
### Networking & Security
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TRUST_PROXY` | — | Express trust proxy setting for reverse proxy deployments. Common: `1` (single hop), `loopback`. Required for correct rate limiting behind nginx/Caddy/Traefik. `true` is blocked for safety |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,...` | Comma-separated list of allowed CORS origins |
|
||||
| `RATE_LIMIT_MAX` | `300` | Max API requests per minute per IP (localhost exempt). Auth endpoints have a stricter 15 req/min limit |
|
||||
| `CSP_REPORT_ONLY` | `false` | Use Content-Security-Policy-Report-Only instead of enforcing |
|
||||
| `CSP_REPORT_URI` | — | URL to receive CSP violation reports |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TRUST_PROXY` | — | Express trust proxy setting for reverse proxy deployments. Common: `1` (single hop), `loopback`. Required for correct rate limiting behind nginx/Caddy/Traefik. `true` is blocked for safety |
|
||||
| `VERITAS_EGRESS_UPSTREAM_PROXY` | — | Optional operator HTTP proxy for policy-approved run egress. The gateway tunnels to the DNS-pinned destination and never persists proxy credentials |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,...` | Comma-separated list of allowed CORS origins |
|
||||
| `RATE_LIMIT_MAX` | `300` | Max API requests per minute per IP (localhost exempt). Auth endpoints have a stricter 15 req/min limit |
|
||||
| `CSP_REPORT_ONLY` | `false` | Use Content-Security-Policy-Report-Only instead of enforcing |
|
||||
| `CSP_REPORT_URI` | — | URL to receive CSP violation reports |
|
||||
|
||||
### Prometheus metrics
|
||||
|
||||
|
|
@ -544,12 +573,16 @@ All variables are set in `server/.env` (or passed as environment variables in Do
|
|||
|
||||
### Data & Storage
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `VERITAS_DATA_DIR` | `.veritas-kanban` (relative to project root) | Directory for config, logs, and internal data |
|
||||
| `DATA_DIR` | `/app/data` (Docker only) | Mapped data directory inside the Docker container |
|
||||
| `TELEMETRY_RETENTION_DAYS` | `30` | Days to keep telemetry event files before deletion |
|
||||
| `TELEMETRY_COMPRESS_DAYS` | `7` | Days after which NDJSON telemetry files are gzip-compressed (0 = disabled) |
|
||||
| Variable | Default | Description |
|
||||
| -------------------------- | ------------------------- | -------------------------------------------------------------------------- |
|
||||
| `VERITAS_DATA_DIR` | Project root when unset | Storage root used when `DATA_DIR` is unset |
|
||||
| `DATA_DIR` | `/app/data` (Docker only) | Preferred storage root; takes precedence over `VERITAS_DATA_DIR` |
|
||||
| `VERITAS_STORAGE` | `file` | Selects `file` or `sqlite` storage |
|
||||
| `VERITAS_SQLITE_PATH` | Runtime `veritas.db` | SQLite database override; must resolve to verified durable local storage |
|
||||
| `VERITAS_SQLITE_TOPOLOGY` | — | Set explicitly to `single-host` before compatibility/override maintenance |
|
||||
| `VERITAS_SQLITE_HOST_ID` | — | Stable unique host binding for SQLite compatibility ownership policy |
|
||||
| `TELEMETRY_RETENTION_DAYS` | `30` | Days to keep telemetry event files before deletion |
|
||||
| `TELEMETRY_COMPRESS_DAYS` | `7` | Days after which NDJSON telemetry files are gzip-compressed (0 = disabled) |
|
||||
|
||||
### Integration
|
||||
|
||||
|
|
@ -593,20 +626,37 @@ wscat -c "ws://localhost:3001/ws?api_key=<api-key>"
|
|||
|
||||
### Where Data Lives
|
||||
|
||||
| Path | Contents |
|
||||
| --------------------------------- | ------------------------------------------------------ |
|
||||
| `tasks/active/` | Active task markdown files (YAML frontmatter + body) |
|
||||
| `tasks/archive/` | Archived task markdown files |
|
||||
| `.veritas-kanban/` | Internal config, logs, worktrees, agent requests |
|
||||
| `.veritas-kanban/config.json` | Application settings |
|
||||
| `.veritas-kanban/security.json` | JWT secret (if not using `VERITAS_JWT_SECRET` env var) |
|
||||
| `.veritas-kanban/logs/` | Application logs |
|
||||
| `.veritas-kanban/worktrees/` | Git worktree metadata |
|
||||
| `.veritas-kanban/agent-requests/` | Pending AI agent requests |
|
||||
| Path | Contents |
|
||||
| ------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `tasks/active/` | Active task markdown files (YAML frontmatter + body) |
|
||||
| `tasks/archive/` | Archived task markdown files |
|
||||
| `.veritas-kanban/` | Internal config, logs, worktrees, agent requests |
|
||||
| `.veritas-kanban/config.json` | Application settings |
|
||||
| `.veritas-kanban/security.json` | JWT secret (if not using `VERITAS_JWT_SECRET` env var) |
|
||||
| `.veritas-kanban/logs/` | Application logs |
|
||||
| `.veritas-kanban/worktrees/` | Task and temporary integration worktree directories |
|
||||
| `.veritas-kanban/worktree-manifests/` | Durable worktree ownership, base, lifecycle, and override evidence |
|
||||
| `.veritas-kanban/agent-requests/` | Pending AI agent requests |
|
||||
|
||||
In Docker, the `DATA_DIR` environment variable maps to `/app/data` by default inside the container.
|
||||
In Docker, `DATA_DIR=/app/data`. Tasks live under `/app/data/tasks` and all runtime state
|
||||
lives under `/app/data/.veritas-kanban`; no persistent state is written to `/app` or
|
||||
`/app/server` outside that volume.
|
||||
|
||||
**Auth state persistence fix (v3.1.1):** Runtime config/state files (including `security.json`) now always live under `${DATA_DIR}/.veritas-kanban`. On startup, Veritas Kanban will automatically migrate any legacy runtime files it finds in container-only paths (for example, `/app/.veritas-kanban` or `/app/server/.veritas-kanban`) into the Docker volume.
|
||||
**Auth state persistence fix (v3.1.1):** Runtime config/state files (including `security.json`) now always live under `${DATA_DIR}/.veritas-kanban`. On startup, Veritas Kanban automatically migrates legacy runtime files it can see at container-only paths (for example, `/app/.veritas-kanban` or `/app/server/.veritas-kanban`) into the Docker volume. A replaced container cannot see data left in an old container layer or an unmounted legacy volume.
|
||||
|
||||
If the old runtime state is in a named volume, mount that volume read-only at its former path for one startup. For example, add the legacy mount temporarily to your Compose service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
veritas-kanban:
|
||||
volumes:
|
||||
- kanban-data:/app/data
|
||||
- legacy-veritas-config:/app/.veritas-kanban:ro
|
||||
```
|
||||
|
||||
Start the service, verify the expected files now exist under
|
||||
`/app/data/.veritas-kanban`, then remove the legacy mount from Compose. The migration is
|
||||
copy-only: it does not delete the legacy source, and an existing destination file wins.
|
||||
|
||||
If you upgraded from an older image and already lost auth state, you can recover by copying `security.json` from a still-running/old container (if available) into the volume:
|
||||
|
||||
|
|
@ -641,15 +691,20 @@ tar czf veritas-tasks-$(date +%Y%m%d).tar.gz tasks/
|
|||
|
||||
#### Docker
|
||||
|
||||
For a live SQLite deployment, create a completed SQLite export from Settings ->
|
||||
Maintenance or `POST /api/v1/maintenance/sqlite/export`, then copy the completed
|
||||
bundle to remote backup storage. A raw filesystem archive is safe only after the
|
||||
Veritas container is stopped; copying a live database without its coordinated
|
||||
WAL state can produce an incomplete backup.
|
||||
|
||||
```bash
|
||||
# Backup the named volume
|
||||
# Stop before making a raw named-volume archive
|
||||
docker compose down
|
||||
docker run --rm \
|
||||
-v kanban-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/veritas-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
|
||||
# Or copy from the running container
|
||||
docker cp veritas-kanban:/app/data ./backup-data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
|
@ -677,7 +732,7 @@ docker compose down
|
|||
docker run --rm \
|
||||
-v kanban-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine sh -c "rm -rf /data/* && tar xzf /backup/veritas-backup-20260129.tar.gz -C /data"
|
||||
alpine sh -c 'set -eu; archive=/backup/veritas-backup-20260129.tar.gz; test -d /data; test "$(readlink -f /data)" = /data; test -r "$archive"; tar tzf "$archive" >/dev/null; find /data -mindepth 1 -delete; tar xzf "$archive" -C /data'
|
||||
|
||||
# Restart
|
||||
docker compose up -d
|
||||
|
|
@ -737,6 +792,38 @@ pre-migration backup and journal, and follow
|
|||
SQLite migration is limited by schema compatibility; restore the pre-migration
|
||||
file-backed backup when an older app cannot open a newer database.
|
||||
|
||||
### Source-Checkout Runtime Data Separation
|
||||
|
||||
When running from a source checkout (not Docker), runtime data and source
|
||||
files share the same directory tree by default:
|
||||
|
||||
| Lifecycle | Paths | Action on upgrade |
|
||||
| ---------------- | -------------------------------------------- | --------------------------- |
|
||||
| **Source** | `server/`, `web/`, `cli/`, `shared/`, `mcp/` | Safe to `git pull`, rebuild |
|
||||
| **Runtime data** | `.veritas-kanban/`, `tasks/`, `storage/` | Preserve across upgrades |
|
||||
|
||||
`.gitignore` excludes all runtime directories, so `git pull` will not overwrite
|
||||
them. However, to make the separation explicit and protect runtime data on
|
||||
bare-metal or CI setups, set `VERITAS_DATA_DIR` (or `DATA_DIR`) to a directory
|
||||
outside the source tree:
|
||||
|
||||
```bash
|
||||
export VERITAS_DATA_DIR=/var/lib/veritas-kanban
|
||||
```
|
||||
|
||||
All services resolve runtime paths through `server/src/utils/paths.ts`, which
|
||||
respects `DATA_DIR` / `VERITAS_DATA_DIR` as the authoritative override (#774).
|
||||
|
||||
**Startup reconciliation:** Current agent launches persist a
|
||||
`run-supervisor/v1` record under the configured runtime data directory or in
|
||||
SQLite. After an unclean stop, startup validates the exact provider, launch,
|
||||
task-envelope, worktree, host, lease, and process/session identity before
|
||||
reattaching. A stale lease has one compare-and-set winner, verified process
|
||||
groups remain stoppable, event replay resumes after the durable cursor, and a
|
||||
terminal result is applied idempotently if the server crashed before task
|
||||
mutation. Unsafe or legacy runs move to `blocked` with a typed recovery reason
|
||||
and operator action instead of being restarted automatically (#781, #853).
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
|
@ -755,6 +842,55 @@ Remote clients and desktop onboarding should also validate:
|
|||
- `GET /api/auth/status` for setup/auth/session state.
|
||||
- `/ws` upgrade from the same origin used by the web app.
|
||||
|
||||
When SQLite is active, authenticated admin calls to `/health/deep` and
|
||||
`/api/health/deep` include redacted filesystem posture, journal mode, and
|
||||
integrity evidence. If the filesystem is unsafe or unverified, the server
|
||||
refuses startup before binding the HTTP port; inspect container or desktop
|
||||
supervisor logs for the reason and move `VERITAS_SQLITE_PATH` to supported local
|
||||
storage.
|
||||
|
||||
### Governed SQLite journal conversion
|
||||
|
||||
Never edit journal pragmas against a running Veritas database. Configure an
|
||||
admin CLI key, preview the exact operation, schedule it, and restart once:
|
||||
|
||||
```bash
|
||||
export VK_API_KEY="$VERITAS_ADMIN_KEY"
|
||||
export VERITAS_SQLITE_TOPOLOGY=single-host
|
||||
export VERITAS_SQLITE_HOST_ID=veritas-primary-01
|
||||
|
||||
vk sqlite journal preview \
|
||||
--target delete \
|
||||
--single-host \
|
||||
--override-reason "Temporary single-host compatibility" \
|
||||
--expires-at 2026-07-16T00:00:00Z \
|
||||
--json
|
||||
|
||||
vk sqlite journal apply \
|
||||
--preview-id <preview-id> \
|
||||
--preview-token <preview-token> \
|
||||
--confirm <preview-id> \
|
||||
--acknowledge-risks
|
||||
|
||||
# Restart the normal service once, then inspect the result.
|
||||
vk sqlite journal status --json
|
||||
```
|
||||
|
||||
Use `--target wal` to return a supported-local database to ordinary WAL mode.
|
||||
If status reports `recovery-required`, keep the database, maintenance directory,
|
||||
and backup artifacts intact and do not start another writer. The next bootstrap
|
||||
completes forward only when the current journal mode and full integrity are
|
||||
verified, or reverts the mode in place when that remains safe. It never blindly
|
||||
restores an older backup; persistent or ambiguous failure requires operator
|
||||
recovery before normal startup. Conversion never makes SQLite a shared network
|
||||
database.
|
||||
|
||||
The journal policy and owner lock are authenticated with `VERITAS_ADMIN_KEY`.
|
||||
Return the database to ordinary local WAL mode before rotating that key. If an
|
||||
unexpected shutdown leaves an owner lock during rotation, verify that the
|
||||
recorded host/process is dead before following the recovery procedure; never
|
||||
delete an active or foreign-host lock.
|
||||
|
||||
The Docker image includes a built-in health check:
|
||||
|
||||
- **Interval:** 30 seconds
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ This guide covers desktop packaging paths for macOS GA artifacts and
|
|||
Linux/Windows preview artifact scaffolding: unsigned PR artifacts, signed
|
||||
release artifacts, update metadata, and smoke testing.
|
||||
|
||||
For v5 GA, macOS is the only supported desktop release target. Linux and
|
||||
For v6 GA, macOS is the only supported desktop release target. Linux and
|
||||
Windows artifacts are unsigned preview artifacts for post-GA validation unless a
|
||||
later release guide explicitly promotes the platform.
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ pnpm desktop:package:windows:unsigned
|
|||
pnpm desktop:release:mac
|
||||
pnpm desktop:release:linux
|
||||
pnpm desktop:release:windows
|
||||
pnpm desktop:smoke:mac:local
|
||||
```
|
||||
|
||||
`desktop:package:mac:dir` creates an unpacked local app for fast inspection.
|
||||
|
|
@ -40,10 +41,20 @@ The package step builds the workspace, stages the production server runtime in
|
|||
`desktop/.desktop-release/web`, and writes artifacts to `desktop/release/`.
|
||||
Both staging and release directories are ignored by git.
|
||||
|
||||
`pnpm desktop:smoke:mac:local` runs the macOS directory packaging path and then
|
||||
asserts that root dev tooling such as Prettier, ESLint, and the desktop
|
||||
`electron-builder` dependency still exist. Use it after package-script changes
|
||||
that touch production staging or pnpm deploy behavior. The staging deploy runs
|
||||
from an isolated temporary workspace so `pnpm deploy --prod` cannot rewrite the
|
||||
live repository install into a production-only dependency state.
|
||||
|
||||
## GitHub Workflows
|
||||
|
||||
`Desktop Artifacts` runs on desktop/server/web/shared changes and on manual
|
||||
dispatch. It builds unsigned artifacts on:
|
||||
`Desktop Artifacts` runs only for a pull request carrying `ci:full` or through
|
||||
manual dispatch. Ordinary pull requests and `main` pushes do not package
|
||||
desktop applications. A release candidate keeps `ci:full` applied through its
|
||||
final synchronization so the artifacts correspond to the reviewed head. The
|
||||
workflow builds unsigned artifacts on:
|
||||
|
||||
- `macos-15`: DMG, ZIP, blockmap, and update YAML.
|
||||
- `ubuntu-24.04`: x64 AppImage, deb, rpm, blockmap, and update YAML.
|
||||
|
|
@ -51,14 +62,17 @@ dispatch. It builds unsigned artifacts on:
|
|||
|
||||
Unsigned artifact jobs do not require platform signing credentials.
|
||||
|
||||
For v5 GA, the Linux and Windows jobs are preview signals only. They keep
|
||||
For v6 GA, the Linux and Windows jobs are preview signals only. They keep
|
||||
packaging paths and artifact names exercised, but they are not supported release
|
||||
deliverables and must not be linked from stable release notes as install
|
||||
targets.
|
||||
|
||||
`Desktop Release` runs on manual dispatch or a published GitHub release. It
|
||||
requires the signing secrets below, builds signed/notarized macOS artifacts,
|
||||
and publishes update metadata with the GitHub provider.
|
||||
and publishes update metadata with the GitHub provider. A published-release run
|
||||
first verifies that the live GitHub body exactly matches
|
||||
`docs/releases/vX.Y.Z.md`; a mismatch stops the workflow before signing or
|
||||
packaging.
|
||||
|
||||
## Homebrew Cask
|
||||
|
||||
|
|
@ -83,19 +97,36 @@ brew livecheck bradgroux/tap/veritas-kanban
|
|||
|
||||
## Required Release Secrets
|
||||
|
||||
Configure these repository secrets before running `Desktop Release`:
|
||||
Configure Developer ID signing secrets before running `Desktop Release`:
|
||||
|
||||
- `MACOS_CSC_LINK`: base64 encoded `.p12` Developer ID Application certificate
|
||||
or a secure URL accepted by electron-builder `CSC_LINK`.
|
||||
- `MACOS_CSC_KEY_PASSWORD`: password for the `.p12` signing identity.
|
||||
|
||||
Then configure exactly one complete notarization credential set. The workflow
|
||||
fails before packaging if neither set is complete, if either set is partial, or
|
||||
if both sets are configured at the same time.
|
||||
|
||||
### Preferred: App Store Connect API-key notarization
|
||||
|
||||
- `APPLE_API_KEY_BASE64`: base64 encoded App Store Connect API `.p8` key.
|
||||
- `APPLE_API_KEY_ID`: App Store Connect API key ID.
|
||||
- `APPLE_API_ISSUER`: App Store Connect API issuer UUID.
|
||||
|
||||
The workflow maps those secrets to electron-builder's `CSC_LINK`,
|
||||
`CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and
|
||||
`APPLE_API_ISSUER` environment variables. The API key is decoded into a
|
||||
temporary file during the release job and is not written to the repository.
|
||||
The workflow decodes `APPLE_API_KEY_BASE64` into a temporary private-key file
|
||||
and maps it to electron-builder/notarytool as `APPLE_API_KEY`. The key file is
|
||||
created under the runner temp directory and is not written to the repository.
|
||||
|
||||
### Fallback: Apple ID app-specific-password notarization
|
||||
|
||||
- `APPLE_ID`: Apple developer account email address.
|
||||
- `APPLE_APP_SPECIFIC_PASSWORD`: app-specific password for notarization.
|
||||
- `APPLE_TEAM_ID`: Apple Developer Team ID.
|
||||
|
||||
The workflow maps those values to electron-builder/notarytool only when the API
|
||||
key credential set is absent. Do not configure both notarization modes in the
|
||||
same repository environment; that is treated as a release-preflight error so CI
|
||||
cannot silently use the wrong credential path.
|
||||
|
||||
Windows releases need a separate code-signing certificate before the first
|
||||
supported Windows artifact is published. Use `WINDOWS_CSC_LINK` and
|
||||
|
|
@ -106,13 +137,13 @@ and update policy requirements are promoted into the supported release path.
|
|||
|
||||
## Desktop Support Boundary
|
||||
|
||||
| Platform | v5 GA stance | Validation matrix | Artifact formats | Update stance |
|
||||
| Platform | v6 GA stance | Validation matrix | Artifact formats | Update stance |
|
||||
| -------- | ----------------------------- | ----------------------------------------------- | ---------------------------- | -------------------------------------------------- |
|
||||
| macOS | Supported desktop GA target | macOS 14+ Apple Silicon | signed DMG, ZIP | Supported through signed electron-updater metadata |
|
||||
| Linux | Preview only; not a GA target | Ubuntu 24.04 x64 and Fedora 40+ x64 smoke hosts | unsigned AppImage, deb, rpm | Deferred until Linux release policy is promoted |
|
||||
| Windows | Preview only; not a GA target | Windows 11 23H2+ x64 smoke host | unsigned NSIS installer, ZIP | Blocked until Windows code signing and smoke pass |
|
||||
|
||||
Linux and Windows support is post-GA. Do not mention Linux/Windows as v5 GA
|
||||
Linux and Windows support is post-GA. Do not mention Linux/Windows as v6 GA
|
||||
install targets until the corresponding release artifact has passed the smoke
|
||||
matrix below and the compatibility policy has been updated to promote the
|
||||
platform.
|
||||
|
|
@ -134,9 +165,9 @@ available, downloading, ready, failed, and unsupported states. The menu enables
|
|||
download only when an update is available and install only when an update has
|
||||
downloaded.
|
||||
|
||||
The full v5 channel, staged rollout, version-skew, stale-client, and rollback
|
||||
The full v6 channel, staged rollout, version-skew, stale-client, and rollback
|
||||
policy is tracked in
|
||||
[v5 Compatibility And Release Policy](V5-COMPATIBILITY-AND-RELEASE-POLICY.md).
|
||||
[v6 Compatibility And Release Policy](V6-COMPATIBILITY-AND-RELEASE-POLICY.md).
|
||||
|
||||
## Release Checklist
|
||||
|
||||
|
|
@ -144,22 +175,55 @@ policy is tracked in
|
|||
- Update `CHANGELOG.md`.
|
||||
- Run `pnpm typecheck`, `pnpm lint:budget`, `pnpm build`, and
|
||||
`pnpm test:unit`.
|
||||
- Confirm `pnpm desktop:check:electron-artifacts` passes. The emitted main and
|
||||
preload bundles must import Electron's runtime API and must not contain the
|
||||
npm install/download shim.
|
||||
- Run `pnpm desktop:smoke:mac:local` to verify local packaging does not prune
|
||||
root dev tooling.
|
||||
- Run `pnpm desktop:test:readiness` and confirm the bounded readiness helper
|
||||
rejects a stale version and reports a useful timeout.
|
||||
- Run `pnpm desktop:package:mac:unsigned` and inspect artifact names.
|
||||
- Run `pnpm desktop:package:linux:unsigned` on Linux or the
|
||||
`Desktop Artifacts` Linux job and inspect preview artifact names. This is not
|
||||
a v5 GA release gate.
|
||||
a v6 GA release gate.
|
||||
- Run `pnpm desktop:package:windows:unsigned` on Windows or the
|
||||
`Desktop Artifacts` Windows job and inspect preview artifact names. This is
|
||||
not a v5 GA release gate.
|
||||
- Run `Desktop Artifacts` and download the uploaded DMG/ZIP/update metadata.
|
||||
- Run `Desktop Release` only after Apple signing secrets are configured.
|
||||
- Confirm notarization succeeds and the DMG installs without Gatekeeper
|
||||
warnings on a clean Mac.
|
||||
not a v6 GA release gate.
|
||||
- Apply `ci:full` to the release-candidate pull request, then download the
|
||||
uploaded DMG/ZIP/update metadata from its `Desktop Artifacts` run.
|
||||
- Edit `docs/releases/vX.Y.Z.md`, run
|
||||
`pnpm validate:release -- --version X.Y.Z`, and publish that exact file with
|
||||
`gh release create --notes-file` or `gh release edit --notes-file`. Do not
|
||||
hand-author or repair the live body separately.
|
||||
- Use one logical source line per prose paragraph and let GitHub wrap it to the
|
||||
available width. Keep release structure to level-two headings. Prefer one
|
||||
cohesive full-width paragraph for a handful of related changes. Use Markdown
|
||||
lists only when every item is concise enough to avoid multi-line hanging
|
||||
indentation; the format gate caps list items at 160 source characters. Never
|
||||
stack bold-led paragraphs or long labeled list items that look like accidental
|
||||
carriage returns.
|
||||
- Run `Desktop Release` only after Developer ID signing secrets and exactly one
|
||||
complete notarization credential set are configured.
|
||||
- Inspect the rendered release on both the releases index and tag page. Confirm
|
||||
prose uses natural page-width wrapping, lists remain compact, and no ragged
|
||||
hanging-indent block, unmarked paragraph stack, or sentence-sized fragment was
|
||||
introduced.
|
||||
- Confirm notarization succeeds with the intended credential mode and the DMG
|
||||
installs without Gatekeeper warnings on a clean Mac.
|
||||
- Confirm a first run creates the profile/workspace app data directories.
|
||||
- Confirm startup/auth/setup surfaces are draggable while their controls remain
|
||||
clickable, and a populated isolated SQLite profile offers **Use Existing
|
||||
Data** with representative counts before password setup.
|
||||
- Confirm the
|
||||
[Web To Mac Desktop Migration](WEB-TO-MAC-DESKTOP-MIGRATION.md) decision tree
|
||||
matches the shipped onboarding labels and Maintenance import behavior.
|
||||
- Confirm update check, download, install, failed-download, and rollback paths
|
||||
on the selected channel.
|
||||
- For a Homebrew upgrade, confirm `open -a` followed by
|
||||
`pnpm desktop:wait:ready -- --expected-version <version>` tolerates normal
|
||||
startup delay and proves the packaged server owns `3001`.
|
||||
- Confirm `pnpm validate:release` passes and verifies root/shared/server/web,
|
||||
CLI, MCP, and desktop package versions plus required v5 release docs.
|
||||
CLI, MCP, and desktop package versions plus required v6 release docs.
|
||||
|
||||
## Smoke Tests
|
||||
|
||||
|
|
@ -184,7 +248,7 @@ Signed release artifact:
|
|||
|
||||
Linux preview unsigned artifact:
|
||||
|
||||
These steps generate post-GA readiness evidence only. They are not v5 GA
|
||||
These steps generate post-GA readiness evidence only. They are not v6 GA
|
||||
install instructions.
|
||||
|
||||
1. Download `veritas-kanban-linux-unsigned` from the workflow run.
|
||||
|
|
@ -203,7 +267,7 @@ install instructions.
|
|||
|
||||
Windows preview unsigned artifact:
|
||||
|
||||
These steps generate post-GA readiness evidence only. They are not v5 GA
|
||||
These steps generate post-GA readiness evidence only. They are not v6 GA
|
||||
install instructions.
|
||||
|
||||
1. Download `veritas-kanban-windows-unsigned` from the workflow run.
|
||||
|
|
@ -229,7 +293,7 @@ Rollback:
|
|||
|
||||
## Platform Notes
|
||||
|
||||
Linux and Windows packages are intentionally not v5 GA blockers. The post-GA
|
||||
Linux and Windows packages are intentionally not v6 GA blockers. The post-GA
|
||||
artifact jobs keep artifact naming and update-channel conventions portable, but
|
||||
Windows release and update support stay blocked until code signing and
|
||||
signed-installer smoke coverage are in place. Linux release and updater support
|
||||
|
|
|
|||
|
|
@ -44,7 +44,13 @@ When completing a task that changes user-facing behavior:
|
|||
|
||||
### Freshness Indicators
|
||||
|
||||
Each doc should include a freshness header:
|
||||
The Settings → Doc Freshness registry is the authoritative freshness source.
|
||||
Each tracked record stores its path, last review date, reviewer, maximum age,
|
||||
tags, and notes. The service computes scores and alerts from those records; it
|
||||
does not scan or rewrite Markdown headers.
|
||||
|
||||
A maintained living document may also include this optional human-readable
|
||||
marker when repository reviewers find it useful:
|
||||
|
||||
```markdown
|
||||
<!-- doc-freshness: 2026-03-25 | v4.0.0 | @veritas -->
|
||||
|
|
@ -52,22 +58,32 @@ Each doc should include a freshness header:
|
|||
|
||||
Format: `date | version | last-updater`
|
||||
|
||||
When a doc is older than the current version, it may need review.
|
||||
The optional marker is not required for release notes, historical evidence,
|
||||
generated references, or every file under `docs/`. When a tracked document is
|
||||
older than its configured maximum age or its maintained version, review it and
|
||||
update the authoritative registry record.
|
||||
|
||||
### Last Sweep
|
||||
|
||||
| Date | Scope | Agent |
|
||||
| ---------- | ------------------------------------------------------------------- | ------- |
|
||||
| 2026-06-05 | v5.0.0 stable release docs, install paths, release assets, RC notes | Codex |
|
||||
| 2026-03-25 | Full v3→v4 version references, governance docs, CHANGELOG, examples | VERITAS |
|
||||
| 2026-03-21 | v4.0 release documentation | TARS |
|
||||
| Date | Scope | Agent |
|
||||
| ---------- | ------------------------------------------------------------------------------------------ | ------- |
|
||||
| 2026-08-24 | README; v6.1.2 audit, storage, provider, CI, security, release, distribution, and SOP docs | Release |
|
||||
| 2026-08-22 | v6.1.1 maintenance, dependency, release, upgrade, and evidence docs | Release |
|
||||
| 2026-07-26 | v6.1.0 roadmap, harness, governance, knowledge, and release docs | Release |
|
||||
| 2026-07-24 | v6.0.2 desktop recovery, version support, release, and evidence | Release |
|
||||
| 2026-07-24 | v6.0.1 stabilization, release, upgrade, API, MCP, and evidence | Release |
|
||||
| 2026-07-24 | v6.0.0 harness, Buzz, release, upgrade, compatibility, and evidence | Release |
|
||||
| 2026-07-12 | v5.2.2 UI-audit fixes, release gates, desktop state, and evidence | Release |
|
||||
| 2026-06-05 | v5.0.0 stable release docs, install paths, release assets, RC notes | Codex |
|
||||
| 2026-03-25 | Full v3→v4 version references, governance docs, CHANGELOG, examples | VERITAS |
|
||||
| 2026-03-21 | v4.0 release documentation | TARS |
|
||||
|
||||
## Automation Plan
|
||||
|
||||
### Phase 1: Manual (Current)
|
||||
|
||||
- Doc update checklist in PR template
|
||||
- Freshness headers in docs
|
||||
- Doc Freshness registry records, with optional source markers where useful
|
||||
- Agent instructions include "update docs" step
|
||||
|
||||
### Phase 2: Hook-Based
|
||||
|
|
@ -99,12 +115,15 @@ curl -X POST /api/hooks -d '{
|
|||
}'
|
||||
```
|
||||
|
||||
## Repo Rules (CLAUDE.md Equivalent)
|
||||
## Repo Rules
|
||||
|
||||
VK's `docs/AGENTS-TEMPLATE.md` serves as the agent instruction file. Key rules:
|
||||
The root `AGENTS.md` is canonical. `docs/AGENTS-TEMPLATE.md` is the reusable
|
||||
project template, and harness-specific files only supplement the canonical
|
||||
rules. Key rules:
|
||||
|
||||
1. **Always update docs alongside code** — no code-only PRs for user-facing changes
|
||||
2. **Use freshness headers** — every doc starts with `<!-- doc-freshness: ... -->`
|
||||
2. **Track maintained living docs** — use the Doc Freshness registry; optional
|
||||
source headers are a reviewer aid, not the system of record
|
||||
3. **JSDoc is documentation** — route handlers and services must have JSDoc
|
||||
4. **Examples must work** — if you change an API, update the examples
|
||||
5. **CHANGELOG is mandatory** — every release gets an entry
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Steal these end-to-end flows when building your own automations. Each example sh
|
|||
```
|
||||
2. **Prompt (worker)**
|
||||
```
|
||||
Implement markdown lessonsLearned field on tasks (UI + API). Include migration + docs. Cross-model review required.
|
||||
Implement markdown lessonsLearned field on tasks (UI + API). Include migration + docs. Run the task's configured review gate, if any.
|
||||
```
|
||||
3. **Workflow**
|
||||
- `vk begin <id>`
|
||||
|
|
@ -38,7 +38,7 @@ Steal these end-to-end flows when building your own automations. Each example sh
|
|||
- Patch bulk archive handler
|
||||
- Add regression test (Playwright)
|
||||
3. CLI flow: `vk begin`, fix, `vk done "Bulk archive now calls API"`
|
||||
4. Cross-model review ensures UI + API parity.
|
||||
4. Focused tests verify UI + API parity; add independent review when required.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,11 +54,11 @@ Steal these end-to-end flows when building your own automations. Each example sh
|
|||
|
||||
## 4. Security Audit (RF-002 style)
|
||||
|
||||
**Goal:** Run cross-model audit on repo.
|
||||
**Goal:** Run a focused security audit on the repository.
|
||||
|
||||
1. Task -> `type=security`, `project=veritas-kanban`.
|
||||
2. Subtasks: scope, run Codex audit, run Claude review, compile findings, create issues.
|
||||
3. Agents spawn using research prompt template, save results to `refactoring/rf-002/*`.
|
||||
2. Subtasks: scope, inspect trust boundaries, validate findings, compile evidence, create issues.
|
||||
3. Use the security-review prompt and save durable results to the task's declared artifact path.
|
||||
4. Deliverables: Markdown report, HTML deck, GitHub issues.
|
||||
|
||||
---
|
||||
|
|
@ -88,7 +88,7 @@ For any workflow:
|
|||
2. **Prompt** stored in registry.
|
||||
3. **API/CLI** calls scripted (vk begin/done, time tracking, status updates).
|
||||
4. **Artifacts** saved to predictable paths and mirrored to Brain/engram if needed.
|
||||
5. **Cross-model review** if code/critical.
|
||||
5. **Focused review** when the task or configured governance policy requires it.
|
||||
6. **Lessons learned** field updated for systemic knowledge.
|
||||
|
||||
Use these recipes as seeds for your own automation playbooks.
|
||||
|
|
|
|||
|
|
@ -41,15 +41,16 @@ Use these recipes as starting points for v4.3 OpenAI Codex workflows in Veritas
|
|||
- test command output
|
||||
- task comment or deliverable listing changed files
|
||||
|
||||
6. Review:
|
||||
- Create a cross-model review task.
|
||||
- Assign it to a non-Codex reviewer.
|
||||
6. Optional review:
|
||||
- If the task or governance policy requires independent review, create a
|
||||
review task and assign it to the requested reviewer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Codex Review Of A Claude-authored PR
|
||||
## 2. Optional Independent Review With Codex
|
||||
|
||||
**Goal:** Use Codex as the opposite-model reviewer for a Claude-authored branch.
|
||||
**Goal:** Use Codex as an independent reviewer when a task or governance policy
|
||||
explicitly requires one. This is not a default delivery step.
|
||||
|
||||
1. Keep the original implementation task `in-progress`.
|
||||
2. Trigger a Codex review action:
|
||||
|
|
@ -152,7 +153,7 @@ steps:
|
|||
agent: reviewer
|
||||
depends_on: [implement]
|
||||
input: |
|
||||
Review Codex's implementation using docs/SOP-cross-model-code-review.md.
|
||||
Run the configured independent review using docs/SOP-cross-model-code-review.md.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
|
|
|||
695
docs/FEATURES.md
695
docs/FEATURES.md
File diff suppressed because it is too large
Load diff
|
|
@ -28,12 +28,12 @@ A working board is not the same as agent-ready or external wake/delivery-ready.
|
|||
|
||||
## Prerequisites (30 seconds)
|
||||
|
||||
| What | Command | Notes |
|
||||
| ----------------- | ------------------ | -------------------------------------------------- |
|
||||
| Node.js | `node -v` | Requires **22+**. Install via Volta/nvm if older. |
|
||||
| pnpm | `pnpm -v` | Requires **9+**. `npm install -g pnpm` if missing. |
|
||||
| Git | `git --version` | Any current version works. |
|
||||
| (Optional) Docker | `docker --version` | Needed only if you prefer containers. |
|
||||
| What | Command | Notes |
|
||||
| ----------------- | ------------------ | ----------------------------------------------------------------------- |
|
||||
| Node.js | `node -v` | Requires **22.22.1+**. Install via Volta/nvm if older. |
|
||||
| pnpm | `pnpm -v` | Use the repository-pinned **11.1.1** release. |
|
||||
| Git | `git --version` | Requires **2.38+**. |
|
||||
| (Optional) Docker | `docker --version` | Needed only if you prefer containers. |
|
||||
|
||||
That's it. No database, no extra services.
|
||||
|
||||
|
|
@ -190,8 +190,12 @@ This section is optional. Agents interact through HTTP + WebSocket; nothing is h
|
|||
```
|
||||
7. **Agent workflow** (example prompt to an agent runner):
|
||||
```
|
||||
Hey Veritas, pick up task <ID>. Set status to in-progress, start the timer, do the work, then call `vk done <id> "summary"` when finished. Use cross-model review if you wrote code.
|
||||
Start the configured agent on task <ID>. Use the persisted task envelope,
|
||||
assigned worktree, and run-scoped tool catalog. Return focused verification
|
||||
and a concise completion summary through the harness result.
|
||||
```
|
||||
Managed agents must not call `vk begin`, `vk done`, or lifecycle callbacks.
|
||||
See [AGENTS-TEMPLATE.md](AGENTS-TEMPLATE.md).
|
||||
8. **Agent completion**
|
||||
- Verify `tasks/active/...` reflects status/time tracking
|
||||
- Check `.veritas-kanban/logs/agents.log` for run details
|
||||
|
|
@ -249,7 +253,7 @@ BoardKit Orchestrator inspired us here: keep prompts, skills, and guidelines in
|
|||
prompt-registry/
|
||||
├── sprint-planning.md # Break epics into sprints
|
||||
├── worker-handoff.md # PM → Worker assignment
|
||||
├── cross-model-review.md # Claude ↔ GPT review gate
|
||||
├── cross-model-review.md # Optional independent review
|
||||
├── feature-development.md # E2E feature implementation
|
||||
├── bug-triage.md # Investigation and fix
|
||||
├── research-report.md # Deep research deliverable
|
||||
|
|
|
|||
96
docs/HARNESS-COMPATIBILITY.md
Normal file
96
docs/HARNESS-COMPATIBILITY.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Harness Compatibility
|
||||
|
||||
Veritas publishes one reviewed compatibility record for Buzz, Grok Build,
|
||||
OpenAI Codex app-server, Claude Code, and GitHub Copilot CLI. The canonical
|
||||
machine-readable form is:
|
||||
|
||||
```text
|
||||
GET /api/config/harness-compatibility
|
||||
```
|
||||
|
||||
Settings -> Agents and `vk doctor --json` consume that record. Run telemetry
|
||||
stores the same profile capability digest beside the existing support tier,
|
||||
provider version/build, runtime-manifest digest, and failure class.
|
||||
|
||||
## Reviewed matrix
|
||||
|
||||
| Harness | Profile | Tested build | Transport | Source availability | Important limitation |
|
||||
| ------------------ | ------------------------- | ----------------------------------------------------------------------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Buzz | `buzz-agent` | Buzz v0.4.24, commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`; `buzz-agent 0.1.0` | ACP v1 stdio | Open source | Task execution uses `buzz-agent`; relay, identity, community, and workflow checks remain separate. |
|
||||
| Grok Build | `grok-build` | v0.2.111, build `94172f2aa4e5` | ACP v1 stdio | Partial source lineage | The released artifact self-reports alpha and is not fully traceable to the public source tree. |
|
||||
| OpenAI Codex | `openai-codex-app-server` | `codex-cli 0.145.0` | app-server JSON-RPC v2 | Open source | Experimental methods remain excluded until pinned schemas and behavior are reviewed. |
|
||||
| Claude Code | `claude-code` | `2.1.218 (Claude Code)` | stream-json process | Partial source | The complete CLI implementation is not public; some host enforcement remains provider-dependent. |
|
||||
| GitHub Copilot CLI | `github-copilot-cli` | v1.0.74, commit `2b809c84e87dbcc88f897cb4f3fb97c43b77af95` | ACP v1 stdio | Partial source | ACP is public preview and provider-managed authentication has no non-consuming status probe. |
|
||||
|
||||
The API is authoritative for the full capability list, reviewed evidence URLs,
|
||||
fixture paths, platform coverage, limitations, live readiness, and matrix
|
||||
digest. This table is an operator summary, not a substitute for current probe
|
||||
evidence.
|
||||
|
||||
## Support tiers
|
||||
|
||||
| Tier | Definition |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `certified` | Installed build, configuration, runtime manifest, probe revision, and deterministic fixtures match passing evidence. |
|
||||
| `configured` | The executable adapter can dispatch, but current deterministic certification evidence is absent. |
|
||||
| `detected` | The executable is installed, but the profile is disabled. |
|
||||
| `degraded` | A readiness, compatibility, policy, or certification check failed. |
|
||||
| `unsupported` | The platform or configured provider has no safe executable adapter for the profile. |
|
||||
|
||||
These definitions are emitted in the matrix response. CLI, API, web, and
|
||||
telemetry must not maintain provider-specific alternatives.
|
||||
|
||||
## Certification and invalidation
|
||||
|
||||
Each reviewed profile includes a deterministic fixture set, fixture revision,
|
||||
capability digest, evidence paths, and current status. Certification is
|
||||
invalidated by any change to:
|
||||
|
||||
- provider version or build;
|
||||
- profile configuration digest;
|
||||
- runtime probe revision;
|
||||
- transport protocol version;
|
||||
- capability digest; or
|
||||
- fixture revision.
|
||||
|
||||
Credential-gated smoke evidence is supplemental only. It can add exact-build
|
||||
runtime evidence, but it cannot replace or overwrite a deterministic failure.
|
||||
Raw observations retain launch-manifest, runtime-manifest, task, attempt, and
|
||||
event references through `harness-conformance-result/v1`.
|
||||
|
||||
Buzz additionally has a composed credential-free gate:
|
||||
|
||||
```bash
|
||||
pnpm test:buzz:compatibility
|
||||
```
|
||||
|
||||
Its matrix record names the relay compatibility, communication/replay, ACP,
|
||||
run-scoped MCP, persona/team import, and workflow-trigger fixtures. Live Buzz
|
||||
smoke is supplemental and never replaces a deterministic failure. See the
|
||||
[Buzz integration guide](BUZZ-INTEGRATION.md#credential-free-release-gate).
|
||||
|
||||
## Operating each harness
|
||||
|
||||
Detailed installation, authentication, configuration, permissions, MCP,
|
||||
worktree, upgrade, degraded-state, and troubleshooting guidance is maintained
|
||||
with each provider:
|
||||
|
||||
- [Buzz Agent ACP](AGENT-PROVIDERS.md#buzz-agent-acp)
|
||||
- [Grok Build ACP](AGENT-PROVIDERS.md#grok-build-acp)
|
||||
- [OpenAI Codex app-server](AGENT-PROVIDERS.md#openai-codex-app-server-v01450)
|
||||
- [Claude Code](AGENT-PROVIDERS.md#claude-code-v21218)
|
||||
- [GitHub Copilot CLI ACP](AGENT-PROVIDERS.md#github-copilot-cli-acp-public-preview)
|
||||
|
||||
The common operating contract is:
|
||||
|
||||
1. Install the exact tested build.
|
||||
2. Authenticate through the provider's login or an allowlisted boot credential.
|
||||
3. Enable the built-in profile without adding provider-owned launch bypasses.
|
||||
4. Assign a Veritas sandbox, permission, and approval policy.
|
||||
5. Run `vk doctor --json`; fix any degraded or unsupported evidence.
|
||||
6. Re-run deterministic certification after an upgrade or evidence revision.
|
||||
|
||||
Veritas launches all five harnesses in the assigned task worktree. MCP access is
|
||||
limited to the immutable task catalog plus the system-owned `veritas-run`
|
||||
bridge. Unsafe launch flags, unexpected versions, stale certification, and
|
||||
adapter/profile mismatches fail closed before an attempt is created.
|
||||
|
|
@ -17,6 +17,12 @@ in Settings -> Maintenance and is backed by `/api/v1/maintenance`.
|
|||
safe to paste into support handoffs by default.
|
||||
- SQLite export/import actions that report bundle path, database path, table
|
||||
counts, warnings, and failure messages.
|
||||
- Redacted authoritative SQLite filesystem type/posture, detection and decision
|
||||
source, effective journal mode, override source, and last one-time integrity
|
||||
check when SQLite storage is active.
|
||||
- SQLite journal previews and restart-time conversion status, including
|
||||
sidecars, ownership, backup class, risks, degraded single-host policy, expiry,
|
||||
revocation, rollback, and recovery-required state without raw paths.
|
||||
- Admin-only skill security scans through
|
||||
`/api/v1/maintenance/skill-security/scan`, with redacted JSON and Markdown
|
||||
reports persisted for audit review.
|
||||
|
|
@ -28,9 +34,26 @@ in Settings -> Maintenance and is backed by `/api/v1/maintenance`.
|
|||
- Destructive cleanup must require explicit confirmation and must never delete
|
||||
active task worktrees or current run state silently.
|
||||
- Debug bundles include redacted log tails, health metadata, storage summaries,
|
||||
lifecycle policy metadata, and work-product preview metadata.
|
||||
lifecycle policy metadata, work-product preview metadata, and a bounded
|
||||
`phase-authority.json` diagnostic export. Phase diagnostics retain identities,
|
||||
source kinds, scope counts, transition expansions, and completion bindings
|
||||
while omitting exact scopes, paths, credential references, and full digests.
|
||||
- Maintenance summaries and log-tail responses redact local log paths before
|
||||
returning data to the UI.
|
||||
- SQLite posture diagnostics omit the database path, mount point, and mount
|
||||
source. Known-unsafe or unknown filesystems refuse startup before Maintenance
|
||||
becomes reachable.
|
||||
- Journal conversion never runs inside a normal API request. An authenticated
|
||||
admin schedules an exact, short-lived preview; the bootstrap executes it
|
||||
before any SQLite consumer opens. A verified backup and fsynced external stage
|
||||
journal remain available after failures.
|
||||
- `DELETE` compatibility mode is explicitly degraded and single-host. It
|
||||
requires `VERITAS_SQLITE_TOPOLOGY=single-host`, a stable
|
||||
`VERITAS_SQLITE_HOST_ID`, an expiring/revocable signed policy, and a
|
||||
process/host ownership lock. Clustered or known-unsafe storage is rejected.
|
||||
- Schedule a return to supported-local WAL mode before a compatibility policy
|
||||
expires. Expired or revoked policy fails readiness immediately and refuses
|
||||
the next database open; it never silently widens or renews authority.
|
||||
- Debug bundles exclude raw tokens, token hashes, cookies, private keys, raw
|
||||
prompts, raw chat content, and generated sensitive text.
|
||||
- Local home, project, storage, runtime, and log paths are redacted in bundle
|
||||
|
|
@ -42,6 +65,7 @@ Focused regression coverage:
|
|||
|
||||
```bash
|
||||
pnpm --filter @veritas-kanban/server test -- maintenance-service.test.ts
|
||||
pnpm --filter @veritas-kanban/server exec vitest run src/__tests__/sqlite-journal-maintenance-service.test.ts src/__tests__/sqlite-journal-ownership-policy.test.ts
|
||||
pnpm --filter @veritas-kanban/server test -- skill-security-service.test.ts
|
||||
pnpm --filter @veritas-kanban/web test -- settings-maintenance-mantine.test.tsx
|
||||
```
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
This document defines the v5 file-to-SQLite rollback and failed-upgrade drill.
|
||||
|
||||
For a complete packaged Mac cutover, including the already-populated desktop
|
||||
case, use
|
||||
[`WEB-TO-MAC-DESKTOP-MIGRATION.md`](WEB-TO-MAC-DESKTOP-MIGRATION.md). Never run
|
||||
file-to-SQLite migration against the authoritative database while the desktop
|
||||
server has it open, and never copy a live WAL database as a backup.
|
||||
|
||||
## Recovery Contract
|
||||
|
||||
The v5 migration must never leave a project in a state where neither file
|
||||
|
|
@ -39,6 +45,12 @@ Interrupted migrations that were writing a new SQLite database use a temporary
|
|||
database path and promote it only after writes and checkpointing complete. A
|
||||
failed temporary database is removed after journaling the failure.
|
||||
|
||||
For desktop cutover, migrate into a fresh staging database with the desktop app
|
||||
stopped. After the migration process closes the database, checkpoint it, run
|
||||
`PRAGMA quick_check`, and install it only while no process owns the desktop
|
||||
target. If the desktop database already contains the expected records, do not
|
||||
rerun migration. Back it up and use the first-launch **Use Existing Data** path.
|
||||
|
||||
## Restore Drill
|
||||
|
||||
The rollback drill restores the file-backed state from the pre-migration backup:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
# SOP: Agent Task Workflow (Create → Work → Complete)
|
||||
|
||||
Use this playbook anytime an agent (human or LLM) takes a task from **todo** to **done**. It standardizes status changes, time tracking, summaries, and ensures telemetry stays usable.
|
||||
Use this playbook when a human or external self-reporting agent takes a task
|
||||
from **todo** to **done**. It standardizes status changes, time tracking,
|
||||
summaries, and telemetry.
|
||||
|
||||
This SOP assumes an agent or human is already doing the work. Veritas Kanban can create agent requests and track status, but it does not execute model work unless a runner/provider such as OpenClaw, Codex CLI/SDK, Codex Cloud, or a custom process is configured.
|
||||
> Managed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, and
|
||||
> OpenClaw runs do not call `vk begin`, `vk done`, lifecycle endpoints, or
|
||||
> telemetry APIs. VK and the selected adapter own those transitions. Managed
|
||||
> agents follow the shared protocol in [AGENTS-TEMPLATE.md](AGENTS-TEMPLATE.md).
|
||||
|
||||
---
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Responsibilities |
|
||||
| ------------------ | --------------------------------------------------------------------------------------- |
|
||||
| **Human / PM** | Defines clear task + acceptance criteria, reviews results, enforces cross-model review. |
|
||||
| **Worker Agent** | Picks up a task, updates status/time, posts results, flags blockers. |
|
||||
| **Reviewer Agent** | Opposite-model reviewer for code or high-risk work (see Cross-Model SOP). |
|
||||
| Role | Responsibilities |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| **Human / PM** | Defines clear task and acceptance criteria, reviews results, and sets any required review policy. |
|
||||
| **Worker Agent** | Picks up a task, updates status/time, posts results, and flags blockers. |
|
||||
| **Reviewer Agent** | Performs an independent review when the task or configured policy requires it. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -25,7 +30,7 @@ This SOP assumes an agent or human is already doing the work. Veritas Kanban can
|
|||
| 2. Work | Agent executes subtasks; marks subtasks complete as it goes. | ✅ |
|
||||
| 3. Update | Post intermediate comment(s) or blockers; set status `blocked` if waiting on human. | As needed |
|
||||
| 4. Complete | Stop timer, set status `done`, provide completion summary + attachments, capture lessons learned. | ✅ |
|
||||
| 5. Review | Trigger cross-model review if code touched or risk level ≥ medium. | ✅ for code |
|
||||
| 5. Review | Run an independent or cross-model review when the task or governance policy requires it. | Conditional |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -193,7 +198,8 @@ URL: http://localhost:3000/task/<ID>
|
|||
- Stop timer + set status done (vk done <id> "summary").
|
||||
- Attach deliverables / link to repo.
|
||||
- Fill the lessons learned field if anything should go into AGENTS/CLAUDE.
|
||||
5. If you touched code, queue cross-model review task before marking done.
|
||||
5. If the task or configured governance policy requires independent review,
|
||||
queue the review before marking done.
|
||||
```
|
||||
|
||||
Store this under `prompt-registry/agent-task-workflow.md` so every agent run is consistent.
|
||||
|
|
@ -240,11 +246,11 @@ System events render as divider lines in the UI — visually distinct from regul
|
|||
|
||||
## Escalation
|
||||
|
||||
| Situation | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------- |
|
||||
| Blocked > 15 minutes | Set status `blocked`, leave blocker comment, ping PM. |
|
||||
| Time tracking forgotten | Start timer immediately, add manual entry for elapsed time with reason. |
|
||||
| Reviewer disagrees | Re-open task, create subtasks for fixes, keep cross-model reviewer in the loop. |
|
||||
| Situation | Action |
|
||||
| ----------------------- | --------------------------------------------------------------------------------- |
|
||||
| Blocked > 15 minutes | Set status `blocked`, leave blocker comment, ping PM. |
|
||||
| Time tracking forgotten | Start timer immediately, add manual entry for elapsed time with reason. |
|
||||
| Reviewer disagrees | Re-open task, create subtasks for fixes, and keep the assigned reviewer informed. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Use this playbook when Veritas Kanban delegates work to OpenAI Codex. v4.3 inclu
|
|||
| **Human / PM** | Defines task scope, confirms Codex mode, reviews outputs, approves final merge. |
|
||||
| **Veritas Orchestrator** | Creates worktree, selects provider, starts attempt, tracks status/logs/telemetry. |
|
||||
| **Codex Worker** | Implements, tests, reports final summary, and leaves useful run evidence. |
|
||||
| **Reviewer Agent** | Performs cross-model review when Codex authored code or reviewed another agent. |
|
||||
| **Reviewer Agent** | Performs an independent review when the task or governance policy requires one. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -31,16 +31,16 @@ Default for v4.3 is **Codex CLI**. Use **Codex SDK** when a task needs a durable
|
|||
|
||||
## Lifecycle Overview
|
||||
|
||||
| Stage | Action | Required? |
|
||||
| ------------ | ---------------------------------------------------------------------- | --------- |
|
||||
| 0. Configure | Add Codex agent profile and verify `codex` install/auth. | Yes |
|
||||
| 1. Prepare | Create or verify task worktree; render task prompt. | Yes |
|
||||
| 2. Start | Veritas starts provider attempt and marks task `in-progress`. | Yes |
|
||||
| 3. Run | Codex executes with scoped prompt and emits progress/log events. | Yes |
|
||||
| 4. Observe | Veritas maps JSONL/SDK events into attempt logs, activity, telemetry. | Yes |
|
||||
| 5. Complete | Veritas records final summary, deliverables, usage, and task outcome. | Yes |
|
||||
| 6. Review | Opposite-model review runs for code or high-risk changes. | For code |
|
||||
| 7. Close | Human or automation approves, merges, archives, or creates follow-ups. | Yes |
|
||||
| Stage | Action | Required? |
|
||||
| ------------ | ----------------------------------------------------------------------- | ----------- |
|
||||
| 0. Configure | Add Codex agent profile and verify `codex` install/auth. | Yes |
|
||||
| 1. Prepare | Create or verify task worktree; render task prompt. | Yes |
|
||||
| 2. Start | Veritas starts provider attempt and marks task `in-progress`. | Yes |
|
||||
| 3. Run | Codex executes with scoped prompt and emits progress/log events. | Yes |
|
||||
| 4. Observe | Veritas maps JSONL/SDK events into attempt logs, activity, telemetry. | Yes |
|
||||
| 5. Complete | Veritas records final summary, deliverables, usage, and task outcome. | Yes |
|
||||
| 6. Review | Independent review runs when required by the task or governance policy. | Conditional |
|
||||
| 7. Close | Human or automation approves, merges, archives, or creates follow-ups. | Yes |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -65,10 +65,17 @@ export VK_API_KEY="<agent-role-key-if-auth-required>"
|
|||
export CODEX_API_KEY="<optional-api-key-for-automation>"
|
||||
```
|
||||
|
||||
When the selected agent profile has `sandboxPresetId`, Veritas validates the
|
||||
policy before launch and derives the effective Codex sandbox arguments and
|
||||
environment passthrough from that preset. Required unsupported controls block
|
||||
the attempt before Codex starts; advisory controls continue with warnings and a
|
||||
redacted governance trace.
|
||||
|
||||
### Veritas Behavior
|
||||
|
||||
1. Resolve the selected agent to a provider: `codex-cli`.
|
||||
2. Create an attempt with provider metadata:
|
||||
2. Dry-run the selected sandbox policy preset when one is assigned.
|
||||
3. Create an attempt with provider metadata:
|
||||
```json
|
||||
{
|
||||
"agent": "codex",
|
||||
|
|
@ -77,8 +84,8 @@ export CODEX_API_KEY="<optional-api-key-for-automation>"
|
|||
"sandbox": "workspace-write"
|
||||
}
|
||||
```
|
||||
3. Run Codex in the task worktree.
|
||||
4. Parse JSONL events:
|
||||
4. Run Codex in the task worktree.
|
||||
5. Parse JSONL events:
|
||||
- `thread.started`
|
||||
- `turn.started`
|
||||
- `item.started`
|
||||
|
|
@ -86,9 +93,10 @@ export CODEX_API_KEY="<optional-api-key-for-automation>"
|
|||
- `turn.completed`
|
||||
- `turn.failed`
|
||||
- `error`
|
||||
5. Append human-readable attempt logs.
|
||||
6. Preserve final response as the completion summary.
|
||||
7. Emit telemetry and token usage when available.
|
||||
6. Append human-readable attempt logs.
|
||||
7. Preserve final response as the completion summary.
|
||||
8. Let Veritas project lifecycle telemetry and provider-reported token usage;
|
||||
do not emit duplicate events from the managed Codex run.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -109,6 +117,9 @@ const thread = codex.startThread({
|
|||
const result = await thread.run('Implement the Veritas task in the current worktree.');
|
||||
```
|
||||
|
||||
Codex SDK starts use the same preset dry-run path as CLI starts. Prefer SDK mode
|
||||
for required network disablement because the SDK supports that control directly.
|
||||
|
||||
Veritas persists the Codex thread ID in attempt metadata:
|
||||
|
||||
```json
|
||||
|
|
@ -204,25 +215,23 @@ codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp
|
|||
|
||||
---
|
||||
|
||||
## AGENTS.md Codex Snippet
|
||||
## AGENTS.md managed-run snippet
|
||||
|
||||
Add this to a repository where Codex will work with Veritas:
|
||||
Use the harness-neutral managed-run block from
|
||||
[AGENTS-TEMPLATE.md](AGENTS-TEMPLATE.md). Codex does not need a separate VK
|
||||
lifecycle protocol:
|
||||
|
||||
```md
|
||||
## Veritas Kanban Protocol
|
||||
## Veritas Kanban managed-run protocol
|
||||
|
||||
When working on Veritas Kanban tasks:
|
||||
|
||||
1. Treat Veritas Kanban as the source of truth for task state.
|
||||
2. Before implementation, inspect the task, acceptance criteria, worktree, and related docs.
|
||||
3. Move the task to `in-progress` and ensure an attempt is tracked.
|
||||
4. Keep notes in task comments or progress files when findings affect future work.
|
||||
5. Run relevant tests/checks before completion.
|
||||
6. Report final summary, files changed, tests run, risks, and follow-ups.
|
||||
7. For code changes, request cross-model review before final completion.
|
||||
8. Use the Veritas MCP server when available instead of ad hoc HTTP calls.
|
||||
|
||||
For OpenAI product/API questions, use the OpenAI developer documentation MCP server first.
|
||||
1. Treat the supplied task envelope as authoritative.
|
||||
2. Work only in the assigned worktree and obey its commit policy.
|
||||
3. Use only the run-scoped tools and credentials supplied by Veritas.
|
||||
4. Do not register, heartbeat, start, complete, or emit telemetry manually.
|
||||
5. Do not call `vk begin` or `vk done`; Veritas already owns the attempt.
|
||||
6. Run focused verification that matches the requested change.
|
||||
7. Return the outcome, changed files or artifacts, checks, risks, and blockers
|
||||
through the normal Codex final response.
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -245,16 +254,14 @@ If `autoTelemetry` is enabled, avoid double-emitting lifecycle events. Token usa
|
|||
|
||||
---
|
||||
|
||||
## Review Rules
|
||||
## Optional review rules
|
||||
|
||||
| Author | Reviewer Recommendation |
|
||||
| ------------ | -------------------------------------------------- |
|
||||
| Codex | Claude, Gemini, or another non-Codex reviewer |
|
||||
| Claude | Codex review or GPT-family reviewer |
|
||||
| Human | Codex review for complex code or high-risk changes |
|
||||
| Codex review | Human adjudicates blocking findings |
|
||||
Independent review is not a default completion gate. Enable it only when the
|
||||
task, configured review gate, issue owner, or release owner requires it.
|
||||
|
||||
Follow [SOP-cross-model-code-review.md](SOP-cross-model-code-review.md) for scoring, findings, and final gate handling.
|
||||
When required, follow
|
||||
[SOP-cross-model-code-review.md](SOP-cross-model-code-review.md) for scoring,
|
||||
findings, and final gate handling.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
# SOP: Cross-Model Code Review (Claude ↔ GPT)
|
||||
# Optional Independent Code Review Playbook
|
||||
|
||||
**Rule (non-negotiable):** If Claude wrote it, GPT reviews it. If GPT wrote it, Claude reviews it. The author may self-check during development, but the final gate must be a different model.
|
||||
This legacy-path document describes an optional independent-review workflow.
|
||||
It is not part of the default delivery SOP or release gate. Use it only when
|
||||
the task, configured review gate, issue owner, or release owner explicitly
|
||||
requests it.
|
||||
|
||||
---
|
||||
|
||||
## When to Trigger
|
||||
|
||||
| Work Type | Review Required? |
|
||||
| -------------------------------- | ----------------------------------- |
|
||||
| Application code, infra, scripts | ✅ Always |
|
||||
| Docs/content | ⚠️ Only if accuracy/safety critical |
|
||||
| Research summaries | Optional (human discretion) |
|
||||
| Work Type | Review Required? |
|
||||
| -------------------------------- | ------------------------ |
|
||||
| Application code, infra, scripts | When explicitly required |
|
||||
| Docs/content | When explicitly required |
|
||||
| Research summaries | When explicitly required |
|
||||
|
||||
If in doubt, review.
|
||||
If no review requirement is present, use focused self-verification and the
|
||||
normal human/CI review path.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -24,7 +28,7 @@ If in doubt, review.
|
|||
- Type: `code`
|
||||
- Sprint/project identical
|
||||
- Description includes acceptance criteria + diff link(s)
|
||||
3. **Assign to opposite model** (via OpenClaw or other orchestrator):
|
||||
3. **Assign an independent reviewer** (human or configured agent):
|
||||
```
|
||||
Hey Codex, review PR for task_1234. Checklist below.
|
||||
```
|
||||
|
|
@ -44,7 +48,8 @@ If in doubt, review.
|
|||
## Verdict
|
||||
Changes required.
|
||||
```
|
||||
7. **Audit trail**: Update commit message or PR description with `[author: claude-sonnet-4-5][reviewed-by: gpt-5.1-codex]`.
|
||||
7. **Audit trail**: Record the reviewer and outcome in the task or pull request
|
||||
when attribution is required by the configured policy.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -65,7 +70,7 @@ Adapt per task type.
|
|||
## Prompt Template (Reviewer)
|
||||
|
||||
```
|
||||
You are the cross-model reviewer. The code was authored by <model>. Apply the checklist:
|
||||
You are the independent reviewer. Apply the checklist:
|
||||
1. Pull latest branch <branch>.
|
||||
2. Run tests (if any).
|
||||
3. For each issue, note severity (High/Medium/Low/Nit) + file/line + fix suggestion.
|
||||
|
|
@ -97,15 +102,16 @@ Store in `prompt-registry/cross-model-review.md`.
|
|||
|
||||
## Review Gates (Veritas Kanban Enforcement)
|
||||
|
||||
VK's built-in enforcement gates integrate directly with the cross-model review workflow, turning this SOP from a process suggestion into a structural guarantee:
|
||||
VK's built-in enforcement gates can make this optional playbook a structural
|
||||
requirement for selected workspaces or tasks:
|
||||
|
||||
1. **reviewGate** — Blocks task completion unless all four reviewScores (security, reliability, performance, accessibility) are 10. This is the automated enforcement layer that ensures the cross-model review checklist has been completed rigorously.
|
||||
1. **reviewGate** — Blocks task completion unless all four reviewScores (security, reliability, performance, accessibility) are 10. This is the automated enforcement layer that ensures the configured review checklist has been completed rigorously.
|
||||
|
||||
2. **closingComments** — Requires a substantive review comment (≥20 characters) before task completion. Ensures the reviewer leaves documented findings, not just scores.
|
||||
|
||||
3. **How they work together**:
|
||||
- Author (Model A) completes code; task remains `in-progress`
|
||||
- Reviewer (Model B) runs the cross-model review checklist
|
||||
- Independent reviewer runs the configured review checklist
|
||||
- Reviewer scores all 4 dimensions via the API: `PATCH /api/tasks/{id}` with `reviewScores`
|
||||
- Reviewer leaves findings as comments (must be ≥20 chars if closingComments enabled)
|
||||
- If reviewGate is enabled, task **cannot** move to `done` until all scores are 10
|
||||
|
|
@ -121,10 +127,13 @@ VK's built-in enforcement gates integrate directly with the cross-model review w
|
|||
|
||||
5. **Handling gate failures** — If task completion returns a 400 error with `REVIEW_GATE_FAILED` or `CLOSING_COMMENT_REQUIRED`, the reviewer must address the deficiency (raise a score, add a comment) and retry.
|
||||
|
||||
6. **Recommendation**: Enable both `reviewGate` and `closingComments` for production workflows. This transforms the cross-model review from a process suggestion into a structural guarantee—no task can slip through without evidence of a thorough review.
|
||||
6. **Recommendation**: Enable `reviewGate` and `closingComments` only when the
|
||||
workspace deliberately requires independent scored review. Leave them off
|
||||
when normal task verification and human/CI review are sufficient.
|
||||
|
||||
7. Full documentation: See [Enforcement Gates](enforcement.md) for all available gates, configuration options, and API reference.
|
||||
|
||||
---
|
||||
|
||||
This SOP preserved a 91% accuracy rate in RF-002. Keep following it.
|
||||
RF-002 recorded a 91% accuracy rate for this review method. That result supports
|
||||
using the method when selected; it does not make it a universal gate.
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ AI agents rely on documentation to understand context, conventions, and constrai
|
|||
|
||||
Every project should maintain these files:
|
||||
|
||||
| File | Purpose | Update Cadence |
|
||||
| ------------------------ | -------------------------------------- | ----------------------------- |
|
||||
| `CLAUDE.md` | Agent rules, patterns, lessons learned | After every mistake/discovery |
|
||||
| `AGENTS.md` | Agent personality, escalation rules | When workflow changes |
|
||||
| `docs/BEST-PRACTICES.md` | Team patterns and anti-patterns | Monthly or after post-mortems |
|
||||
| `prompt-registry/*.md` | Workflow prompts | When prompts drift or improve |
|
||||
| `README.md` | Project overview, quick start | After major releases |
|
||||
| File | Purpose | Update Cadence |
|
||||
| ------------------------ | -------------------------------------- | ------------------------------------ |
|
||||
| `AGENTS.md` | Canonical agent rules and architecture | After toolchain/architecture changes |
|
||||
| `CLAUDE.md` | Claude-specific supplement | When Claude behavior differs |
|
||||
| `docs/BEST-PRACTICES.md` | Team patterns and anti-patterns | Monthly or after post-mortems |
|
||||
| `prompt-registry/*.md` | Workflow prompts | When prompts drift or improve |
|
||||
| `README.md` | Project overview, quick start | After major releases |
|
||||
|
||||
Register maintained living documents in Settings → Doc Freshness. The registry
|
||||
record, not an optional Markdown comment, is authoritative for the last review,
|
||||
reviewer, maximum age, score, and alerts. Historical evidence and release notes
|
||||
do not need synthetic freshness headers.
|
||||
|
||||
### Optional Model-Specific Files
|
||||
|
||||
|
|
@ -43,9 +48,13 @@ Every project should maintain these files:
|
|||
|
||||
Update docs **within the same session** when:
|
||||
|
||||
1. **A bug was caused by missing context** — Add the context to CLAUDE.md
|
||||
2. **Cross-model review catches a pattern** — Document the pattern
|
||||
3. **A workaround is discovered** — Add to Troubleshooting or CLAUDE.md
|
||||
1. **A bug was caused by missing context** — Add durable shared context to
|
||||
`AGENTS.md`, or a harness-specific supplement when it truly differs
|
||||
2. **Focused review catches a pattern** — Document the pattern regardless of
|
||||
whether the reviewer is a maintainer, an independent agent, or a configured
|
||||
governance gate
|
||||
3. **A workaround is discovered** — Add it to Troubleshooting or the nearest
|
||||
applicable instruction file
|
||||
4. **API behavior changes** — Update relevant docs
|
||||
|
||||
### Scheduled Updates
|
||||
|
|
@ -84,7 +93,8 @@ Run this monthly or after major releases:
|
|||
|
||||
- [ ] Prompts reference current API endpoints
|
||||
- [ ] No prompts for removed features
|
||||
- [ ] Cross-model review prompt matches current checklist
|
||||
- [ ] Optional review prompts match the current checklist and are not described
|
||||
as default delivery gates
|
||||
|
||||
### README.md
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ When you ask “Hey Veritas, can you be the PM for this sprint and assign sub-ag
|
|||
- Spawn worker agent with clear instructions + acceptance criteria.
|
||||
4. **Track**:
|
||||
- Update Agent Status panel using `vk agent sub-agent <count>`.
|
||||
- Make sure each worker uses `vk begin/done` so timers stay accurate.
|
||||
- Managed workers rely on VK-owned attempt timing. Only human or external
|
||||
self-reporting workers use `vk begin`/`vk done`.
|
||||
5. **Review**:
|
||||
- Run cross-model review before marking tasks done.
|
||||
- Run independent or cross-model review only when required by the task or
|
||||
configured governance policy.
|
||||
- Request fixes via subtasks or comments.
|
||||
6. **Report**:
|
||||
- Post updates in task comments and daily standups (`vk summary standup --text`).
|
||||
|
|
@ -40,11 +42,11 @@ Task: <ID> — <Title>
|
|||
Context: <link to research/requirements>
|
||||
Deliverable: <clear definition of done>
|
||||
Steps:
|
||||
1. Run vk begin <id>.
|
||||
1. Treat the Veritas task envelope as authoritative.
|
||||
2. Complete subtasks in order. Leave notes if deviations occur.
|
||||
3. If blocked, set status blocked + explain.
|
||||
4. On completion, vk done <id> "summary".
|
||||
5. Request cross-model review by creating task <new id> tagged review.
|
||||
3. If blocked, report the blocker through an available VK tool or final output.
|
||||
4. Return a concise completion summary through the native harness result.
|
||||
5. Request an independent review only when the task or policy requires it.
|
||||
Uploads: <where to store artifacts>
|
||||
```
|
||||
|
||||
|
|
@ -116,8 +118,8 @@ See [SQUAD-CHAT-PROTOCOL.md](SQUAD-CHAT-PROTOCOL.md) for full details.
|
|||
1. **Human**: `sessions_spawn` Opus with task “Be PM for US-1600”.
|
||||
2. **Opus (PM)**: Reads sprint tasks, assigns `US-1601` to itself (docs) and `US-1602` to Codex.
|
||||
3. **Opus**: Runs `vk agent sub-agent 1` to show a worker is active.
|
||||
4. **Opus**: Spawns Codex worker with handoff template; instructs to run `vk begin task_...` etc.
|
||||
5. **Codex**: Executes, posts completion summary, requests cross-model review from Claude.
|
||||
4. **Opus**: Spawns the Codex worker with the managed-run handoff template.
|
||||
5. **Codex**: Executes and returns its completion summary through the managed run.
|
||||
6. **Opus**: Reviews, marks done, updates sprint recap comment.
|
||||
7. **Opus**: Sets agent status back to idle once all workers complete (`vk agent idle`).
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,26 @@ The Scoring Framework lets you define profiles with weighted criteria that evalu
|
|||
|
||||
**Scorer types:**
|
||||
|
||||
| Type | What it checks |
|
||||
| ------------------- | ------------------------------------------------------------ |
|
||||
| `RegexMatch` | Whether the output matches a regular expression |
|
||||
| `KeywordContains` | Whether the output contains required keywords |
|
||||
| `NumericRange` | Whether a numeric field in the output falls within a range |
|
||||
| `CustomExpression` | A custom evaluation expression |
|
||||
| Type | What it checks |
|
||||
| ------------------ | --------------------------------------------------------------------- |
|
||||
| `RegexMatch` | Whether bounded worker-isolated regex evaluation matches |
|
||||
| `KeywordContains` | Whether the output contains required keywords |
|
||||
| `NumericRange` | Whether a numeric field in the output falls within a range |
|
||||
| `OccurrenceRatio` | Literal occurrence density with optional numeric-path normalization |
|
||||
|
||||
`RegexMatch` accepts patterns up to 256 characters and any valid JavaScript regex flag set supported
|
||||
by the active Node runtime. Evaluation uses a globally bounded four-worker pool outside the server
|
||||
event loop, a bounded wait queue, and a 100 ms limit. Output is limited to 100,000 characters,
|
||||
action text to 10,000 characters, and their combined scoring target to 110,001 characters.
|
||||
|
||||
`OccurrenceRatio` is the declarative replacement for legacy custom expressions. It accepts one to
|
||||
32 literal `needles` and divides their occurrence count by either a fixed `denominator` or a numeric
|
||||
`denominatorPath`, optionally scaled with `denominatorScale`. `wholeWord`, `caseSensitive`,
|
||||
`minimumDenominator`, and `invert` provide bounded transformations without executing code.
|
||||
|
||||
Persisted profiles containing the removed `CustomExpression` scorer fail closed during evaluation.
|
||||
Replace those scorers through the profile API before retrying; the server never evaluates or
|
||||
silently converts the stored expression.
|
||||
|
||||
**Composite methods:**
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ Example frontmatter:
|
|||
id: cross-model-review
|
||||
name: Cross Model Review
|
||||
category: evaluation
|
||||
description: Opposite-model review checklist
|
||||
description: Optional independent review checklist
|
||||
---
|
||||
|
||||
# Cross Model Review
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue