@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/README.md +4 -0
  3. package/docs/learning/CURRICULUM-MAP.md +1 -1
  4. package/docs/learning/FEATURE-GUIDE.md +7 -1
  5. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  6. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  7. package/docs/learning/lessons/L02-installation.md +2 -2
  8. package/docs/learning/lessons/L03-first-project.md +1 -1
  9. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  10. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  11. package/docs/learning/lessons/L21-context-packs.md +1 -1
  12. package/docs/learning/lessons/L22-native-windows.md +2 -2
  13. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  14. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  15. package/docs/learning/lessons/L25-playbooks.md +1 -1
  16. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  17. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  18. package/docs/learning/lessons/L28-security-assurance.md +1 -1
  19. package/docs/learning/lessons/L29-team-continuity.md +10 -1
  20. package/docs/learning/tracks/operator.md +3 -0
  21. package/docs/learning/video-scripts/V29-team-continuity.md +12 -2
  22. package/docs/operating.md +4 -0
  23. package/docs/privacy-and-data-flow.md +9 -0
  24. package/docs/releasing.md +6 -0
  25. package/docs/storage.md +9 -0
  26. package/docs/team-onboarding.md +4 -0
  27. package/docs/team-operations.md +127 -0
  28. package/docs/team.md +10 -4
  29. package/examples/eval/bundled-fake.json +1 -1
  30. package/mcp-memory-server/dist/team-audit.js +41 -0
  31. package/mcp-memory-server/dist/team-cli.js +68 -27
  32. package/mcp-memory-server/dist/team-errors.js +7 -0
  33. package/mcp-memory-server/dist/team-files.js +28 -0
  34. package/mcp-memory-server/dist/team-mcp.js +4 -3
  35. package/mcp-memory-server/dist/team-pages.js +40 -0
  36. package/mcp-memory-server/dist/team-store.js +98 -39
  37. package/package.json +1 -1
  38. package/scripts/cairn-cli.mjs +1 -1
  39. package/scripts/completion.sh +8 -3
  40. package/scripts/test-ci-sharding.sh +6 -1
  41. package/scripts/test-completion.sh +5 -2
  42. package/scripts/test-release-cache.mjs +16 -0
  43. package/scripts/test-security-baseline.sh +3 -0
  44. package/scripts/test-smoke-timings.mjs +19 -0
  45. package/scripts/test-team-docs.sh +16 -1
  46. package/scripts/test-team-installed.mjs +11 -0
  47. package/scripts/test-team-lab-env.mjs +9 -0
  48. package/scripts/test-team-operations.mjs +31 -0
  49. package/scripts/test-team-operations.sh +5 -0
  50. package/scripts/verify-published-release.mjs +11 -2
  51. package/scripts/windows-platform.mjs +1 -1
@@ -1,13 +1,15 @@
1
- import { closeSync, existsSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
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 { hardenPrivatePath } from './platform-security.js';
7
- import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, prepareTeamPrivateDirectory, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
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,7 @@
1
+ export class TeamError extends Error {
2
+ code;
3
+ constructor(code) {
4
+ super(`Team ${code}.`);
5
+ this.code = code;
6
+ }
7
+ }
@@ -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 auditEntrySchema = z.object({ schema_version: z.literal(1), sequence: z.number().int().positive(), organization: id, project: id.or(z.literal('')), subject: id, credential_class: z.enum(['human', 'workload', 'local-os']), operation: z.enum(['project-create', 'member-set', 'credential-revoke', 'credential-issue', 'memory-delete', 'list', 'audit', 'read', 'history', 'search', 'propose', 'proposals', 'proposal-show', 'review', 'snapshot', 'restore', 'connection-check']), result: z.enum(['accepted', 'denied', 'conflict', 'invalid']), object_digest: digest.nullable(), previous_digest: digest.nullable(), request_id: z.string().uuid(), at: date }).strict();
26
- const snapshotSchema = 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();
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
- let previous = null;
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 !== '1')
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 sequence = valid(z.number().int().nonnegative(), last?.sequence ?? 0) + 1;
372
- const entry = { schema_version: 1, sequence, organization: this.organization, project, subject, credential_class: credentialClass, operation, result, object_digest: objectDigest, previous_digest: last?.digest ?? null, request_id: randomUUID(), at: new Date().toISOString() };
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 body = { schema_version: 1, organization: this.organization, records, audit };
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
- async snapshot() {
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
- await this.appendAudit('', 'local-operator', 'local-os', 'snapshot', 'accepted');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 ...]
@@ -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');