@cairnkeep/cli 2.23.1 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/docs/README.md +4 -0
- package/docs/agents.md +18 -0
- package/docs/containers.md +4 -0
- package/docs/design/team-mode.md +17 -9
- package/docs/harness-compatibility.md +17 -0
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +34 -1
- package/docs/learning/README.md +5 -3
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L04-memory-fundamentals.md +12 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +2 -2
- package/docs/learning/lessons/L29-team-continuity.md +95 -0
- package/docs/learning/tracks/operator.md +2 -0
- package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +13 -0
- package/docs/learning/video-scripts/V29-team-continuity.md +66 -0
- package/docs/mcp-tool-profiles.md +7 -0
- package/docs/memory-retrieval.md +84 -2
- package/docs/operating.md +22 -3
- package/docs/privacy-and-data-flow.md +37 -9
- package/docs/security-assurance.md +28 -0
- package/docs/storage.md +18 -0
- package/docs/team-pilot.md +132 -0
- package/docs/team.md +234 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/index.js +30 -2
- package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
- package/mcp-memory-server/dist/memory-reranker.js +109 -0
- package/mcp-memory-server/dist/team-cli.js +218 -0
- package/mcp-memory-server/dist/team-http.js +193 -0
- package/mcp-memory-server/dist/team-mcp.js +82 -0
- package/mcp-memory-server/dist/team-store.js +663 -0
- package/mcp-memory-server/package.json +1 -0
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +23 -3
- package/scripts/doctor.sh +17 -0
- package/scripts/lib/team-retention.mjs +29 -0
- package/scripts/probe-memory-reranker.mjs +22 -0
- package/scripts/test-ci-sharding.sh +4 -0
- package/scripts/test-completion.sh +8 -0
- package/scripts/test-container-health.mjs +2 -0
- package/scripts/test-container.sh +21 -1
- package/scripts/test-dependency-security.mjs +11 -1
- package/scripts/test-doctor.sh +28 -0
- package/scripts/test-package-install.sh +9 -0
- package/scripts/test-team-docs.sh +52 -0
- package/scripts/test-team-pilot.mjs +59 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/uninstall.sh +13 -0
- package/scripts/verify-dependency-security.mjs +3 -2
- package/scripts/verify-team-retention.mjs +3 -0
- package/scripts/windows-platform.mjs +14 -2
package/docs/team.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Team continuity preview
|
|
2
|
+
|
|
3
|
+
Status: opt-in experimental preview included in **Cairnkeep 2.25.0**;
|
|
4
|
+
**not general production admission**. Existing local memory and trusted
|
|
5
|
+
shared-token HTTP are unchanged. Installing or upgrading does not enable team
|
|
6
|
+
sharing, enroll users or start a team service.
|
|
7
|
+
|
|
8
|
+
The governed internal security review and a separately approved isolated
|
|
9
|
+
native-harness pilot are complete. That pilot used selected synthetic context,
|
|
10
|
+
one actual human reviewer and two workload identities; it was not a study with
|
|
11
|
+
two independent human participants or an external professional assessment.
|
|
12
|
+
Independent production review and deployment-specific acceptance remain
|
|
13
|
+
necessary before using this preview with real organization data.
|
|
14
|
+
|
|
15
|
+
Team memory is a separate store of explicitly selected, reviewed project
|
|
16
|
+
context. It never scans or uploads personal stores, prompts, transcripts or
|
|
17
|
+
repositories. The same project IDs work across harnesses and working folders.
|
|
18
|
+
Sharing changes visibility: inspect and redact the selected text first.
|
|
19
|
+
|
|
20
|
+
For a trial alongside unchanged personal memory, use the
|
|
21
|
+
[isolated pilot guide](team-pilot.md). It keeps stores, credentials, ports and
|
|
22
|
+
client profiles separate and distinguishes actual human review from rehearsal.
|
|
23
|
+
|
|
24
|
+
## Trust boundary
|
|
25
|
+
|
|
26
|
+
The initial service has one organization, server-owned subjects, explicit
|
|
27
|
+
project memberships and expiring/revocable human or workload credentials.
|
|
28
|
+
Caller-supplied actor labels do not grant authority. Each operation rechecks
|
|
29
|
+
credentials and membership inside its transaction.
|
|
30
|
+
|
|
31
|
+
Roles are independent: reader retrieves approved memory; contributor submits
|
|
32
|
+
and inspects their proposals; reviewer inspects and approves/rejects proposals;
|
|
33
|
+
auditor reads scoped audit metadata. Maintainer does **not** imply review or
|
|
34
|
+
read authority. Membership administration currently trusts the local OS owner,
|
|
35
|
+
not a remote maintainer token. Workload credentials cannot review, even when
|
|
36
|
+
their subject has a reviewer role. Self-review is always refused.
|
|
37
|
+
|
|
38
|
+
Proposals retain exact selected bytes, source digest, base revision, expiry and
|
|
39
|
+
policy digest. Review confirms the proposal digest; stale policy/base produces a
|
|
40
|
+
conflict, never automatic merging. Publication, head update and audit append are
|
|
41
|
+
atomic. Audit stores identities, digests and outcomes—not memory text or tokens.
|
|
42
|
+
Hash links detect corruption; they do not prevent a trusted storage owner from
|
|
43
|
+
rewriting the database. Digests establish integrity, not publisher authenticity.
|
|
44
|
+
|
|
45
|
+
## Enable explicitly
|
|
46
|
+
|
|
47
|
+
Use Node 22.13 or newer for the optional built-in SQLite store. Node 24/26 are
|
|
48
|
+
also supported. The core's Node engine floor remains 22; the optional team
|
|
49
|
+
store needs the later built-in SQLite API. Use current maintained patch releases.
|
|
50
|
+
No new runtime dependency or personal-store migration is needed.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
export CAIRN_TEAM=1
|
|
54
|
+
cairn team init --organization demo-org --data /absolute/private/team
|
|
55
|
+
cairn team project create alpha --data /absolute/private/team
|
|
56
|
+
cairn team member set alice --project alpha --roles reader,contributor --data /absolute/private/team
|
|
57
|
+
cairn team member set bob --project alpha --roles reader,reviewer --data /absolute/private/team
|
|
58
|
+
cairn team credential issue alice --class workload --expires-at 2026-12-01T00:00:00Z --output /absolute/private/alice.token --data /absolute/private/team
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Choose a future expiry appropriate to the deployment (maximum one year), not
|
|
62
|
+
the example date blindly. Credential output is exclusive and private; the raw
|
|
63
|
+
token is never printed. Keep tokens outside source/harness directories.
|
|
64
|
+
Organization state defaults to `~/.cairnkeep/team`, overridden by
|
|
65
|
+
`CAIRN_TEAM_BASE_DIR` or `--data`. This path convention is not required.
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
export CAIRN_TEAM_HTTP=1
|
|
69
|
+
cairn team serve --data /absolute/private/team --port 7955
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The server binds loopback only. Access remotely through an encrypted,
|
|
73
|
+
authenticated tunnel; direct unauthenticated/public exposure is unsupported.
|
|
74
|
+
The API refuses browser origins, unexpected Host headers, oversized/invalid
|
|
75
|
+
requests and revoked credentials. It does not support CORS browser applications.
|
|
76
|
+
This is a token-first JSON API—not an OAuth-authorized remote MCP endpoint.
|
|
77
|
+
OIDC/SSO and standards-compliant remote MCP HTTP authorization are deferred.
|
|
78
|
+
|
|
79
|
+
## Harness connection
|
|
80
|
+
|
|
81
|
+
Set `CAIRN_TEAM_URL`, `CAIRN_TEAM_ORGANIZATION` and `CAIRN_TEAM_TOKEN_FILE` in the
|
|
82
|
+
local harness environment, alongside `CAIRN_TEAM=1`. Tokens are file-only.
|
|
83
|
+
The URL may be loopback HTTP through a tunnel or HTTPS, without embedded
|
|
84
|
+
credentials, query, fragment or path prefix. Redirects are never followed.
|
|
85
|
+
|
|
86
|
+
Register **an additional** local stdio MCP named `cairn-team-memory`, command
|
|
87
|
+
`cairn`, arguments `team mcp --project-root /absolute/project`. Do not replace
|
|
88
|
+
the existing `cairn-memory` server or unrelated MCP entries. Every team call
|
|
89
|
+
names the explicit server project ID; cwd and display labels never select
|
|
90
|
+
authority. Existing personal memory remains independently available.
|
|
91
|
+
|
|
92
|
+
Tools: `team_memory_list`, `team_memory_read`, `team_memory_search`,
|
|
93
|
+
`team_memory_history` and `team_memory_propose`. No review/admin MCP exists.
|
|
94
|
+
Retrieval is deterministic substring matching, with no model/provider calls.
|
|
95
|
+
Personal-memory embeddings and reranking settings do not apply to team
|
|
96
|
+
retrieval; enabling them cannot send shared team text to a provider.
|
|
97
|
+
The remote boundary is marked open-world in MCP annotations; four retrieval
|
|
98
|
+
tools are observations, while proposal creation is an additive mutation and
|
|
99
|
+
requires the harness's explicit mutation consent. Approved memory is context,
|
|
100
|
+
not executable instruction or authority over maintained source.
|
|
101
|
+
|
|
102
|
+
The ordinary `CAIRN_MCP_TOOL_PROFILE`/project profile applies through equivalent
|
|
103
|
+
core names (`memory_list/read/search/history/write`). Optional
|
|
104
|
+
`CAIRN_TEAM_MCP_TOOL_PROFILE=full|read-only|custom` and
|
|
105
|
+
`CAIRN_TEAM_MCP_ALLOWED_TOOLS` further restrict the team catalog. Custom names
|
|
106
|
+
must be exact team tool names. `memory.write` and `memory.search` capability
|
|
107
|
+
settings restrict proposal and search when `CAIRN_CAPABILITY_CONTRACT` is on.
|
|
108
|
+
These intersections cannot grant server authority. Team results include a
|
|
109
|
+
separate effective profile digest; existing core/capability digests are unchanged.
|
|
110
|
+
|
|
111
|
+
Human CLI review uses a separately issued human credential in
|
|
112
|
+
`CAIRN_TEAM_TOKEN_FILE`:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
cairn team proposals --project alpha
|
|
116
|
+
cairn team proposal-show --project alpha --proposal PROPOSAL_ID
|
|
117
|
+
cairn team review --project alpha --proposal PROPOSAL_ID --decision approve --confirm PROPOSAL_DIGEST
|
|
118
|
+
cairn team search --project alpha --query gateway
|
|
119
|
+
cairn team read --project alpha --key decisions/gateway
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Propose with a private JSON input file containing request UUID, key, selected
|
|
123
|
+
value, source_scope (`selected-local` or `selected-project`), source_digest,
|
|
124
|
+
base_revision (null for a new key) and expires_at. Confirm the selected value's
|
|
125
|
+
UTF-8 SHA-256 with `cairn team propose --project alpha --input FILE --confirm DIGEST`.
|
|
126
|
+
Neither CLI nor MCP derives a candidate by reading personal memory for you.
|
|
127
|
+
Inspect the proposal's exact content and provenance before confirming review.
|
|
128
|
+
|
|
129
|
+
Failure is explicit; the bridge never falls back to personal/global storage or
|
|
130
|
+
a different identity. Local memory commands still work independently offline.
|
|
131
|
+
|
|
132
|
+
## Recovery, retention and credential rotation
|
|
133
|
+
|
|
134
|
+
Local administration requires access as the trusted storage OS owner. It is
|
|
135
|
+
not exposed over HTTP or MCP. Keep the data directory private (`0700` on POSIX,
|
|
136
|
+
owner-only ACL on Windows); database, WAL, credential and snapshot files are
|
|
137
|
+
private too. Do not put a live store inside a source checkout or another store.
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
cairn team doctor --data /absolute/private/team
|
|
141
|
+
cairn team backup --data /absolute/private/team --output /absolute/private/team-snapshot.json
|
|
142
|
+
cairn team restore --input /absolute/private/team-snapshot.json --confirm SNAPSHOT_DIGEST --data /absolute/private/restored-team
|
|
143
|
+
cairn team doctor --data /absolute/private/restored-team
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Backup is a consistent transaction including records and value-free audit.
|
|
147
|
+
The output must be a new file; it contains shared text, proposals, subject IDs,
|
|
148
|
+
memberships and token hashes. Encrypt and restrict retained copies separately:
|
|
149
|
+
the store and JSON snapshot are not encrypted by Cairnkeep. Restore checks
|
|
150
|
+
schema, object digests, references and audit links before publishing a new
|
|
151
|
+
directory. It never overwrites an existing store. Concurrent cooperating
|
|
152
|
+
restores are exclusive; a failure never activates partial restored state.
|
|
153
|
+
Every restored credential is revoked. Issue fresh credentials and explicitly
|
|
154
|
+
select the restored store only after doctor passes. No automatic service switch
|
|
155
|
+
or identity approval occurs. Snapshot digests prove integrity, not authenticity.
|
|
156
|
+
|
|
157
|
+
Doctor reports temporary-remnant counts without deleting or selecting them.
|
|
158
|
+
If an interrupted restore leaves a lock or temporary directory, stop competing
|
|
159
|
+
administration, verify process ownership and preserve a copy before manually
|
|
160
|
+
removing the exact abandoned path. Doctor never chooses which version to use.
|
|
161
|
+
|
|
162
|
+
Rotate a credential by issuing a fresh private token file, distributing it
|
|
163
|
+
through a separate secure channel, and revoking the old credential ID with
|
|
164
|
+
`cairn team credential revoke ID --data DIR`. Replacing the configured token
|
|
165
|
+
file makes the bridge use it on the next request; revocation and membership
|
|
166
|
+
changes are also enforced on already-connected clients. There are no cached
|
|
167
|
+
authorization grants. Protect human reviewer tokens from agent environments.
|
|
168
|
+
|
|
169
|
+
For a compromised workload token, revoke it first and remove project roles if
|
|
170
|
+
needed. Remove all of a subject's access to one project explicitly:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
cairn team member remove alice --project alpha --data /absolute/private/team
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This clears that project's roles immediately, including for connected clients.
|
|
177
|
+
Other project memberships and credentials remain unchanged; revoke credentials
|
|
178
|
+
separately for complete offboarding. Existing revisions and audit history are
|
|
179
|
+
retained. To re-enroll, use `member set` with an explicit nonempty `--roles` list.
|
|
180
|
+
Missing or empty role lists are rejected rather than silently removing access.
|
|
181
|
+
|
|
182
|
+
Inspect `cairn team audit --project ID` using an auditor credential;
|
|
183
|
+
accepted, denied and conflicted authenticated operations record bounded
|
|
184
|
+
metadata. Invalid credentials do not create unauthenticated identity records.
|
|
185
|
+
Audit integrity is not external tamper resistance against the OS owner.
|
|
186
|
+
|
|
187
|
+
Current retention is manual: approved revisions, pending/rejected proposals
|
|
188
|
+
and audit events do not expire out of storage automatically. Proposal expiry
|
|
189
|
+
prevents review, not retention. An operator can delete a selected shared key,
|
|
190
|
+
its revisions and associated proposals/reviews using its current head digest:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
cairn team memory-delete --project alpha --key decisions/gateway --confirm REVISION_DIGEST --data /absolute/private/team
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
This is logical deletion, not certified erasure: audit metadata, SQLite free
|
|
197
|
+
pages/WAL and older snapshots may retain traces or bytes. Apply a separately
|
|
198
|
+
reviewed storage/backup disposal policy if erasure is required. Ordinary
|
|
199
|
+
uninstall retains team data. Purge operations overlapping the configured team
|
|
200
|
+
store are refused before any uninstall changes; a store selected only through
|
|
201
|
+
`--data` must remain outside purge targets or also be set as
|
|
202
|
+
`CAIRN_TEAM_BASE_DIR` for that retention check.
|
|
203
|
+
|
|
204
|
+
## Bounded preview and admission
|
|
205
|
+
|
|
206
|
+
Selected values are at most 64 KiB UTF-8; requests 512 KiB; responses 2 MiB;
|
|
207
|
+
snapshots 64 MiB. Records are capped at 32,768 and 16 MiB of stored JSON;
|
|
208
|
+
audit at 100,000 events and 16 MiB, with a 1,000-event/256 KiB OS-operator reserve.
|
|
209
|
+
Limits fail closed rather than discarding records. Back up and review retention
|
|
210
|
+
before approaching them; the preview has no audit archival/compaction command.
|
|
211
|
+
Retrieval is bounded (100 list/history items, ten search results); the API
|
|
212
|
+
currently has no pagination. HTTP separately limits malformed authentication
|
|
213
|
+
per connection address and authenticated requests per subject to 120 per minute;
|
|
214
|
+
invalid token-shaped credentials receive ten attempts per token digest. Each
|
|
215
|
+
budget holds at most 512 identities, and invalid authentication cannot consume
|
|
216
|
+
an authenticated subject's quota. Headers are capped at 32 KiB, inactive
|
|
217
|
+
requests at ten seconds, and bridge fetches at fifteen seconds.
|
|
218
|
+
|
|
219
|
+
Digest confirmation proves selected bytes match; it cannot prove a human
|
|
220
|
+
clicked approval. MCP hosts must obtain mutation consent. Only separate human
|
|
221
|
+
CLI credentials can review, and the OS owner remains trusted to assign them.
|
|
222
|
+
No automatic setup, existing MCP replacement or fleet deployment is performed.
|
|
223
|
+
|
|
224
|
+
Release checks cover Node 22/24/26, native Windows, real Bash 3.2,
|
|
225
|
+
installed-package and container behavior. The internal governed review and
|
|
226
|
+
approved isolated native pilot covered exact human proposal approval,
|
|
227
|
+
cross-harness recall, offboarding, restore and project isolation. Automated
|
|
228
|
+
fixtures are regression evidence, not proof of human consent. These results do
|
|
229
|
+
not admit arbitrary production deployments: establish an independent security
|
|
230
|
+
review appropriate to the deployment, named operational ownership, a separately
|
|
231
|
+
approved limited real-data pilot with rollback, and explicit operator acceptance
|
|
232
|
+
before enabling a real organization. Keep reviewer credentials out of agents.
|
|
233
|
+
OIDC/SSO, multi-organization tenancy, team packs, remote maintainer administration,
|
|
234
|
+
provider retrieval and externally anchored audit are deferred.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.25.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -16,6 +16,7 @@ import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifact
|
|
|
16
16
|
import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
|
|
17
17
|
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
|
|
18
18
|
import { queryDomainKnowledge, resolveDomainRetrievalProvider } from "./domain-retrieval-provider.js";
|
|
19
|
+
import { getMemoryRerankerConfig, memoryRerankCandidateCount, rerankMemoryResults } from "./memory-reranker.js";
|
|
19
20
|
import { formatHostAuthority, normalizeHostAuthority, normalizeHttpHost, parseHttpPort, resolveHttpToken } from "./http-security.js";
|
|
20
21
|
import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
|
|
21
22
|
import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
|
|
@@ -377,7 +378,7 @@ function entryText(entry) {
|
|
|
377
378
|
function embeddingCachePath(scope, projectId) {
|
|
378
379
|
return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope, { projectId }))}.json`);
|
|
379
380
|
}
|
|
380
|
-
async function
|
|
381
|
+
async function semanticSearchCandidates(scope, query, topK, minScore, config = getMemoryConfig(), projectId, options = {}) {
|
|
381
382
|
const scopes = getSearchScopes(scope, config);
|
|
382
383
|
const embeddingConfig = getEmbeddingConfig();
|
|
383
384
|
const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({
|
|
@@ -480,6 +481,32 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
|
|
|
480
481
|
return substringFallback();
|
|
481
482
|
}
|
|
482
483
|
}
|
|
484
|
+
async function semanticSearch(...args) {
|
|
485
|
+
const reranker = getMemoryRerankerConfig();
|
|
486
|
+
if (!reranker)
|
|
487
|
+
return semanticSearchCandidates(...args);
|
|
488
|
+
const [, query, topK] = args;
|
|
489
|
+
const options = args[6] ?? {};
|
|
490
|
+
const candidateArgs = [...args];
|
|
491
|
+
candidateArgs[2] = memoryRerankCandidateCount(topK, reranker);
|
|
492
|
+
// The existing path still owns scope, current-value, typed and relevance
|
|
493
|
+
// admission. The external ranker never sees records rejected by that path.
|
|
494
|
+
const candidate = await semanticSearchCandidates(...candidateArgs);
|
|
495
|
+
const exactKeys = new Set([query, ...(options.aliases ?? [])].map(value => value.normalize('NFKC').toLowerCase().trim()));
|
|
496
|
+
const pinned = (entry) => options.strategy === 'hybrid'
|
|
497
|
+
? exactKeys.has(entry.key.normalize('NFKC').toLowerCase())
|
|
498
|
+
: Boolean(options.typed && isExactTextHit(entry, query));
|
|
499
|
+
const { results } = await rerankMemoryResults(candidate.results, query, topK, reranker, pinned);
|
|
500
|
+
const retrieval = candidate.retrieval;
|
|
501
|
+
if (retrieval?.possible_conflicts) {
|
|
502
|
+
const keys = new Set(results.map(entry => entry.key));
|
|
503
|
+
// Explain only selected keys, including when the larger candidate pool
|
|
504
|
+
// falls back. Do not leak explanations for unreturned candidates.
|
|
505
|
+
return { ...candidate, results, retrieval: { ...retrieval,
|
|
506
|
+
possible_conflicts: retrieval.possible_conflicts.filter(row => keys.has(row.key)) } };
|
|
507
|
+
}
|
|
508
|
+
return { ...candidate, results };
|
|
509
|
+
}
|
|
483
510
|
// Confines a candidate wiki source file to inside `<repoRoot>/.planning/wiki/sources`
|
|
484
511
|
// via relative()-based containment (Phase 2 SEC-0001 idiom, reused from
|
|
485
512
|
// opencode/plugins/memory-recall.ts) — `resolve() === join()` misses `../`
|
|
@@ -708,8 +735,9 @@ function gitToplevel(cwd) {
|
|
|
708
735
|
// serve many concurrent clients within one trusted server-side storage domain.
|
|
709
736
|
function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
|
|
710
737
|
const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
|
|
738
|
+
const memoryReranking = getMemoryRerankerConfig() !== null;
|
|
711
739
|
const registerTool = (name, config, callback) => {
|
|
712
|
-
const metadata = metadataForTool(name);
|
|
740
|
+
const metadata = metadataForTool(name, { memoryReranking });
|
|
713
741
|
if (!profileAllowsTool(toolProfile, name))
|
|
714
742
|
return;
|
|
715
743
|
server.registerTool(name, {
|
|
@@ -45,8 +45,11 @@ export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
|
|
|
45
45
|
export function isCairnToolName(value) {
|
|
46
46
|
return Object.hasOwn(MCP_TOOL_CATALOG, value);
|
|
47
47
|
}
|
|
48
|
-
export function metadataForTool(name) {
|
|
48
|
+
export function metadataForTool(name, options = {}) {
|
|
49
49
|
if (!isCairnToolName(name))
|
|
50
50
|
throw new Error(`MCP tool ${name} has no annotation contract.`);
|
|
51
|
+
if (name === 'memory_search' && options.memoryReranking) {
|
|
52
|
+
return { ...MCP_TOOL_CATALOG[name], annotations: { ...MCP_TOOL_CATALOG[name].annotations, openWorldHint: true } };
|
|
53
|
+
}
|
|
51
54
|
return MCP_TOOL_CATALOG[name];
|
|
52
55
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const MAX_CANDIDATES = 50;
|
|
3
|
+
const MAX_TEXT_BYTES = 4096;
|
|
4
|
+
const MAX_REQUEST_BYTES = 256 * 1024;
|
|
5
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
6
|
+
const responseSchema = z.object({ results: z.array(z.object({
|
|
7
|
+
index: z.number().int().min(0).max(MAX_CANDIDATES - 1),
|
|
8
|
+
relevance_score: z.number().finite(),
|
|
9
|
+
})).max(MAX_CANDIDATES) });
|
|
10
|
+
/** Configuration alone never grants permission: explicit opt-in is required. */
|
|
11
|
+
export function getMemoryRerankerConfig(env = process.env) {
|
|
12
|
+
if (env.CAIRN_MEMORY_RERANK !== '1')
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(env.CAIRN_MEMORY_RERANK_URL ?? '');
|
|
16
|
+
const model = env.CAIRN_MEMORY_RERANK_MODEL?.trim();
|
|
17
|
+
const rawTimeout = env.CAIRN_MEMORY_RERANK_TIMEOUT_MS ?? '2000';
|
|
18
|
+
const timeoutMs = Number(rawTimeout);
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
|
|
20
|
+
|| !model || Buffer.byteLength(model) > 256 || !/^\d+$/.test(rawTimeout)
|
|
21
|
+
|| !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000)
|
|
22
|
+
return null;
|
|
23
|
+
const apiKey = env.CAIRN_MEMORY_RERANK_API_KEY?.trim();
|
|
24
|
+
if (apiKey && (apiKey.length > 8192 || /[\r\n]/.test(apiKey)))
|
|
25
|
+
return null;
|
|
26
|
+
return { apiUrl: url.href.replace(/\/+$/, ''), model, ...(apiKey ? { apiKey } : {}), timeoutMs };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function memoryRerankCandidateCount(topK, config) {
|
|
33
|
+
if (!Number.isInteger(topK) || topK < 1 || topK > MAX_CANDIDATES)
|
|
34
|
+
throw new Error('Reranking requires a bounded result count.');
|
|
35
|
+
return config ? Math.min(MAX_CANDIDATES, Math.max(32, topK * 4)) : topK;
|
|
36
|
+
}
|
|
37
|
+
function textPrefix(value, maxUnits) {
|
|
38
|
+
let end = Math.min(value.length, maxUnits);
|
|
39
|
+
const last = value.charCodeAt(end - 1);
|
|
40
|
+
if (end < value.length && last >= 0xD800 && last <= 0xDBFF)
|
|
41
|
+
end--;
|
|
42
|
+
return value.slice(0, end);
|
|
43
|
+
}
|
|
44
|
+
function rankingText(entry) {
|
|
45
|
+
// Bound allocation before UTF-8 encoding, then discard an incomplete final
|
|
46
|
+
// code point rather than introducing replacement characters at the cutoff.
|
|
47
|
+
const text = `${textPrefix(entry.key, 1024)}\n${textPrefix(entry.value, MAX_TEXT_BYTES)}`;
|
|
48
|
+
const bytes = Buffer.from(text);
|
|
49
|
+
return new TextDecoder().decode(bytes.subarray(0, MAX_TEXT_BYTES), { stream: true });
|
|
50
|
+
}
|
|
51
|
+
async function readRankingResponse(response) {
|
|
52
|
+
if (!response.ok || !response.body || Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
|
|
53
|
+
await response.body?.cancel();
|
|
54
|
+
throw new Error('Reranker response unavailable.');
|
|
55
|
+
}
|
|
56
|
+
const reader = response.body.getReader();
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done)
|
|
63
|
+
break;
|
|
64
|
+
size += value.byteLength;
|
|
65
|
+
if (size > MAX_RESPONSE_BYTES)
|
|
66
|
+
throw new Error('Reranker response exceeds limit.');
|
|
67
|
+
chunks.push(value);
|
|
68
|
+
}
|
|
69
|
+
const bytes = Buffer.concat(chunks, size);
|
|
70
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await reader.cancel().catch(() => { });
|
|
74
|
+
reader.releaseLock();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Reorder admitted records only. No input object, score, scope or value is changed. */
|
|
78
|
+
export async function rerankMemoryResults(entries, query, topK, config, pinned = () => false) {
|
|
79
|
+
const baseline = entries.slice(0, topK);
|
|
80
|
+
if (!config)
|
|
81
|
+
return { results: baseline, status: 'disabled' };
|
|
82
|
+
if (entries.length < 2 || entries.length > MAX_CANDIDATES || !query.trim() || Buffer.byteLength(query) > MAX_TEXT_BYTES) {
|
|
83
|
+
return { results: baseline, status: 'skipped' };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const body = JSON.stringify({ model: config.model, query, documents: entries.map(rankingText), top_n: entries.length });
|
|
87
|
+
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES)
|
|
88
|
+
return { results: baseline, status: 'skipped' };
|
|
89
|
+
const response = await fetch(`${config.apiUrl}/rerank`, {
|
|
90
|
+
method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(config.timeoutMs),
|
|
91
|
+
headers: { 'Content-Type': 'application/json', ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) },
|
|
92
|
+
body,
|
|
93
|
+
});
|
|
94
|
+
const parsed = responseSchema.parse(await readRankingResponse(response));
|
|
95
|
+
if (parsed.results.length !== entries.length
|
|
96
|
+
|| new Set(parsed.results.map(row => row.index)).size !== entries.length
|
|
97
|
+
|| parsed.results.some(row => row.index >= entries.length))
|
|
98
|
+
throw new Error('Invalid ranking indices.');
|
|
99
|
+
const scores = new Map(parsed.results.map(row => [row.index, row.relevance_score]));
|
|
100
|
+
const ordered = entries.map((entry, index) => ({ entry, index, pinned: pinned(entry) }));
|
|
101
|
+
ordered.sort((a, b) => Number(b.pinned) - Number(a.pinned)
|
|
102
|
+
|| scores.get(b.index) - scores.get(a.index) || a.index - b.index);
|
|
103
|
+
return { results: ordered.slice(0, topK).map(row => row.entry), status: 'reranked' };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Never return endpoint errors, response bodies or credentials to clients.
|
|
107
|
+
return { results: baseline, status: 'failed' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { readStableJson } from './stable-file.js';
|
|
6
|
+
import { hardenPrivatePath } from './platform-security.js';
|
|
7
|
+
import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, prepareTeamPrivateDirectory, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
|
|
8
|
+
import { createTeamHttpServer, teamRequest } from './team-http.js';
|
|
9
|
+
import { createTeamMcpServer, teamClientToken } from './team-mcp.js';
|
|
10
|
+
const HELP = `cairn team — experimental token-first team continuity (default off)
|
|
11
|
+
|
|
12
|
+
CAIRN_TEAM=1 is required. Personal memory is never uploaded.
|
|
13
|
+
|
|
14
|
+
cairn team init --organization ID [--data DIR]
|
|
15
|
+
cairn team project create ID [--data DIR]
|
|
16
|
+
cairn team member set SUBJECT --project ID --roles LIST [--data DIR]
|
|
17
|
+
cairn team member remove SUBJECT --project ID [--data DIR]
|
|
18
|
+
cairn team credential issue SUBJECT --class human|workload --expires-at ISO --output FILE [--data DIR]
|
|
19
|
+
cairn team credential revoke ID [--data DIR]
|
|
20
|
+
cairn team serve [--data DIR] [--port PORT] # requires CAIRN_TEAM_HTTP=1; loopback only
|
|
21
|
+
cairn team mcp [--project-root PATH] # local stdio MCP; no review/admin tools
|
|
22
|
+
cairn team list --project ID
|
|
23
|
+
cairn team proposals --project ID
|
|
24
|
+
cairn team audit --project ID # scoped, value-free metadata
|
|
25
|
+
cairn team read --project ID --key KEY [--revision DIGEST]
|
|
26
|
+
cairn team history --project ID --key KEY
|
|
27
|
+
cairn team search --project ID --query TEXT
|
|
28
|
+
cairn team proposal-show --project ID --proposal ID
|
|
29
|
+
cairn team propose --project ID --input FILE --confirm VALUE_DIGEST
|
|
30
|
+
cairn team review --project ID --proposal ID --decision approve|reject --confirm PROPOSAL_DIGEST
|
|
31
|
+
cairn team doctor [--data DIR]
|
|
32
|
+
cairn team backup --output FILE [--data DIR]
|
|
33
|
+
cairn team restore --input FILE --confirm SNAPSHOT_DIGEST --data NEW_DIR
|
|
34
|
+
cairn team memory-delete --project ID --key KEY --confirm REVISION_DIGEST [--data DIR]
|
|
35
|
+
|
|
36
|
+
Clients require CAIRN_TEAM_URL, CAIRN_TEAM_ORGANIZATION and CAIRN_TEAM_TOKEN_FILE.
|
|
37
|
+
Never put tokens in command arguments, source trees or harness config.
|
|
38
|
+
Team API is not OAuth MCP HTTP. Independent production security review and a
|
|
39
|
+
deployment-specific approved real-data pilot remain admission gates. See docs/team.md.
|
|
40
|
+
`;
|
|
41
|
+
function parse(args) {
|
|
42
|
+
const words = [];
|
|
43
|
+
const flags = {};
|
|
44
|
+
const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision']);
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg.startsWith('--')) {
|
|
48
|
+
const name = arg.slice(2);
|
|
49
|
+
if (!known.has(name) || flags[name] !== undefined || !args[i + 1] || args[i + 1].startsWith('--'))
|
|
50
|
+
throw new TeamError('invalid');
|
|
51
|
+
flags[name] = args[++i];
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
words.push(arg);
|
|
55
|
+
}
|
|
56
|
+
return { words, flags };
|
|
57
|
+
}
|
|
58
|
+
export function writePrivateExclusive(path, bytes) {
|
|
59
|
+
path = resolve(path);
|
|
60
|
+
const directory = dirname(path);
|
|
61
|
+
prepareTeamPrivateDirectory(directory, true);
|
|
62
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
63
|
+
try {
|
|
64
|
+
hardenPrivatePath(path);
|
|
65
|
+
writeFileSync(fd, bytes, 'utf8');
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function main(args) {
|
|
73
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
74
|
+
console.log(HELP);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
78
|
+
throw new TeamError('disabled');
|
|
79
|
+
const { words, flags } = parse(args);
|
|
80
|
+
const [command, subcommand, subject] = words;
|
|
81
|
+
const allowed = {
|
|
82
|
+
init: ['data', 'organization'], project: ['data'], member: subcommand === 'remove' ? ['data', 'project'] : ['data', 'project', 'roles'],
|
|
83
|
+
credential: subcommand === 'issue' ? ['data', 'class', 'expires-at', 'output'] : ['data'],
|
|
84
|
+
serve: ['data', 'port'], mcp: ['project-root'], doctor: ['data'], backup: ['data', 'output'],
|
|
85
|
+
restore: ['data', 'input', 'confirm'], 'memory-delete': ['data', 'project', 'key', 'confirm'],
|
|
86
|
+
list: ['project'], proposals: ['project'], audit: ['project'], read: ['project', 'key', 'revision'],
|
|
87
|
+
history: ['project', 'key'], search: ['project', 'query'], 'proposal-show': ['project', 'proposal'],
|
|
88
|
+
propose: ['project', 'input', 'confirm'], review: ['project', 'proposal', 'decision', 'confirm'],
|
|
89
|
+
};
|
|
90
|
+
if (!allowed[command] || Object.keys(flags).some(flag => !allowed[command].includes(flag)))
|
|
91
|
+
throw new TeamError('invalid');
|
|
92
|
+
const memberRoles = (flags.roles ?? '').split(',').filter(Boolean);
|
|
93
|
+
if (command === 'member' && (!['set', 'remove'].includes(subcommand) || !subject || words.length !== 3 || !flags.project || (subcommand === 'set' && memberRoles.length === 0)))
|
|
94
|
+
throw new TeamError('invalid');
|
|
95
|
+
const root = resolve(flags.data ?? process.env.CAIRN_TEAM_BASE_DIR ?? join(homedir(), '.cairnkeep', 'team'));
|
|
96
|
+
const print = (result) => console.log(JSON.stringify(result, null, 2));
|
|
97
|
+
let store;
|
|
98
|
+
try {
|
|
99
|
+
if (command === 'mcp') {
|
|
100
|
+
if (words.length !== 1)
|
|
101
|
+
throw new TeamError('invalid');
|
|
102
|
+
teamClientToken(); // validate private credential before startup
|
|
103
|
+
const server = await createTeamMcpServer({ url: process.env.CAIRN_TEAM_URL ?? '', organization: process.env.CAIRN_TEAM_ORGANIZATION ?? '', token: () => teamClientToken(), projectRoot: flags['project-root'] });
|
|
104
|
+
await server.connect(new StdioServerTransport());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === 'restore') {
|
|
108
|
+
if (words.length !== 1 || !flags.input || !flags.confirm || !flags.data)
|
|
109
|
+
throw new TeamError('invalid');
|
|
110
|
+
const snapshot = verifyTeamSnapshot(readStableJson(resolve(flags.input), { label: 'Team snapshot', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
|
|
111
|
+
store = await restoreTeamSnapshot(root, snapshot, flags.confirm);
|
|
112
|
+
print({ restored: true, organization: store.organization, credentials_revoked: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete'].includes(command)) {
|
|
116
|
+
if (command === 'init' && (words.length !== 1 || !flags.organization))
|
|
117
|
+
throw new TeamError('invalid');
|
|
118
|
+
store = await openTeamStore(root, command === 'init' ? { create: true, organization: flags.organization } : {});
|
|
119
|
+
if (command === 'doctor' && words.length === 1) {
|
|
120
|
+
print(await store.doctor());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (command === 'backup' && words.length === 1) {
|
|
124
|
+
if (!flags.output)
|
|
125
|
+
throw new TeamError('invalid');
|
|
126
|
+
const snapshot = await store.snapshot();
|
|
127
|
+
writePrivateExclusive(flags.output, `${JSON.stringify(snapshot)}\n`);
|
|
128
|
+
print({ snapshot_digest: snapshot.digest, written: true });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (command === 'memory-delete' && words.length === 1) {
|
|
132
|
+
await store.admin({ operation: 'memory-delete', project: flags.project, key: flags.key, confirm: flags.confirm });
|
|
133
|
+
print({ deleted: true, backup_copies_retained: true });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === 'init') {
|
|
137
|
+
print({ initialized: true, organization: store.organization, experimental: true });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (command === 'project' && subcommand === 'create' && subject && words.length === 3) {
|
|
141
|
+
await store.admin({ operation: 'project-create', project: subject });
|
|
142
|
+
print({ created: true });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (command === 'member') {
|
|
146
|
+
await store.admin({ operation: 'member-set', subject, project: flags.project, roles: subcommand === 'remove' ? [] : memberRoles });
|
|
147
|
+
print(subcommand === 'remove' ? { removed: true } : { updated: true });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (command === 'credential' && subcommand === 'issue' && subject && words.length === 3) {
|
|
151
|
+
if (!flags.output || existsSync(resolve(flags.output)))
|
|
152
|
+
throw new TeamError('invalid');
|
|
153
|
+
const credential = await store.issue({ subject, credential_class: flags.class, expires_at: flags['expires-at'] });
|
|
154
|
+
try {
|
|
155
|
+
writePrivateExclusive(flags.output, `${credential.token}\n`);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
await store.admin({ operation: 'credential-revoke', credential: credential.id });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
print({ credential_id: credential.id, token_file_created: true });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'credential' && subcommand === 'revoke' && subject && words.length === 3) {
|
|
165
|
+
await store.admin({ operation: 'credential-revoke', credential: subject });
|
|
166
|
+
print({ revoked: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (command === 'serve' && words.length === 1) {
|
|
170
|
+
const raw = flags.port ?? '7955';
|
|
171
|
+
if (!/^[1-9][0-9]{0,4}$/.test(raw) || Number(raw) > 65535)
|
|
172
|
+
throw new TeamError('invalid');
|
|
173
|
+
const server = createTeamHttpServer(store);
|
|
174
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(raw), '127.0.0.1', resolve); });
|
|
175
|
+
process.stderr.write(`Team preview API listening on loopback port ${raw}.\n`);
|
|
176
|
+
await new Promise(resolve => {
|
|
177
|
+
const stop = () => { server.close(() => resolve()); server.closeAllConnections(); };
|
|
178
|
+
process.once('SIGINT', stop);
|
|
179
|
+
process.once('SIGTERM', stop);
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
throw new TeamError('invalid');
|
|
184
|
+
}
|
|
185
|
+
if (words.length !== 1 || !flags.project)
|
|
186
|
+
throw new TeamError('invalid');
|
|
187
|
+
const common = { organization: process.env.CAIRN_TEAM_ORGANIZATION, project: flags.project, operation: command };
|
|
188
|
+
let input;
|
|
189
|
+
if (['list', 'proposals', 'audit'].includes(command))
|
|
190
|
+
input = common;
|
|
191
|
+
else if (['read', 'history'].includes(command))
|
|
192
|
+
input = { ...common, key: flags.key, ...(flags.revision ? { revision: flags.revision } : {}) };
|
|
193
|
+
else if (command === 'search')
|
|
194
|
+
input = { ...common, query: flags.query };
|
|
195
|
+
else if (command === 'proposal-show')
|
|
196
|
+
input = { ...common, proposal_id: flags.proposal };
|
|
197
|
+
else if (command === 'review')
|
|
198
|
+
input = { ...common, proposal_id: flags.proposal, decision: flags.decision, confirm: flags.confirm };
|
|
199
|
+
else if (command === 'propose') {
|
|
200
|
+
if (!flags.input || !flags.confirm)
|
|
201
|
+
throw new TeamError('invalid');
|
|
202
|
+
const selected = readStableJson(resolve(flags.input), { label: 'Team selected proposal', maxBytes: 512 * 1024, private: true });
|
|
203
|
+
if (!selected || typeof selected !== 'object' || Array.isArray(selected))
|
|
204
|
+
throw new TeamError('invalid');
|
|
205
|
+
input = { ...selected, ...common, confirm: flags.confirm };
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
throw new TeamError('invalid');
|
|
209
|
+
print(await teamRequest(process.env.CAIRN_TEAM_URL ?? '', teamClientToken(), input));
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await store?.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
void main(process.argv.slice(2)).catch(error => {
|
|
216
|
+
process.stderr.write(`${error instanceof TeamError ? error.message : 'Team operation failed; no credential or input content is logged.'}\n`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
});
|