@cairnkeep/cli 2.26.0 → 2.27.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 +23 -0
- package/docs/README.md +4 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +7 -1
- 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/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 +1 -1
- package/docs/learning/lessons/L29-team-continuity.md +10 -1
- package/docs/learning/tracks/operator.md +3 -0
- package/docs/learning/video-scripts/V29-team-continuity.md +12 -2
- package/docs/operating.md +4 -0
- package/docs/privacy-and-data-flow.md +9 -0
- package/docs/releasing.md +6 -0
- package/docs/storage.md +9 -0
- package/docs/team-onboarding.md +4 -0
- package/docs/team-operations.md +127 -0
- package/docs/team.md +10 -4
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/team-audit.js +41 -0
- package/mcp-memory-server/dist/team-cli.js +68 -27
- package/mcp-memory-server/dist/team-errors.js +7 -0
- package/mcp-memory-server/dist/team-files.js +28 -0
- package/mcp-memory-server/dist/team-mcp.js +4 -3
- package/mcp-memory-server/dist/team-pages.js +40 -0
- package/mcp-memory-server/dist/team-store.js +98 -39
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +1 -1
- package/scripts/completion.sh +8 -3
- package/scripts/test-ci-sharding.sh +6 -1
- package/scripts/test-completion.sh +5 -2
- package/scripts/test-release-cache.mjs +16 -0
- package/scripts/test-security-baseline.sh +3 -0
- package/scripts/test-smoke-timings.mjs +19 -0
- package/scripts/test-team-docs.sh +16 -1
- package/scripts/test-team-installed.mjs +11 -0
- package/scripts/test-team-lab-env.mjs +9 -0
- package/scripts/test-team-operations.mjs +31 -0
- package/scripts/test-team-operations.sh +5 -0
- package/scripts/verify-published-release.mjs +11 -2
- package/scripts/windows-platform.mjs +1 -1
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { readStableJson } from './stable-file.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
|
|
7
|
+
import { writePrivateExclusive } from './team-files.js';
|
|
8
|
+
export { writePrivateExclusive } from './team-files.js';
|
|
8
9
|
import { createTeamHttpServer, teamRequest } from './team-http.js';
|
|
9
10
|
import { createTeamMcpServer, teamClientToken } from './team-mcp.js';
|
|
10
11
|
import { readTeamConnection, connectionToken } from './team-client.js';
|
|
12
|
+
import { verifyTeamAuditArchive } from './team-audit.js';
|
|
11
13
|
const HELP = `cairn team — experimental token-first team continuity (default off)
|
|
12
14
|
|
|
13
15
|
CAIRN_TEAM=1 is required. Personal memory is never uploaded.
|
|
@@ -23,7 +25,7 @@ CAIRN_TEAM=1 is required. Personal memory is never uploaded.
|
|
|
23
25
|
cairn team disconnect [--project-root PATH] [--apply --yes]
|
|
24
26
|
cairn team doctor --client [--project-root PATH]
|
|
25
27
|
cairn team mcp [--project-root PATH | --connection FILE] # local stdio MCP; no review/admin tools
|
|
26
|
-
cairn team list --project ID
|
|
28
|
+
cairn team list --project ID [--page-size N --cursor CURSOR]
|
|
27
29
|
cairn team proposals --project ID
|
|
28
30
|
cairn team audit --project ID # scoped, value-free metadata
|
|
29
31
|
cairn team read --project ID --key KEY [--revision DIGEST]
|
|
@@ -32,8 +34,11 @@ CAIRN_TEAM=1 is required. Personal memory is never uploaded.
|
|
|
32
34
|
cairn team proposal-show --project ID --proposal ID
|
|
33
35
|
cairn team propose --project ID --input FILE --confirm VALUE_DIGEST
|
|
34
36
|
cairn team review --project ID --proposal ID --decision approve|reject --confirm PROPOSAL_DIGEST
|
|
35
|
-
cairn team doctor [--data DIR]
|
|
36
|
-
cairn team backup --output FILE [--data DIR]
|
|
37
|
+
cairn team doctor [--data DIR] # capacity and audit checkpoint
|
|
38
|
+
cairn team backup --output FILE [--read-only] [--data DIR]
|
|
39
|
+
cairn team audit-export --backup FILE --output FILE [--data DIR]
|
|
40
|
+
cairn team audit-verify --input FILE [--previous FILE]
|
|
41
|
+
cairn team audit-archive --input FILE --backup FILE --confirm DIGEST [--apply] [--data DIR]
|
|
37
42
|
cairn team restore --input FILE --confirm SNAPSHOT_DIGEST --data NEW_DIR
|
|
38
43
|
cairn team memory-delete --project ID --key KEY --confirm REVISION_DIGEST [--data DIR]
|
|
39
44
|
|
|
@@ -41,15 +46,25 @@ Clients require CAIRN_TEAM_URL, CAIRN_TEAM_ORGANIZATION and CAIRN_TEAM_TOKEN_FIL
|
|
|
41
46
|
Never put tokens in command arguments, source trees or harness config.
|
|
42
47
|
Team API is not OAuth MCP HTTP. Independent production security review and a
|
|
43
48
|
deployment-specific approved real-data pilot remain admission gates. See docs/team.md.
|
|
49
|
+
Paging is optional for list/search/history/proposals/audit. Repeat filters and
|
|
50
|
+
page size with the returned next_cursor. Search pages are at most ten items.
|
|
51
|
+
Audit export is metadata-only and does not mutate the store. Archival defaults
|
|
52
|
+
to a no-write plan, retains files, and explicitly upgrades the team store to v2.
|
|
44
53
|
`;
|
|
45
54
|
function parse(args) {
|
|
46
55
|
const words = [];
|
|
47
56
|
const flags = {};
|
|
48
|
-
const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision', 'connection']);
|
|
57
|
+
const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision', 'connection', 'backup', 'apply', 'read-only', 'page-size', 'cursor', 'previous']);
|
|
49
58
|
for (let i = 0; i < args.length; i++) {
|
|
50
59
|
const arg = args[i];
|
|
51
60
|
if (arg.startsWith('--')) {
|
|
52
61
|
const name = arg.slice(2);
|
|
62
|
+
if (['apply', 'read-only'].includes(name)) {
|
|
63
|
+
if (flags[name] !== undefined)
|
|
64
|
+
throw new TeamError('invalid');
|
|
65
|
+
flags[name] = 'true';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
53
68
|
if (!known.has(name) || flags[name] !== undefined || !args[i + 1] || args[i + 1].startsWith('--'))
|
|
54
69
|
throw new TeamError('invalid');
|
|
55
70
|
flags[name] = args[++i];
|
|
@@ -59,20 +74,6 @@ function parse(args) {
|
|
|
59
74
|
}
|
|
60
75
|
return { words, flags };
|
|
61
76
|
}
|
|
62
|
-
export function writePrivateExclusive(path, bytes) {
|
|
63
|
-
path = resolve(path);
|
|
64
|
-
const directory = dirname(path);
|
|
65
|
-
prepareTeamPrivateDirectory(directory, true);
|
|
66
|
-
const fd = openSync(path, 'wx', 0o600);
|
|
67
|
-
try {
|
|
68
|
-
hardenPrivatePath(path);
|
|
69
|
-
writeFileSync(fd, bytes, 'utf8');
|
|
70
|
-
fsyncSync(fd);
|
|
71
|
-
}
|
|
72
|
-
finally {
|
|
73
|
-
closeSync(fd);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
77
|
async function main(args) {
|
|
77
78
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
78
79
|
console.log(HELP);
|
|
@@ -85,10 +86,12 @@ async function main(args) {
|
|
|
85
86
|
const allowed = {
|
|
86
87
|
init: ['data', 'organization'], project: ['data'], member: subcommand === 'remove' ? ['data', 'project'] : ['data', 'project', 'roles'],
|
|
87
88
|
credential: subcommand === 'issue' ? ['data', 'class', 'expires-at', 'output'] : ['data'],
|
|
88
|
-
serve: ['data', 'port'], mcp: ['project-root', 'connection'], doctor: ['data'], backup: ['data', 'output'],
|
|
89
|
+
serve: ['data', 'port'], mcp: ['project-root', 'connection'], doctor: ['data'], backup: ['data', 'output', 'read-only'],
|
|
90
|
+
'audit-export': ['data', 'backup', 'output'], 'audit-archive': ['data', 'backup', 'input', 'confirm', 'apply'],
|
|
91
|
+
'audit-verify': ['input', 'previous'],
|
|
89
92
|
restore: ['data', 'input', 'confirm'], 'memory-delete': ['data', 'project', 'key', 'confirm'],
|
|
90
|
-
list: ['project'], proposals: ['project'], audit: ['project'], read: ['project', 'key', 'revision'],
|
|
91
|
-
history: ['project', 'key'], search: ['project', 'query'], 'proposal-show': ['project', 'proposal'],
|
|
93
|
+
list: ['project', 'page-size', 'cursor'], proposals: ['project', 'page-size', 'cursor'], audit: ['project', 'page-size', 'cursor'], read: ['project', 'key', 'revision'],
|
|
94
|
+
history: ['project', 'key', 'page-size', 'cursor'], search: ['project', 'query', 'page-size', 'cursor'], 'proposal-show': ['project', 'proposal'],
|
|
92
95
|
propose: ['project', 'input', 'confirm'], review: ['project', 'proposal', 'decision', 'confirm'],
|
|
93
96
|
};
|
|
94
97
|
if (!allowed[command] || Object.keys(flags).some(flag => !allowed[command].includes(flag)))
|
|
@@ -100,6 +103,19 @@ async function main(args) {
|
|
|
100
103
|
const print = (result) => console.log(JSON.stringify(result, null, 2));
|
|
101
104
|
let store;
|
|
102
105
|
try {
|
|
106
|
+
if (command === 'audit-verify') {
|
|
107
|
+
if (words.length !== 1 || !flags.input)
|
|
108
|
+
throw new TeamError('invalid');
|
|
109
|
+
const load = (path) => verifyTeamAuditArchive(readStableJson(resolve(path), { label: 'Team audit archive', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
|
|
110
|
+
const archive = load(flags.input);
|
|
111
|
+
if (flags.previous) {
|
|
112
|
+
const previous = load(flags.previous), last = previous.entries[previous.entries.length - 1], checkpoint = archive.previous_checkpoint;
|
|
113
|
+
if (previous.organization !== archive.organization || !checkpoint || checkpoint.archive_digest !== previous.digest || checkpoint.backup_digest !== previous.backup_digest || checkpoint.sequence !== last.sequence || checkpoint.digest !== last.digest || checkpoint.previous_archive_digest !== (previous.previous_checkpoint?.archive_digest ?? null))
|
|
114
|
+
throw new TeamError('integrity');
|
|
115
|
+
}
|
|
116
|
+
print({ verified: true, archive_digest: archive.digest, events: archive.entries.length, first_sequence: archive.entries[0].sequence, last_sequence: archive.entries[archive.entries.length - 1].sequence, previous_archive_digest: archive.previous_checkpoint?.archive_digest ?? null, previous_link_verified: !!flags.previous, history_scope: 'single-segment-and-optional-previous-link' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
103
119
|
if (command === 'mcp') {
|
|
104
120
|
if (words.length !== 1)
|
|
105
121
|
throw new TeamError('invalid');
|
|
@@ -122,7 +138,7 @@ async function main(args) {
|
|
|
122
138
|
print({ restored: true, organization: store.organization, credentials_revoked: true });
|
|
123
139
|
return;
|
|
124
140
|
}
|
|
125
|
-
if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete'].includes(command)) {
|
|
141
|
+
if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete', 'audit-export', 'audit-archive'].includes(command)) {
|
|
126
142
|
if (command === 'init' && (words.length !== 1 || !flags.organization))
|
|
127
143
|
throw new TeamError('invalid');
|
|
128
144
|
store = await openTeamStore(root, command === 'init' ? { create: true, organization: flags.organization } : {});
|
|
@@ -133,11 +149,30 @@ async function main(args) {
|
|
|
133
149
|
if (command === 'backup' && words.length === 1) {
|
|
134
150
|
if (!flags.output)
|
|
135
151
|
throw new TeamError('invalid');
|
|
136
|
-
const snapshot = await store.snapshot();
|
|
152
|
+
const snapshot = await store.snapshot({ audit: flags['read-only'] !== 'true' });
|
|
137
153
|
writePrivateExclusive(flags.output, `${JSON.stringify(snapshot)}\n`);
|
|
138
154
|
print({ snapshot_digest: snapshot.digest, written: true });
|
|
139
155
|
return;
|
|
140
156
|
}
|
|
157
|
+
if (['audit-export', 'audit-archive'].includes(command) && words.length === 1) {
|
|
158
|
+
if (!flags.backup)
|
|
159
|
+
throw new TeamError('invalid');
|
|
160
|
+
const backup = verifyTeamSnapshot(readStableJson(resolve(flags.backup), { label: 'Team archival backup', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
|
|
161
|
+
if (command === 'audit-export') {
|
|
162
|
+
if (!flags.output)
|
|
163
|
+
throw new TeamError('invalid');
|
|
164
|
+
const archive = await store.exportAudit(backup);
|
|
165
|
+
writePrivateExclusive(flags.output, `${JSON.stringify(archive)}\n`);
|
|
166
|
+
print({ written: true, archive_digest: archive.digest, backup_digest: archive.backup_digest, events: archive.entries.length });
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (!flags.input || !flags.confirm)
|
|
170
|
+
throw new TeamError('invalid');
|
|
171
|
+
const archive = verifyTeamAuditArchive(readStableJson(resolve(flags.input), { label: 'Team audit archive', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
|
|
172
|
+
print(await store.archiveAudit(archive, backup, flags.confirm, flags.apply === 'true'));
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
141
176
|
if (command === 'memory-delete' && words.length === 1) {
|
|
142
177
|
await store.admin({ operation: 'memory-delete', project: flags.project, key: flags.key, confirm: flags.confirm });
|
|
143
178
|
print({ deleted: true, backup_copies_retained: true });
|
|
@@ -216,6 +251,12 @@ async function main(args) {
|
|
|
216
251
|
}
|
|
217
252
|
else
|
|
218
253
|
throw new TeamError('invalid');
|
|
254
|
+
if (flags['page-size'] || flags.cursor) {
|
|
255
|
+
const raw = flags['page-size'] ?? '100';
|
|
256
|
+
if (!/^[1-9][0-9]{0,2}$/.test(raw) || Number(raw) > 100 || (command === 'search' && Number(raw) > 10))
|
|
257
|
+
throw new TeamError('invalid');
|
|
258
|
+
input.page = { size: Number(raw), ...(flags.cursor ? { cursor: flags.cursor } : {}) };
|
|
259
|
+
}
|
|
219
260
|
print(await teamRequest(process.env.CAIRN_TEAM_URL ?? '', teamClientToken(), input));
|
|
220
261
|
}
|
|
221
262
|
finally {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { closeSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { hardenPrivatePath } from './platform-security.js';
|
|
4
|
+
import { prepareTeamPrivateDirectory } from './team-store.js';
|
|
5
|
+
export function writePrivateExclusive(path, bytes) {
|
|
6
|
+
path = resolve(path);
|
|
7
|
+
prepareTeamPrivateDirectory(dirname(path), true);
|
|
8
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
9
|
+
try {
|
|
10
|
+
hardenPrivatePath(path);
|
|
11
|
+
writeFileSync(fd, bytes, 'utf8');
|
|
12
|
+
fsyncSync(fd);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
closeSync(fd);
|
|
16
|
+
}
|
|
17
|
+
// Persist the new directory entry as well as its contents on POSIX.
|
|
18
|
+
// Node cannot open/fsync a directory handle on Windows.
|
|
19
|
+
if (process.platform !== 'win32') {
|
|
20
|
+
const parent = openSync(dirname(path), 'r');
|
|
21
|
+
try {
|
|
22
|
+
fsyncSync(parent);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
closeSync(parent);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -5,6 +5,7 @@ import { isCapabilityContractEnabled, resolveCapabilityStatus } from './capabili
|
|
|
5
5
|
import { teamRequest, validateTeamUrl } from './team-http.js';
|
|
6
6
|
import { TeamError, teamDigest } from './team-store.js';
|
|
7
7
|
import { resolveHttpToken } from './http-security.js';
|
|
8
|
+
import { teamPageSchema } from './team-pages.js';
|
|
8
9
|
const observation = (title) => ({ title, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } });
|
|
9
10
|
// The bridge crosses the local process boundary into a remote team service;
|
|
10
11
|
// openWorldHint is true even though team retrieval is deterministic and closed
|
|
@@ -49,10 +50,10 @@ export async function createTeamMcpServer(options) {
|
|
|
49
50
|
const key = z.string().regex(/^(decisions|patterns|constraints|pitfalls|conventions|bugs)\/[a-z0-9][a-z0-9-]{0,95}$/);
|
|
50
51
|
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
51
52
|
const schemas = {
|
|
52
|
-
team_memory_list: { project },
|
|
53
|
+
team_memory_list: { project, page: teamPageSchema.optional() },
|
|
53
54
|
team_memory_read: { project, key, revision: hash.optional() },
|
|
54
|
-
team_memory_search: { project, query: z.string().min(1).max(256) },
|
|
55
|
-
team_memory_history: { project, key },
|
|
55
|
+
team_memory_search: { project, query: z.string().min(1).max(256), page: teamPageSchema.extend({ size: z.number().int().min(1).max(10) }).optional() },
|
|
56
|
+
team_memory_history: { project, key, page: teamPageSchema.optional() },
|
|
56
57
|
team_memory_propose: { project, request_id: z.string().uuid(), key, value: z.string().min(1).max(65536), source_scope: z.enum(['selected-local', 'selected-project']), source_digest: hash, base_revision: hash.nullable(), expires_at: z.iso.datetime(), confirm: hash },
|
|
57
58
|
};
|
|
58
59
|
const operations = { team_memory_list: 'list', team_memory_read: 'read', team_memory_search: 'search', team_memory_history: 'history', team_memory_propose: 'propose' };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { TeamError } from './team-errors.js';
|
|
4
|
+
export const teamPageSchema = z.object({ size: z.number().int().min(1).max(100), cursor: z.string().min(1).max(2048).optional() }).strict();
|
|
5
|
+
const cursorSchema = z.object({ v: z.literal(1), scope: z.string().regex(/^[a-f0-9]{64}$/), view: z.string().regex(/^[a-f0-9]{64}$/), offset: z.number().int().positive().max(100000), upper: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) }).strict();
|
|
6
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const mac = (key, value) => createHmac('sha256', Buffer.from(key, 'hex')).update(value).digest();
|
|
8
|
+
export function pageTeamItems(items, secret, scope, page, audit = false) {
|
|
9
|
+
let offset = 0, upper = audit ? Number(items[0]?.sequence ?? 0) : 0;
|
|
10
|
+
let decoded;
|
|
11
|
+
if (page.cursor) {
|
|
12
|
+
try {
|
|
13
|
+
const match = /^([A-Za-z0-9_-]+)\.([a-f0-9]{64})$/.exec(page.cursor);
|
|
14
|
+
if (!match || !timingSafeEqual(mac(secret, match[1]), Buffer.from(match[2], 'hex')))
|
|
15
|
+
throw new Error();
|
|
16
|
+
decoded = cursorSchema.parse(JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8')));
|
|
17
|
+
if (decoded.scope !== hash(scope))
|
|
18
|
+
throw new Error();
|
|
19
|
+
offset = decoded.offset;
|
|
20
|
+
upper = decoded.upper;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new TeamError('invalid');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (audit)
|
|
27
|
+
items = items.filter(row => Number(row.sequence) <= upper);
|
|
28
|
+
const view = hash(JSON.stringify(items));
|
|
29
|
+
if (decoded && decoded.view !== view)
|
|
30
|
+
throw new TeamError('conflict');
|
|
31
|
+
if (offset > items.length)
|
|
32
|
+
throw new TeamError('invalid');
|
|
33
|
+
const selected = items.slice(offset, offset + page.size), next = offset + selected.length;
|
|
34
|
+
let cursor = null;
|
|
35
|
+
if (next < items.length) {
|
|
36
|
+
const bytes = Buffer.from(JSON.stringify({ v: 1, scope: hash(scope), view, offset: next, upper })).toString('base64url');
|
|
37
|
+
cursor = bytes + '.' + mac(secret, bytes).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
return { items: selected, next_cursor: cursor };
|
|
40
|
+
}
|
|
@@ -3,6 +3,10 @@ import { closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, op
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { hardenPrivatePath, privatePathIsSafe } from './platform-security.js';
|
|
6
|
+
import { TeamError } from './team-errors.js';
|
|
7
|
+
export { TeamError } from './team-errors.js';
|
|
8
|
+
import { teamAuditCheckpointSchema, verifyTeamAuditRows, verifyTeamAuditArchive } from './team-audit.js';
|
|
9
|
+
import { teamPageSchema, pageTeamItems } from './team-pages.js';
|
|
6
10
|
const id = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/);
|
|
7
11
|
const roles = z.array(z.enum(['reader', 'contributor', 'reviewer', 'maintainer', 'auditor'])).max(5);
|
|
8
12
|
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
@@ -22,21 +26,21 @@ const adminSchema = z.discriminatedUnion('operation', [
|
|
|
22
26
|
z.object({ operation: z.literal('credential-revoke'), credential: z.string().uuid() }).strict(),
|
|
23
27
|
z.object({ operation: z.literal('memory-delete'), project: id, key: memoryKey, confirm: digest }).strict(),
|
|
24
28
|
]);
|
|
25
|
-
const
|
|
26
|
-
const snapshotSchema = z.
|
|
29
|
+
const legacySnapshotSchema = z.object({ schema_version: z.literal(1), organization: id, records: z.array(z.object({ organization: id, project: id.or(z.literal('')), kind: z.enum(['project', 'member', 'credential', 'proposal', 'review', 'revision', 'head']), id: z.string().min(1).max(128), value: z.unknown() }).strict()).max(32768), audit: z.array(z.object({ sequence: z.number().int().positive(), organization: id, project: id.or(z.literal('')), value: z.string().max(4096), digest }).strict()).max(100000), digest }).strict();
|
|
30
|
+
const snapshotSchema = z.discriminatedUnion('schema_version', [legacySnapshotSchema, legacySnapshotSchema.extend({ schema_version: z.literal(2), audit_checkpoint: teamAuditCheckpointSchema })]);
|
|
27
31
|
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
28
32
|
const MAX_AUDIT_BYTES = 16 * 1024 * 1024;
|
|
29
33
|
export const TEAM_SNAPSHOT_MAX_BYTES = 64 * 1024 * 1024;
|
|
30
34
|
const requestBase = { organization: id, project: id };
|
|
31
35
|
const requestSchema = z.discriminatedUnion('operation', [
|
|
32
36
|
z.object({ ...requestBase, operation: z.literal('connection-check') }).strict(),
|
|
33
|
-
z.object({ ...requestBase, operation: z.literal('list') }).strict(),
|
|
34
|
-
z.object({ ...requestBase, operation: z.literal('audit') }).strict(),
|
|
35
|
-
z.object({ ...requestBase, operation: z.literal('search'), query: z.string().min(1).max(256) }).strict(),
|
|
37
|
+
z.object({ ...requestBase, operation: z.literal('list'), page: teamPageSchema.optional() }).strict(),
|
|
38
|
+
z.object({ ...requestBase, operation: z.literal('audit'), page: teamPageSchema.optional() }).strict(),
|
|
39
|
+
z.object({ ...requestBase, operation: z.literal('search'), page: teamPageSchema.optional(), query: z.string().min(1).max(256) }).strict(),
|
|
36
40
|
z.object({ ...requestBase, operation: z.literal('read'), key: memoryKey, revision: digest.optional() }).strict(),
|
|
37
|
-
z.object({ ...requestBase, operation: z.literal('history'), key: memoryKey }).strict(),
|
|
41
|
+
z.object({ ...requestBase, operation: z.literal('history'), page: teamPageSchema.optional(), key: memoryKey }).strict(),
|
|
38
42
|
z.object({ ...requestBase, operation: z.literal('propose'), ...proposalInput.shape }).strict(),
|
|
39
|
-
z.object({ ...requestBase, operation: z.literal('proposals') }).strict(),
|
|
43
|
+
z.object({ ...requestBase, operation: z.literal('proposals'), page: teamPageSchema.optional() }).strict(),
|
|
40
44
|
z.object({ ...requestBase, operation: z.literal('proposal-show'), proposal_id: z.string().uuid() }).strict(),
|
|
41
45
|
z.object({ ...requestBase, operation: z.literal('review'), proposal_id: z.string().uuid(), confirm: digest, decision: z.enum(['approve', 'reject']) }).strict(),
|
|
42
46
|
]);
|
|
@@ -55,13 +59,6 @@ async function immediate(db, operation) {
|
|
|
55
59
|
throw error;
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
|
-
export class TeamError extends Error {
|
|
59
|
-
code;
|
|
60
|
-
constructor(code) {
|
|
61
|
-
super(`Team ${code}.`);
|
|
62
|
-
this.code = code;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
62
|
export function teamDigest(text) { return createHash('sha256').update(text).digest('hex'); }
|
|
66
63
|
function checkedProposal(input) {
|
|
67
64
|
const value = valid(proposalSchema, input);
|
|
@@ -172,18 +169,7 @@ export function verifyTeamSnapshot(input) {
|
|
|
172
169
|
}
|
|
173
170
|
}
|
|
174
171
|
}
|
|
175
|
-
|
|
176
|
-
let sequence = 0;
|
|
177
|
-
let auditBytes = 0;
|
|
178
|
-
for (const row of snapshot.audit) {
|
|
179
|
-
const entry = valid(auditEntrySchema, JSON.parse(row.value));
|
|
180
|
-
auditBytes += Buffer.byteLength(row.value);
|
|
181
|
-
if (row.organization !== snapshot.organization || row.sequence !== ++sequence || entry.sequence !== sequence || entry.previous_digest !== previous || entry.organization !== row.organization || entry.project !== row.project || row.digest !== teamDigest(row.value))
|
|
182
|
-
throw new Error();
|
|
183
|
-
previous = row.digest;
|
|
184
|
-
}
|
|
185
|
-
if (auditBytes > MAX_AUDIT_BYTES + 256 * 1024)
|
|
186
|
-
throw new Error();
|
|
172
|
+
verifyTeamAuditRows(snapshot.organization, snapshot.audit, snapshot.schema_version === 2 ? snapshot.audit_checkpoint : null);
|
|
187
173
|
return snapshot;
|
|
188
174
|
}
|
|
189
175
|
catch {
|
|
@@ -301,7 +287,7 @@ export class TeamStore {
|
|
|
301
287
|
return options.organization;
|
|
302
288
|
});
|
|
303
289
|
const version = await db.prepare('SELECT value FROM team_meta WHERE id = ?').get('schema_version');
|
|
304
|
-
if (version?.value
|
|
290
|
+
if (!['1', '2'].includes(String(version?.value)))
|
|
305
291
|
throw new TeamError('integrity');
|
|
306
292
|
const store = new TeamStore(agent, org, root);
|
|
307
293
|
store.harden();
|
|
@@ -368,8 +354,9 @@ export class TeamStore {
|
|
|
368
354
|
async appendAudit(project, subject, credentialClass, operation, result, objectDigest = null) {
|
|
369
355
|
const db = this.agent.getDatabase();
|
|
370
356
|
const last = await db.prepare('SELECT sequence,digest FROM team_audit ORDER BY sequence DESC LIMIT 1').get();
|
|
371
|
-
const
|
|
372
|
-
const
|
|
357
|
+
const checkpoint = this.auditCheckpoint();
|
|
358
|
+
const sequence = valid(z.number().int().nonnegative(), last?.sequence ?? checkpoint?.sequence ?? 0) + 1;
|
|
359
|
+
const entry = { schema_version: 1, sequence, organization: this.organization, project, subject, credential_class: credentialClass, operation, result, object_digest: objectDigest, previous_digest: last?.digest ?? checkpoint?.digest ?? null, request_id: randomUUID(), at: new Date().toISOString() };
|
|
373
360
|
const value = JSON.stringify(entry);
|
|
374
361
|
const size = db.prepare('SELECT count(*) AS entries,coalesce(sum(length(CAST(value AS BLOB))),0) AS bytes FROM team_audit').get();
|
|
375
362
|
// Reserve bounded space for local revocation/recovery after a client's
|
|
@@ -466,8 +453,8 @@ export class TeamStore {
|
|
|
466
453
|
expires_at: credential.expires_at, roles: member.roles };
|
|
467
454
|
}
|
|
468
455
|
else if (request.operation === 'audit') {
|
|
469
|
-
const rows = await this.agent.getDatabase().prepare('SELECT value,digest FROM team_audit WHERE organization=? AND project=? ORDER BY sequence DESC LIMIT 100').all(this.organization, request.project);
|
|
470
|
-
result = rows.map(row => ({ ...JSON.parse(sqlText(row.value)), digest: row.digest }));
|
|
456
|
+
const rows = await this.agent.getDatabase().prepare('SELECT value,digest FROM team_audit WHERE organization=? AND project=? ORDER BY sequence DESC' + (request.page ? '' : ' LIMIT 100')).all(this.organization, request.project);
|
|
457
|
+
result = (request.page ? rows : rows.slice(0, 100)).map(row => ({ ...JSON.parse(sqlText(row.value)), digest: row.digest }));
|
|
471
458
|
}
|
|
472
459
|
else if (request.operation === 'propose') {
|
|
473
460
|
const { organization: _org, project: _project, operation: _op, ...rawInput } = request;
|
|
@@ -531,7 +518,7 @@ export class TeamStore {
|
|
|
531
518
|
result = proposal;
|
|
532
519
|
}
|
|
533
520
|
else {
|
|
534
|
-
result = await Promise.all(visible.slice(0, 100).map(async (proposal) => ({ id: proposal.id, digest: proposal.digest, key: proposal.input.key, subject: proposal.subject, expires_at: proposal.input.expires_at, status: (await this.get('review', request.project, proposal.id)) ? 'reviewed' : 'pending' })));
|
|
521
|
+
result = await Promise.all(visible.slice(0, request.page ? undefined : 100).map(async (proposal) => ({ id: proposal.id, digest: proposal.digest, key: proposal.input.key, subject: proposal.subject, expires_at: proposal.input.expires_at, status: (await this.get('review', request.project, proposal.id)) ? 'reviewed' : 'pending' })));
|
|
535
522
|
}
|
|
536
523
|
}
|
|
537
524
|
else if (request.operation === 'read') {
|
|
@@ -547,17 +534,29 @@ export class TeamStore {
|
|
|
547
534
|
}
|
|
548
535
|
}
|
|
549
536
|
else if (request.operation === 'history') {
|
|
550
|
-
result = (await this.rows('revision', request.project)).map(row => checkedRevision(row.value)).filter(revision => revision.key === request.key).sort((a, b) => b.created_at.localeCompare(a.created_at) || a.revision_digest.localeCompare(b.revision_digest)).slice(0, 100).map(({ value: _value, ...metadata }) => metadata);
|
|
537
|
+
result = (await this.rows('revision', request.project)).map(row => checkedRevision(row.value)).filter(revision => revision.key === request.key).sort((a, b) => b.created_at.localeCompare(a.created_at) || a.revision_digest.localeCompare(b.revision_digest)).slice(0, request.page ? undefined : 100).map(({ value: _value, ...metadata }) => metadata);
|
|
551
538
|
}
|
|
552
539
|
else {
|
|
553
540
|
const heads = await this.rows('head', request.project);
|
|
554
541
|
const revisions = await Promise.all(heads.map(async (head) => checkedRevision(await this.get('revision', request.project, valid(digest, head.value)))));
|
|
555
542
|
if (request.operation === 'search') {
|
|
556
543
|
const needle = request.query.toLowerCase();
|
|
557
|
-
result = revisions.filter(revision => `${revision.key}\n${revision.value}`.toLowerCase().includes(needle)).slice(0, 10).map(revision => this.presentRevision(revision));
|
|
544
|
+
result = revisions.filter(revision => `${revision.key}\n${revision.value}`.toLowerCase().includes(needle)).slice(0, request.page ? undefined : 10).map(revision => this.presentRevision(revision));
|
|
558
545
|
}
|
|
559
546
|
else
|
|
560
|
-
result = revisions.slice(0, 100).map(({ value: _value, ...metadata }) => metadata);
|
|
547
|
+
result = revisions.slice(0, request.page ? undefined : 100).map(({ value: _value, ...metadata }) => metadata);
|
|
548
|
+
}
|
|
549
|
+
if ('page' in request && request.page) {
|
|
550
|
+
if (!Array.isArray(result) || (request.operation === 'search' && request.page.size > 10))
|
|
551
|
+
throw new TeamError('invalid');
|
|
552
|
+
const db = this.agent.getDatabase();
|
|
553
|
+
let secret = db.prepare('SELECT value FROM team_meta WHERE id=?').get('pagination-secret')?.value;
|
|
554
|
+
if (!secret) {
|
|
555
|
+
secret = randomBytes(32).toString('hex');
|
|
556
|
+
db.prepare('INSERT INTO team_meta(id,value) VALUES(?,?)').run('pagination-secret', secret);
|
|
557
|
+
}
|
|
558
|
+
const scope = JSON.stringify([this.organization, request.project, credential.id, credential.subject, credential.credential_class, member.roles, project.policy_revision, request.operation, 'key' in request ? request.key : null, 'query' in request ? request.query : null, request.page.size, this.auditCheckpoint()?.archive_digest ?? null]);
|
|
559
|
+
result = pageTeamItems(result, valid(digest, secret), scope, request.page, request.operation === 'audit');
|
|
561
560
|
}
|
|
562
561
|
await this.appendAudit(request.project, credential.subject, credential.credential_class, request.operation, 'accepted', objectDigest);
|
|
563
562
|
return result;
|
|
@@ -583,12 +582,68 @@ export class TeamStore {
|
|
|
583
582
|
const db = this.agent.getDatabase();
|
|
584
583
|
const records = db.prepare('SELECT organization,project,kind,id,value FROM team_records ORDER BY organization,project,kind,id').all().map(row => ({ organization: sqlText(row.organization), project: sqlText(row.project), kind: sqlText(row.kind), id: sqlText(row.id), value: JSON.parse(sqlText(row.value)) }));
|
|
585
584
|
const audit = db.prepare('SELECT sequence,organization,project,value,digest FROM team_audit ORDER BY sequence').all();
|
|
586
|
-
const
|
|
585
|
+
const checkpoint = this.auditCheckpoint();
|
|
586
|
+
const body = checkpoint ? { schema_version: 2, organization: this.organization, records, audit, audit_checkpoint: checkpoint } : { schema_version: 1, organization: this.organization, records, audit };
|
|
587
587
|
return verifyTeamSnapshot({ ...body, digest: teamDigest(JSON.stringify(body)) });
|
|
588
588
|
}
|
|
589
|
-
|
|
589
|
+
auditCheckpoint() {
|
|
590
|
+
const db = this.agent.getDatabase();
|
|
591
|
+
const version = db.prepare('SELECT value FROM team_meta WHERE id=?').get('schema_version')?.value;
|
|
592
|
+
const row = db.prepare('SELECT value FROM team_meta WHERE id=?').get('audit-checkpoint');
|
|
593
|
+
if (version === '1' && !row)
|
|
594
|
+
return null;
|
|
595
|
+
if (version !== '2' || !row)
|
|
596
|
+
throw new TeamError('integrity');
|
|
597
|
+
try {
|
|
598
|
+
return teamAuditCheckpointSchema.parse(JSON.parse(sqlText(row.value)));
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
throw new TeamError('integrity');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
capacity() {
|
|
605
|
+
const db = this.agent.getDatabase();
|
|
606
|
+
const usage = (table, limit, bytesLimit) => {
|
|
607
|
+
const row = db.prepare(`SELECT count(*) AS entries,coalesce(sum(length(CAST(value AS BLOB))),0) AS bytes FROM ${table}`).get();
|
|
608
|
+
const entries = Number(row?.entries), bytes = Number(row?.bytes);
|
|
609
|
+
return { entries, bytes, limit, bytes_limit: bytesLimit, remaining_entries: Math.max(0, limit - entries), remaining_bytes: Math.max(0, bytesLimit - bytes), status: entries >= limit || bytes >= bytesLimit ? 'exhausted' : entries >= limit * 0.8 || bytes >= bytesLimit * 0.8 ? 'near-limit' : 'available' };
|
|
610
|
+
};
|
|
611
|
+
return { records: usage('team_records', 32768, MAX_RECORD_BYTES), audit: usage('team_audit', 99000, MAX_AUDIT_BYTES), local_audit_reserve: { entries: 1000, bytes: 256 * 1024 }, checkpoint: this.auditCheckpoint() };
|
|
612
|
+
}
|
|
613
|
+
async exportAudit(input) {
|
|
614
|
+
const backup = verifyTeamSnapshot(input);
|
|
615
|
+
return this.transaction(async () => {
|
|
616
|
+
const current = this.collectSnapshot();
|
|
617
|
+
if (current.digest !== backup.digest)
|
|
618
|
+
throw new TeamError('conflict');
|
|
619
|
+
const body = { schema_version: 1, organization: this.organization, backup_digest: backup.digest, previous_checkpoint: this.auditCheckpoint(), entries: current.audit };
|
|
620
|
+
return verifyTeamAuditArchive({ ...body, digest: teamDigest(JSON.stringify(body)) });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
async archiveAudit(input, backupInput, confirm, apply = false) {
|
|
624
|
+
const archive = verifyTeamAuditArchive(input), backup = verifyTeamSnapshot(backupInput);
|
|
625
|
+
if (archive.digest !== confirm || archive.organization !== this.organization || archive.backup_digest !== backup.digest)
|
|
626
|
+
throw new TeamError('conflict');
|
|
590
627
|
return this.transaction(async () => {
|
|
591
|
-
|
|
628
|
+
const current = this.collectSnapshot();
|
|
629
|
+
if (current.digest !== backup.digest || JSON.stringify(current.audit) !== JSON.stringify(archive.entries) || JSON.stringify(this.auditCheckpoint()) !== JSON.stringify(archive.previous_checkpoint))
|
|
630
|
+
throw new TeamError('conflict');
|
|
631
|
+
const last = archive.entries[archive.entries.length - 1];
|
|
632
|
+
if (apply) {
|
|
633
|
+
const db = this.agent.getDatabase();
|
|
634
|
+
const checkpoint = { sequence: last.sequence, digest: last.digest, archive_digest: archive.digest, backup_digest: backup.digest, previous_archive_digest: archive.previous_checkpoint?.archive_digest ?? null };
|
|
635
|
+
db.prepare('INSERT INTO team_meta(id,value) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run('audit-checkpoint', JSON.stringify(checkpoint));
|
|
636
|
+
db.prepare('UPDATE team_meta SET value=? WHERE id=?').run('2', 'schema_version');
|
|
637
|
+
db.exec('DELETE FROM team_audit');
|
|
638
|
+
await this.appendAudit('', 'local-operator', 'local-os', 'audit-archive', 'accepted', archive.digest);
|
|
639
|
+
}
|
|
640
|
+
return { applied: apply, events: archive.entries.length, archive_digest: archive.digest, backup_digest: backup.digest, last_sequence: last.sequence };
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
async snapshot(options = {}) {
|
|
644
|
+
return this.transaction(async () => {
|
|
645
|
+
if (options.audit !== false)
|
|
646
|
+
await this.appendAudit('', 'local-operator', 'local-os', 'snapshot', 'accepted');
|
|
592
647
|
return this.collectSnapshot();
|
|
593
648
|
});
|
|
594
649
|
}
|
|
@@ -599,7 +654,7 @@ export class TeamStore {
|
|
|
599
654
|
const snapshot = this.collectSnapshot();
|
|
600
655
|
// Report only a count. Never select a restore, expose paths or delete remnants.
|
|
601
656
|
const remnants = readdirSync(dirname(this.root)).filter(name => name.startsWith('.team-restore-') || name === `.${basename(this.root)}.restore.lock`).length;
|
|
602
|
-
return { ok: true, records: snapshot.records.length, audit_events: snapshot.audit.length, digest: snapshot.digest, temporary_remnants: remnants };
|
|
657
|
+
return { ok: true, records: snapshot.records.length, audit_events: snapshot.audit.length, digest: snapshot.digest, temporary_remnants: remnants, capacity: this.capacity() };
|
|
603
658
|
});
|
|
604
659
|
}
|
|
605
660
|
static async restore(root, input, confirm) {
|
|
@@ -635,6 +690,10 @@ export class TeamStore {
|
|
|
635
690
|
const value = row.kind === 'credential' ? { ...valid(credentialSchema, row.value), revoked: true } : row.value;
|
|
636
691
|
db.prepare('INSERT INTO team_records(organization,project,kind,id,value) VALUES(?,?,?,?,?)').run(row.organization, row.project, row.kind, row.id, JSON.stringify(value));
|
|
637
692
|
}
|
|
693
|
+
if (snapshot.schema_version === 2) {
|
|
694
|
+
db.prepare('UPDATE team_meta SET value=? WHERE id=?').run('2', 'schema_version');
|
|
695
|
+
db.prepare('INSERT INTO team_meta(id,value) VALUES(?,?)').run('audit-checkpoint', JSON.stringify(snapshot.audit_checkpoint));
|
|
696
|
+
}
|
|
638
697
|
for (const row of snapshot.audit)
|
|
639
698
|
db.prepare('INSERT INTO team_audit(sequence,organization,project,value,digest) VALUES(?,?,?,?,?)').run(row.sequence, row.organization, row.project, row.value, row.digest);
|
|
640
699
|
await store.appendAudit('', 'local-operator', 'local-os', 'restore', 'accepted', snapshot.digest);
|
package/package.json
CHANGED
package/scripts/cairn-cli.mjs
CHANGED
|
@@ -62,7 +62,7 @@ Usage:
|
|
|
62
62
|
cairn evidence <list|show|delete|prune|doctor>
|
|
63
63
|
cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>
|
|
64
64
|
cairn security doctor [--project PATH] [--json]
|
|
65
|
-
cairn team <connect|disconnect|init|project|member|credential|serve|mcp|list|read|search|history|propose|proposals|proposal-show|review|audit|doctor|backup|restore|memory-delete>
|
|
65
|
+
cairn team <connect|disconnect|init|project|member|credential|serve|mcp|list|read|search|history|propose|proposals|proposal-show|review|audit|audit-export|audit-verify|audit-archive|doctor|backup|restore|memory-delete>
|
|
66
66
|
cairn memory <path|export|import>
|
|
67
67
|
cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
|
|
68
68
|
cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
|
package/scripts/completion.sh
CHANGED
|
@@ -27,7 +27,7 @@ _cairn_complete() {
|
|
|
27
27
|
sync-omp) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
|
|
28
28
|
doctor) COMPREPLY=( $(compgen -W "--repair" -- "$current") ) ;;
|
|
29
29
|
security) COMPREPLY=( $(compgen -W "doctor --project --json" -- "$current") ) ;;
|
|
30
|
-
team) COMPREPLY=( $(compgen -W "connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete --organization --data --project --roles --class --expires-at --output --input --confirm --port --key --query --proposal --decision --revision --project-root --connection --client --url --token-file --harness --access --apply --yes" -- "$current") ) ;;
|
|
30
|
+
team) COMPREPLY=( $(compgen -W "connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit audit-export audit-verify audit-archive doctor backup restore memory-delete --backup --previous --page-size --cursor --read-only --organization --data --project --roles --class --expires-at --output --input --confirm --port --key --query --proposal --decision --revision --project-root --connection --client --url --token-file --harness --access --apply --yes" -- "$current") ) ;;
|
|
31
31
|
trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
|
|
32
32
|
artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
|
|
33
33
|
evidence) COMPREPLY=( $(compgen -W "list show delete prune doctor --status --json --dry-run --repair" -- "$current") ) ;;
|
|
@@ -134,7 +134,7 @@ _cairn() {
|
|
|
134
134
|
sync-omp) _arguments '--apply[apply changes]' '--live-root[omp agent root]:directory:_files -/' ;;
|
|
135
135
|
doctor) _arguments '--repair[repair trajectory metadata and indexes]' ;;
|
|
136
136
|
security) _values 'security command' doctor '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
|
|
137
|
-
team) _values 'team command' connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete '--organization[team option]:value:' '--data[team option]:value:' '--project[team option]:value:' '--roles[team option]:value:' '--class[team option]:value:' '--expires-at[team option]:value:' '--output[team option]:value:' '--input[team option]:value:' '--confirm[team option]:value:' '--port[team option]:value:' '--key[team option]:value:' '--query[team option]:value:' '--proposal[team option]:value:' '--decision[team option]:value:' '--revision[team option]:value:' '--apply[apply plan]' '--yes[confirm plan]' '--client[client diagnostics]' '--url[team URL]:value:' '--token-file[private credential file]:file:_files' '--harness[team adapters]:value:' '--access[tool profile]:value:(read-only full)' '--connection[private connection file]:file:_files' '--project-root[team option]:value:' ;;
|
|
137
|
+
team) _values 'team command' connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit audit-export audit-verify audit-archive doctor backup restore memory-delete '--previous[previous archive segment]:file:_files' '--backup[verified backup file]:file:_files' '--page-size[page bound]:value:' '--cursor[continuation]:value:' '--read-only[observe-only backup]' '--organization[team option]:value:' '--data[team option]:value:' '--project[team option]:value:' '--roles[team option]:value:' '--class[team option]:value:' '--expires-at[team option]:value:' '--output[team option]:value:' '--input[team option]:value:' '--confirm[team option]:value:' '--port[team option]:value:' '--key[team option]:value:' '--query[team option]:value:' '--proposal[team option]:value:' '--decision[team option]:value:' '--revision[team option]:value:' '--apply[apply plan]' '--yes[confirm plan]' '--client[client diagnostics]' '--url[team URL]:value:' '--token-file[private credential file]:file:_files' '--harness[team adapters]:value:' '--access[tool profile]:value:(read-only full)' '--connection[private connection file]:file:_files' '--project-root[team option]:value:' ;;
|
|
138
138
|
trajectory) _values 'trajectory command' list show prune ;;
|
|
139
139
|
artifact)
|
|
140
140
|
case $words[3] in
|
|
@@ -222,7 +222,12 @@ complete -c cairn -n "__fish_seen_subcommand_from doctor" -l repair
|
|
|
222
222
|
complete -c cairn -n "__fish_seen_subcommand_from security" -a "doctor"
|
|
223
223
|
complete -c cairn -n "__fish_seen_subcommand_from security" -l project -r
|
|
224
224
|
complete -c cairn -n "__fish_seen_subcommand_from security" -l json
|
|
225
|
-
complete -c cairn -n "__fish_seen_subcommand_from team" -a "connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete"
|
|
225
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -a "connect disconnect init project member credential create set remove issue revoke serve mcp list read search history propose proposals proposal-show review audit audit-export audit-verify audit-archive doctor backup restore memory-delete"
|
|
226
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -l previous -r
|
|
227
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -l backup -r
|
|
228
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -l page-size -r
|
|
229
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -l cursor -r
|
|
230
|
+
complete -c cairn -n "__fish_seen_subcommand_from team" -l read-only
|
|
226
231
|
complete -c cairn -n "__fish_seen_subcommand_from team" -l organization -r
|
|
227
232
|
complete -c cairn -n "__fish_seen_subcommand_from team" -l data -r
|
|
228
233
|
complete -c cairn -n "__fish_seen_subcommand_from team" -l project -r
|
|
@@ -12,12 +12,17 @@ const root = process.argv[2];
|
|
|
12
12
|
const { buildShardPlan, smokeChecks } = await import(
|
|
13
13
|
pathToFileURL(join(root, "mcp-memory-server", "scripts", "run-smoke-suite.mjs")));
|
|
14
14
|
const plan = buildShardPlan(3);
|
|
15
|
+
const measured = JSON.parse(readFileSync(join(root, 'mcp-memory-server/scripts/fixtures/windows-smoke-timings.json'), 'utf8'));
|
|
16
|
+
assert.equal(measured.schema_version,1);
|
|
17
|
+
for (const [name,seconds] of Object.entries(measured.measurements)) {
|
|
18
|
+
assert.equal(smokeChecks.find(row=>row.name===name)?.weight,seconds,`measured weight drift: ${name}`);
|
|
19
|
+
}
|
|
15
20
|
const flattened = plan.flatMap(({ checks }) => checks.map(({ name }) => name));
|
|
16
21
|
assert.equal(flattened.length, smokeChecks.length);
|
|
17
22
|
assert.equal(new Set(flattened).size, smokeChecks.length);
|
|
18
23
|
assert.deepEqual([...flattened].sort(), smokeChecks.map(({ name }) => name).sort());
|
|
19
24
|
assert.ok(plan.every(({ checks }) => checks.length > 0));
|
|
20
|
-
const teamContracts = ['team', 'team-review', 'team-http', 'team-mcp', 'team-restore', 'team-cli', 'team-onboarding'];
|
|
25
|
+
const teamContracts = ['team', 'team-review', 'team-http', 'team-mcp', 'team-restore', 'team-cli', 'team-onboarding', 'team-operations'];
|
|
21
26
|
for (const name of teamContracts) assert.ok(flattened.includes(name), `missing team contract: ${name}`);
|
|
22
27
|
assert.ok(plan.every(({ checks }) => checks.some(({ name }) => teamContracts.includes(name))),
|
|
23
28
|
'native ACL-heavy team contracts must be distributed across all Windows shards');
|