@cairnkeep/cli 2.24.0 → 2.26.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 (60) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/docs/README.md +6 -0
  3. package/docs/agents.md +18 -0
  4. package/docs/design/team-mode.md +17 -9
  5. package/docs/harness-compatibility.md +20 -0
  6. package/docs/learning/CURRICULUM-MAP.md +4 -2
  7. package/docs/learning/FEATURE-GUIDE.md +35 -3
  8. package/docs/learning/README.md +5 -3
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  11. package/docs/learning/lessons/L02-installation.md +2 -2
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +2 -2
  17. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  18. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  19. package/docs/learning/lessons/L25-playbooks.md +1 -1
  20. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  21. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  22. package/docs/learning/lessons/L28-security-assurance.md +6 -2
  23. package/docs/learning/lessons/L29-team-continuity.md +127 -0
  24. package/docs/learning/tracks/operator.md +4 -0
  25. package/docs/learning/video-scripts/V28-security-assurance.md +4 -0
  26. package/docs/learning/video-scripts/V29-team-continuity.md +84 -0
  27. package/docs/operating.md +23 -3
  28. package/docs/privacy-and-data-flow.md +45 -6
  29. package/docs/provider-data-flow-review.md +48 -0
  30. package/docs/security-assurance.md +39 -0
  31. package/docs/storage.md +32 -0
  32. package/docs/team-onboarding.md +226 -0
  33. package/docs/team-pilot.md +134 -0
  34. package/docs/team.md +245 -0
  35. package/examples/eval/bundled-fake.json +1 -1
  36. package/mcp-memory-server/dist/capability-config.js +12 -1
  37. package/mcp-memory-server/dist/team-cli.js +228 -0
  38. package/mcp-memory-server/dist/team-client.js +71 -0
  39. package/mcp-memory-server/dist/team-http.js +193 -0
  40. package/mcp-memory-server/dist/team-mcp.js +89 -0
  41. package/mcp-memory-server/dist/team-store.js +669 -0
  42. package/mcp-memory-server/package.json +2 -2
  43. package/package.json +1 -1
  44. package/scripts/cairn-cli.mjs +7 -0
  45. package/scripts/completion.sh +31 -3
  46. package/scripts/lib/team-retention.mjs +29 -0
  47. package/scripts/team-connect.mjs +366 -0
  48. package/scripts/test-ci-sharding.sh +4 -0
  49. package/scripts/test-codeql-cohort.mjs +35 -0
  50. package/scripts/test-completion.sh +11 -0
  51. package/scripts/test-package-install.sh +10 -0
  52. package/scripts/test-phase18-capability-lifecycle.sh +2 -2
  53. package/scripts/test-security-baseline.sh +1 -0
  54. package/scripts/test-team-docs.sh +61 -0
  55. package/scripts/test-team-installed.mjs +51 -0
  56. package/scripts/test-team-pilot.mjs +101 -0
  57. package/scripts/uninstall.sh +13 -0
  58. package/scripts/verify-security-baseline.mjs +20 -0
  59. package/scripts/verify-team-retention.mjs +3 -0
  60. package/scripts/windows-platform.mjs +6 -2
@@ -58,14 +58,14 @@
58
58
  "check:context-pack-retrieval": "node scripts/smoke-context-pack-retrieval.mjs",
59
59
  "check:context-pack-v216-compat": "node scripts/smoke-context-pack-v216-compat.mjs",
60
60
  "check:context-usage": "node scripts/smoke-context-usage.mjs",
61
- "check:domain-retrieval": "node scripts/smoke-domain-retrieval.mjs",
61
+ "check:domain-retrieval": "node scripts/smoke-domain-retrieval.mjs && node scripts/smoke-provider-config-flow.mjs",
62
62
  "check:anythingllm-workspace": "node scripts/smoke-anythingllm-workspace.mjs && node scripts/smoke-anythingllm-workspace-mcp.mjs",
63
63
  "check:retrieval-benchmark": "node scripts/smoke-retrieval-benchmark.mjs && node scripts/run-retrieval-benchmark.mjs --check",
64
64
  "benchmark:retrieval": "npm run build && node scripts/run-retrieval-benchmark.mjs",
65
65
  "check:work-evidence": "node scripts/smoke-work-evidence.mjs",
66
66
  "check:okf": "node scripts/smoke-okf.mjs",
67
67
  "check:playbook": "node scripts/smoke-playbook.mjs",
68
- "check:security-assurance": "node scripts/smoke-security-assurance.mjs",
68
+ "check:security-assurance": "node scripts/smoke-security-fixture-order.mjs",
69
69
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
70
70
  "test": "npm run test:smoke",
71
71
  "test:smoke": "npm run build && node scripts/run-smoke-suite.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.24.0",
3
+ "version": "2.26.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",
@@ -21,6 +21,7 @@ const NODE_COMMANDS = new Map([
21
21
  ["evidence", "work-evidence-cli.js"],
22
22
  ["playbook", "playbook-cli.js"],
23
23
  ["security", "security-cli.js"],
24
+ ["team", "team-cli.js"],
24
25
  ]);
25
26
 
26
27
  const POSIX_COMMANDS = new Map([
@@ -61,6 +62,7 @@ Usage:
61
62
  cairn evidence <list|show|delete|prune|doctor>
62
63
  cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>
63
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>
64
66
  cairn memory <path|export|import>
65
67
  cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
66
68
  cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
@@ -94,6 +96,11 @@ export async function main(argv) {
94
96
  return;
95
97
  }
96
98
 
99
+ if (command === 'team' && (['connect', 'disconnect'].includes(args[0]) || (args[0] === 'doctor' && args.includes('--client')))) {
100
+ const { runTeamConnection } = await import('./team-connect.mjs');
101
+ process.exitCode = await runTeamConnection(args);
102
+ return;
103
+ }
97
104
  const nodeEntry = NODE_COMMANDS.get(command);
98
105
  if (nodeEntry) {
99
106
  // Preserve the historical dispatcher contract: child Node resolution comes
@@ -13,7 +13,7 @@ _cairn_complete() {
13
13
  COMPREPLY=()
14
14
  current=${COMP_WORDS[COMP_CWORD]}
15
15
  previous=${COMP_WORDS[COMP_CWORD-1]:-}
16
- commands="bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
16
+ commands="bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security team trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
17
17
  if (( COMP_CWORD == 1 )); then
18
18
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
19
19
  return
@@ -27,6 +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
31
  trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
31
32
  artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
32
33
  evidence) COMPREPLY=( $(compgen -W "list show delete prune doctor --status --json --dry-run --repair" -- "$current") ) ;;
@@ -100,6 +101,7 @@ _cairn() {
100
101
  'sync-omp:install the oh-my-pi memory extension'
101
102
  'doctor:check runtime dependencies and endpoints'
102
103
  'security:audit local security posture'
104
+ 'team:operate opt-in reviewed team continuity'
103
105
  'trajectory:inspect and prune local session trajectories'
104
106
  'artifact:inspect, delete, and prune local artifacts'
105
107
  'evidence:inspect and manage local Git-linked work evidence'
@@ -132,6 +134,7 @@ _cairn() {
132
134
  sync-omp) _arguments '--apply[apply changes]' '--live-root[omp agent root]:directory:_files -/' ;;
133
135
  doctor) _arguments '--repair[repair trajectory metadata and indexes]' ;;
134
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:' ;;
135
138
  trajectory) _values 'trajectory command' list show prune ;;
136
139
  artifact)
137
140
  case $words[3] in
@@ -196,8 +199,8 @@ EOF
196
199
  fish)
197
200
  sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
198
201
  complete -c cairn -f
199
- for command in bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
200
- complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
202
+ for command in bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security team trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
203
+ complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security team trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
201
204
  end
202
205
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
203
206
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -219,6 +222,31 @@ complete -c cairn -n "__fish_seen_subcommand_from doctor" -l repair
219
222
  complete -c cairn -n "__fish_seen_subcommand_from security" -a "doctor"
220
223
  complete -c cairn -n "__fish_seen_subcommand_from security" -l project -r
221
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"
226
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l organization -r
227
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l data -r
228
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l project -r
229
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l roles -r
230
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l class -r
231
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l expires-at -r
232
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l output -r
233
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l input -r
234
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l confirm -r
235
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l port -r
236
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l key -r
237
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l query -r
238
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l proposal -r
239
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l decision -r
240
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l revision -r
241
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l project-root -r
242
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l connection -r
243
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l client
244
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l url -r
245
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l token-file -r
246
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l harness -r -a "claude opencode qwen codex"
247
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l access -r -a "read-only full"
248
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l apply
249
+ complete -c cairn -n "__fish_seen_subcommand_from team" -l yes
222
250
  complete -c cairn -n "__fish_seen_subcommand_from trajectory" -a "list show prune"
223
251
  complete -c cairn -n "__fish_seen_subcommand_from artifact" -a "list show delete prune"
224
252
  complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subcommand_from list" -l kind -r
@@ -0,0 +1,29 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, resolve, sep } from 'node:path';
4
+
5
+ function canonical(path) {
6
+ const absolute = resolve(path);
7
+ if (existsSync(absolute)) return realpathSync(absolute);
8
+ const parent = dirname(absolute);
9
+ return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
10
+ }
11
+
12
+ /** Uninstall never grants permission to purge the separate team store. */
13
+ export function assertUninstallRetainsTeam(targets, env = process.env, home = homedir()) {
14
+ const stores = new Set([join(home, '.cairnkeep', 'team'), join(home, '.cairnkeep', 'team-clients'), ...(env.CAIRN_TEAM_BASE_DIR ? [env.CAIRN_TEAM_BASE_DIR] : [])]);
15
+ const normalized = path => {
16
+ const value = canonical(path);
17
+ return process.platform === 'win32' ? value.toLowerCase() : value;
18
+ };
19
+ for (const team of stores) {
20
+ if (!existsSync(resolve(team))) continue;
21
+ const protectedPath = normalized(team);
22
+ for (const target of targets) {
23
+ const path = normalized(target);
24
+ if (path === protectedPath || protectedPath.startsWith(path.endsWith(sep) ? path : `${path}${sep}`)) {
25
+ throw new Error('Uninstall refused: a removal target contains team data. Retain it, or relocate the team store with an explicitly verified backup first.');
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,366 @@
1
+ import { chmodSync, closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { dirname, join, resolve, parse, relative, isAbsolute, sep } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { readStableFile, assertFileUnchanged } from './lib/stable-file.mjs';
8
+ import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from './terminal-prompts.mjs';
9
+ import { readStableJson } from '../mcp-memory-server/dist/stable-file.js';
10
+ import { hardenPrivatePath } from '../mcp-memory-server/dist/platform-security.js';
11
+ import { prepareTeamPrivateDirectory, TeamError } from '../mcp-memory-server/dist/team-store.js';
12
+ import { validateTeamConnection, readTeamConnection, connectionToken, connectionDoctor, TeamClientError } from '../mcp-memory-server/dist/team-client.js';
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const NAME = 'cairn-team-memory';
16
+ const MAX = 1024 * 1024;
17
+ // Four before/after 1 MiB files, base64 encoded, plus bounded metadata.
18
+ const MAX_RECEIPT = 16 * MAX;
19
+ const ADAPTERS = Object.freeze({ claude: '.mcp.json', opencode: 'opencode.json', qwen: '.qwen/settings.json', codex: '.codex/config.toml' });
20
+ const HELP = `cairn team connect — opt-in, project-local team MCP (personal memory unchanged)
21
+
22
+ cairn team connect [--project-root PATH] [--url URL --organization ID --project ID --token-file FILE --harness claude,opencode,qwen,codex] [--access read-only|full] [--apply --yes]
23
+ cairn team disconnect [--project-root PATH] [--apply --yes]
24
+ cairn team doctor --client [--project-root PATH]
25
+
26
+ CAIRN_TEAM=1 is required. Without --apply a complete command is a no-write plan.
27
+ Interactive connect asks for missing choices and confirms before applying.
28
+ Credentials remain in owner-only files OUTSIDE source trees. No token values are
29
+ stored in harness config. Existing team entries/unsupported syntax fail closed.
30
+ Doctor checks authenticated reader access, not production admission. Disconnect
31
+ keeps credentials/backups; restart existing harness sessions or ask the operator
32
+ to revoke a credential for immediate denial. See docs/team-onboarding.md.
33
+ `;
34
+ class ConnectionError extends Error {}
35
+ function fail(message) { throw new ConnectionError(message); }
36
+ function enabled() { if (process.env.CAIRN_TEAM !== '1') fail('Team disabled. Opt in explicitly with CAIRN_TEAM=1.'); }
37
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
38
+ function info(path) { try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
39
+ function safeAncestors(path) {
40
+ let cursor = resolve(path);
41
+ while (true) {
42
+ const stat = info(cursor);
43
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()
44
+ || (process.platform !== 'win32' && (stat.mode & 0o022) !== 0 && !(stat.mode & 0o1000)))) fail('Team configuration has an unsafe directory ancestor.');
45
+ if (cursor === dirname(cursor)) break;
46
+ cursor = dirname(cursor);
47
+ }
48
+ }
49
+ function locations(projectRoot, home = homedir()) {
50
+ const target = realpathSync(resolve(projectRoot));
51
+ if (target === parse(target).root || /[\x00-\x1f\x7f]/.test(target)) fail('Invalid team project root.');
52
+ safeAncestors(target);
53
+ const targetInfo = lstatSync(target);
54
+ if (process.platform !== 'win32' && ((targetInfo.mode & 0o022) !== 0 || (typeof process.getuid === 'function' && targetInfo.uid !== process.getuid()))) fail('Team project directory must be owned by this user and not writable by other users.');
55
+ const state = join(realpathSync(resolve(home)), '.cairnkeep', 'team-clients', hash(target));
56
+ safeAncestors(state);
57
+ return { target, state, connectionFile: join(state, 'connection.json'), receiptFile: join(state, 'receipt.json') };
58
+ }
59
+ function snapshot(path, maxBytes = MAX) {
60
+ if (!info(path)) return null;
61
+ const current = readStableFile(path, { label: 'Harness configuration', maxBytes });
62
+ if (process.platform !== 'win32' && (current.stat.mode & 0o022)) fail('Harness configuration must not be writable by other users.');
63
+ return current;
64
+ }
65
+ function object(value) { return value && typeof value === 'object' && !Array.isArray(value); }
66
+ // Refuse duplicate object keys, including escaped spellings. JSON.parse alone
67
+ // would silently discard a preexisting MCP entry hidden behind a duplicate key.
68
+ function parseConfig(text) {
69
+ let offset = 0;
70
+ const ws = () => { while (/\s/.test(text[offset] ?? '') && offset < text.length) offset++; };
71
+ function string() {
72
+ const start = offset++;
73
+ while (offset < text.length) { const ch = text[offset++]; if (ch === '\\') offset++; else if (ch === '"') return JSON.parse(text.slice(start, offset)); }
74
+ fail('Harness configuration contains invalid JSON.');
75
+ }
76
+ function value(depth = 0) {
77
+ if (depth > 100) fail('Harness configuration is nested too deeply.');
78
+ ws();
79
+ if (text[offset] === '"') { string(); return; }
80
+ if (text[offset] === '{') {
81
+ offset++; ws(); const keys = new Set(); if (text[offset] === '}') { offset++; return; }
82
+ while (offset < text.length) {
83
+ ws(); if (text[offset] !== '"') fail('Harness configuration requires strict JSON (not JSONC).');
84
+ const key = string(); if (keys.has(key)) fail('Harness configuration has duplicate JSON keys.'); keys.add(key);
85
+ ws(); if (text[offset++] !== ':') fail('Harness configuration contains invalid JSON.');
86
+ value(depth + 1); ws(); const ch = text[offset++]; if (ch === '}') return; if (ch !== ',') break;
87
+ }
88
+ fail('Harness configuration contains invalid JSON.');
89
+ }
90
+ if (text[offset] === '[') {
91
+ offset++; ws(); if (text[offset] === ']') { offset++; return; }
92
+ while (offset < text.length) { value(depth + 1); ws(); const ch = text[offset++]; if (ch === ']') return; if (ch !== ',') break; }
93
+ fail('Harness configuration contains invalid JSON.');
94
+ }
95
+ const match = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(text.slice(offset));
96
+ if (!match) fail('Harness configuration contains invalid JSON.'); offset += match[0].length;
97
+ }
98
+ value(); ws(); if (offset !== text.length) fail('Harness configuration requires strict JSON.');
99
+ const parsed = JSON.parse(text); if (!object(parsed)) fail('Harness configuration must be a JSON object.'); return parsed;
100
+ }
101
+ function text(bytes) { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
102
+ function entryFor(harness, connectionFile) {
103
+ const args = [join(ROOT, 'bin', 'cairn'), 'team', 'mcp', '--connection', connectionFile];
104
+ return harness === 'opencode'
105
+ ? { type: 'local', command: [process.execPath, ...args], environment: { CAIRN_TEAM: '1' }, enabled: true }
106
+ : { command: process.execPath, args, env: { CAIRN_TEAM: '1' } };
107
+ }
108
+ function jsonAdd(bytes, harness, entry) {
109
+ const parsed = bytes ? parseConfig(text(bytes)) : {};
110
+ const field = harness === 'opencode' ? 'mcp' : 'mcpServers';
111
+ if (Object.hasOwn(parsed, field) && !object(parsed[field])) fail('Harness MCP configuration must be an object.');
112
+ if (Object.hasOwn(parsed[field] ?? {}, NAME)) fail('Existing team MCP entry is not owned by this connection; preserve it and resolve manually.');
113
+ parsed[field] = { ...parsed[field], [NAME]: entry };
114
+ return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`);
115
+ }
116
+ function tomlBlock(entry, nonce) {
117
+ return `\n# BEGIN cairnkeep team ${nonce}\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(entry.command)}\nargs = ${JSON.stringify(entry.args)}\n[mcp_servers.${NAME}.env]\nCAIRN_TEAM = "1"\n# END cairnkeep team ${nonce}\n`;
118
+ }
119
+ function receiptAt(loc) {
120
+ if (!info(loc.receiptFile)) return null;
121
+ const receipt = readStableJson(loc.receiptFile, { label: 'Team connection receipt', maxBytes: MAX_RECEIPT, private: true });
122
+ if (!object(receipt) || receipt.schema_version !== 1 || receipt.project_root !== loc.target
123
+ || typeof receipt.connection_digest !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.connection_digest)
124
+ || !['preparing', 'connected', 'disconnecting'].includes(receipt.status) || !Array.isArray(receipt.files) || receipt.files.length < 1 || receipt.files.length > 4
125
+ || new Set(receipt.files.map(row => row.harness)).size !== receipt.files.length) fail('Invalid team connection receipt.');
126
+ for (const row of receipt.files) {
127
+ if (!object(row) || !Object.hasOwn(ADAPTERS, row.harness) || row.path !== ADAPTERS[row.harness]
128
+ || !(row.before === null || (typeof row.before === 'string' && row.before.length <= 1.4 * MAX))
129
+ || typeof row.after !== 'string' || row.after.length > 1.4 * MAX
130
+ || !Number.isInteger(row.mode) || row.mode < 0 || row.mode > 0o777
131
+ || (row.harness === 'codex' ? typeof row.block !== 'string' : !object(row.entry))) fail('Invalid team connection receipt.');
132
+ }
133
+ return receipt;
134
+ }
135
+ export function planTeamConnection(options) {
136
+ enabled();
137
+ const loc = locations(options.projectRoot ?? process.cwd(), options.home);
138
+ const connection = validateTeamConnection({ schema_version: 1, project_root: loc.target, organization: options.organization,
139
+ project: options.project, url: options.url, token_file: resolve(options.tokenFile ?? ''), access: options.access ?? 'read-only' });
140
+ if (!options.tokenFile) fail('A private token-file reference is required.');
141
+ // Do not embed a credential path from this source tree into project config.
142
+ const tokenRelative = relative(loc.target, realpathSync(connection.token_file));
143
+ if (tokenRelative === '' || (!isAbsolute(tokenRelative) && tokenRelative.split(sep)[0] !== '..')) fail('Keep the credential file outside the project source tree.');
144
+ connectionToken(connection);
145
+ const harnesses = options.harnesses;
146
+ if (!Array.isArray(harnesses) || !harnesses.length || harnesses.length > 4 || new Set(harnesses).size !== harnesses.length
147
+ || harnesses.some(h => !Object.hasOwn(ADAPTERS, h))) fail('Unsupported or duplicate team harness; choose claude, opencode, qwen or codex.');
148
+ const prior = receiptAt(loc);
149
+ if (prior) {
150
+ const currentConnection = readTeamConnection(loc.connectionFile);
151
+ if (prior.status !== 'connected' || JSON.stringify(currentConnection) !== JSON.stringify(connection)
152
+ || [...harnesses].sort().join() !== prior.files.map(r => r.harness).sort().join()) fail('Team connection already exists or is incomplete; doctor/disconnect before changing it.');
153
+ for (const row of prior.files) {
154
+ const current = snapshot(join(loc.target, row.path));
155
+ if (!current || !current.bytes.equals(Buffer.from(row.after, 'base64'))) fail('Team harness configuration changed; resolve drift before reconnecting.');
156
+ }
157
+ return { ...loc, connection, files: prior.files, unchanged: true };
158
+ }
159
+ if (info(loc.connectionFile)) fail('Incomplete team connection; inspect private recovery state before retrying.');
160
+ const nonce = randomUUID();
161
+ const files = harnesses.map(harness => {
162
+ const path = ADAPTERS[harness]; const destination = join(loc.target, path); safeAncestors(dirname(destination));
163
+ if (harness === 'opencode' && info(join(loc.target, 'opencode.jsonc'))) fail('OpenCode JSONC exists; no automatic edits. Use a separate supported configuration.');
164
+ const current = snapshot(destination); const bytes = current?.bytes ?? null;
165
+ const entry = entryFor(harness, loc.connectionFile);
166
+ let after, block;
167
+ if (harness === 'codex') {
168
+ // Do not attempt to reinterpret arbitrary operator TOML or equivalent
169
+ // quoted/inline tables. A conservative collision check preserves it.
170
+ if (bytes && text(bytes).includes(NAME)) fail('Existing team MCP TOML entry must be resolved manually.');
171
+ if (bytes && /(?:^|\n)\s*(?:"mcp_servers"|'mcp_servers'|mcp_servers)\s*=/.test(text(bytes))) fail('Inline MCP TOML configuration is unsupported; preserve it and configure manually.');
172
+ block = tomlBlock(entry, nonce); after = Buffer.concat([bytes ?? Buffer.alloc(0), Buffer.from(block)]);
173
+ } else after = jsonAdd(bytes, harness, entry);
174
+ if (after.length > MAX) fail('Harness configuration exceeds the 1 MiB limit.');
175
+ return { harness, path, before: bytes?.toString('base64') ?? null, after: after.toString('base64'), mode: current ? current.stat.mode & 0o777 : 0o600, ...(block ? { block } : { entry }), expected: current?.stat ?? null };
176
+ });
177
+ return { ...loc, connection, files, unchanged: false };
178
+ }
179
+ function makeParents(target, directory) {
180
+ const segments = directory.slice(target.length).split(/[\\/]/).filter(Boolean);
181
+ let cursor = target;
182
+ for (const segment of segments) { cursor = join(cursor, segment); if (!info(cursor)) { mkdirSync(cursor, { mode: 0o700 }); hardenPrivatePath(cursor); } safeAncestors(cursor); }
183
+ }
184
+ // Like the setup writer, this has a point-in-time drift check, not an OS CAS.
185
+ // The private state lock serializes Cairnkeep writers. Owner-controlled config
186
+ // directories are required; hostile same-identity processes are out of scope.
187
+ function publish(path, bytes, mode, expected = null) {
188
+ safeAncestors(dirname(path));
189
+ const parent = lstatSync(dirname(path));
190
+ const temporary = join(dirname(path), `.cairn-team-${randomUUID()}.tmp`);
191
+ let fd;
192
+ try {
193
+ fd = openSync(temporary, 'wx', mode); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
194
+ hardenPrivatePath(temporary);
195
+ if (process.platform !== 'win32') chmodSync(temporary, mode);
196
+ assertFileUnchanged(path, expected, 'Team configuration');
197
+ const now = lstatSync(dirname(path));
198
+ if (parent.dev !== now.dev || parent.ino !== now.ino || now.isSymbolicLink()) fail('Team configuration parent changed.');
199
+ if (expected) renameSync(temporary, path);
200
+ else linkSync(temporary, path); // exclusive destination; never replace a competing new file
201
+ } finally {
202
+ if (fd !== undefined) closeSync(fd);
203
+ if (info(temporary)) unlinkSync(temporary);
204
+ }
205
+ }
206
+ function lockPath(loc) { return `${loc.state}.operation.lock`; }
207
+ function lock(loc) {
208
+ // Keep the lock outside the directory we atomically archive on disconnect.
209
+ // Native Windows cannot rename a directory containing an open lock handle.
210
+ prepareTeamPrivateDirectory(dirname(loc.state), true);
211
+ const path = lockPath(loc);
212
+ let fd;
213
+ try { fd = openSync(path, 'wx', 0o600); }
214
+ catch { fail('Team connection is locked or interrupted. Check running operations before manually clearing operation.lock; see recovery guide.'); }
215
+ try { hardenPrivatePath(path); } catch (error) { closeSync(fd); unlinkSync(path); throw error; }
216
+ try { prepareTeamPrivateDirectory(loc.state, true); }
217
+ catch (error) { closeSync(fd); unlinkSync(path); throw error; }
218
+ let released = false;
219
+ return (location = path) => { if (released) return; released = true; closeSync(fd); unlinkSync(location); };
220
+ }
221
+ export async function applyTeamConnection(plan, hooks = {}) {
222
+ enabled();
223
+ const loc = locations(plan.target, dirname(dirname(dirname(plan.state))));
224
+ if (loc.state !== plan.state || loc.connectionFile !== plan.connectionFile) fail('Invalid team connection plan.');
225
+ if (plan.unchanged) { await connectionDoctor(plan.connection); return { connected: true, unchanged: true, connection_file: loc.connectionFile }; }
226
+ const unlock = lock(loc);
227
+ try {
228
+ if (receiptAt(loc) || info(loc.connectionFile)) fail('Team connection changed; inspect recovery state.');
229
+ const connection = validateTeamConnection(plan.connection); connectionToken(connection);
230
+ // Verify access before writing anything except our private lock directory.
231
+ const identity = await connectionDoctor(connection);
232
+ if (identity.credential_class !== 'workload') fail('Team harness connection requires a workload credential; keep human reviewer credentials outside agent configuration.');
233
+ for (const row of plan.files) assertFileUnchanged(join(loc.target, row.path), row.expected, 'Harness configuration');
234
+ const connectionBytes = Buffer.from(`${JSON.stringify(connection)}\n`);
235
+ const receipt = { schema_version: 1, project_root: loc.target, status: 'preparing', connection_digest: hash(connectionBytes), files: plan.files.map(({ expected, ...row }) => row) };
236
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify(receipt)}\n`), 0o600);
237
+ publish(loc.connectionFile, connectionBytes, 0o600);
238
+ for (const row of plan.files) {
239
+ await hooks.beforeWrite?.(row);
240
+ const destination = join(loc.target, row.path); makeParents(loc.target, dirname(destination));
241
+ publish(destination, Buffer.from(row.after, 'base64'), row.mode, row.expected);
242
+ }
243
+ const current = snapshot(loc.receiptFile, MAX_RECEIPT);
244
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify({ ...receipt, status: 'connected' })}\n`), 0o600, current.stat);
245
+ return { connected: true, unchanged: false, connection_file: loc.connectionFile, access: connection.access };
246
+ } finally { unlock(); }
247
+ }
248
+ function removal(row, current, disconnecting = false) {
249
+ const before = row.before === null ? null : Buffer.from(row.before, 'base64');
250
+ if (!current) { if (before === null) return undefined; fail('Team configuration changed or is missing; resolve drift before disconnect.'); }
251
+ if (before?.equals(current.bytes)) return undefined; // interrupted connect did not publish this file
252
+ if (current.bytes.equals(Buffer.from(row.after, 'base64'))) return before;
253
+ if (row.harness === 'codex') {
254
+ const contents = text(current.bytes);
255
+ if (disconnecting && !contents.includes(NAME)) return undefined;
256
+ if (contents.split(row.block).length !== 2) fail('Owned team TOML block changed; resolve drift before disconnect.');
257
+ return Buffer.from(contents.replace(row.block, ''));
258
+ }
259
+ const parsed = parseConfig(text(current.bytes)); const field = row.harness === 'opencode' ? 'mcp' : 'mcpServers';
260
+ if (disconnecting && (!Object.hasOwn(parsed, field) || (object(parsed[field]) && !Object.hasOwn(parsed[field], NAME)))) return undefined;
261
+ if (!object(parsed[field]) || JSON.stringify(parsed[field][NAME]) !== JSON.stringify(row.entry)) fail('Owned team MCP entry changed; resolve drift before disconnect.');
262
+ delete parsed[field][NAME];
263
+ const original = before ? parseConfig(text(before)) : {};
264
+ if (Object.keys(parsed[field]).length === 0 && !Object.hasOwn(original, field)) delete parsed[field];
265
+ return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`);
266
+ }
267
+ export async function disconnectTeamConnection(options) {
268
+ enabled(); const loc = locations(options.projectRoot ?? process.cwd(), options.home);
269
+ const unlock = options.dryRun ? () => {} : lock(loc);
270
+ try {
271
+ const receipt = receiptAt(loc); if (!receipt) fail('No managed team connection for this project.');
272
+ // Preflight EVERY file before removing any entry. Drift never authorizes
273
+ // wholesale backup restore over newly edited personal/unrelated MCP config.
274
+ const changes = receipt.files.map(row => {
275
+ const destination = join(loc.target, row.path); safeAncestors(dirname(destination));
276
+ const current = snapshot(destination); return { row, destination, current, bytes: removal(row, current, receipt.status === 'disconnecting') };
277
+ });
278
+ if (options.dryRun) return { planned: true, disconnect: true, project_root: loc.target, files: receipt.files.map(row => row.path) };
279
+ const receiptSnapshot = snapshot(loc.receiptFile, MAX_RECEIPT);
280
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify({ ...receipt, status: 'disconnecting' })}\n`), 0o600, receiptSnapshot.stat);
281
+ for (const { row, destination, current, bytes } of changes) {
282
+ if (bytes === undefined) continue;
283
+ await options.beforeWrite?.(row);
284
+ assertFileUnchanged(destination, current.stat, 'Harness configuration');
285
+ if (bytes === null) unlinkSync(destination);
286
+ else publish(destination, bytes, row.mode, current.stat);
287
+ }
288
+ // Keep private backups, but retire the active connection path so new
289
+ // harness launches cannot reuse it accidentally. Existing processes need
290
+ // restart or server-side revocation; disconnect does not revoke a token.
291
+ const archive = `${loc.state}.disconnected-${randomUUID()}`;
292
+ renameSync(loc.state, archive);
293
+ unlock();
294
+ return { disconnected: true, credentials_retained: true, backups_retained: true };
295
+ } finally { unlock(); }
296
+ }
297
+ function argsFor(args) {
298
+ const flags = {}; const values = new Set(['project-root', 'url', 'organization', 'project', 'token-file', 'harness', 'access']);
299
+ for (let index = 1; index < args.length; index++) {
300
+ const name = args[index].slice(2);
301
+ if (!args[index].startsWith('--') || Object.hasOwn(flags, name)) fail('Unknown or duplicate team connection option.');
302
+ if (['apply', 'yes', 'client'].includes(name)) flags[name] = true;
303
+ else if (values.has(name) && args[index + 1] && !args[index + 1].startsWith('--')) flags[name] = args[++index];
304
+ else fail('Unknown or missing team connection option.');
305
+ }
306
+ return flags;
307
+ }
308
+ export async function runTeamConnection(args, streams = {}) {
309
+ const input = streams.input ?? process.stdin, output = streams.output ?? process.stdout, error = streams.error ?? process.stderr;
310
+ let prompt;
311
+ try {
312
+ if (args.includes('--help') || args.includes('-h')) { output.write(HELP); return 0; }
313
+ enabled();
314
+ const flags = argsFor(args); const command = args[0];
315
+ const allowed = command === 'connect' ? ['project-root', 'url', 'organization', 'project', 'token-file', 'harness', 'access', 'apply', 'yes']
316
+ : command === 'disconnect' ? ['project-root', 'apply', 'yes'] : command === 'doctor' ? ['project-root', 'client'] : [];
317
+ if (!allowed.length || Object.keys(flags).some(key => !allowed.includes(key))) fail('Invalid team connection command or option.');
318
+ const options = { projectRoot: flags['project-root'] ?? process.cwd(), home: streams.home };
319
+ if (command === 'doctor') {
320
+ if (!flags.client) fail('Use team doctor --client for connection diagnostics.');
321
+ const loc = locations(options.projectRoot, options.home); const receipt = receiptAt(loc);
322
+ if (!receipt || receipt.status !== 'connected' || info(lockPath(loc))) fail('No completed, unlocked team connection; inspect recovery state.');
323
+ for (const row of receipt.files) {
324
+ const current = snapshot(join(loc.target, row.path));
325
+ if (!current || removal(row, current) === undefined) fail('Managed team entry is missing; reconnect or inspect recovery state.');
326
+ }
327
+ const connection = readTeamConnection(loc.connectionFile);
328
+ output.write(`${JSON.stringify(await connectionDoctor(connection), null, 2)}\n`); return 0;
329
+ }
330
+ const interactive = Boolean(input.isTTY && output.isTTY);
331
+ const wizard = command === 'connect' && interactive && ['url', 'organization', 'project', 'token-file', 'harness'].some(key => !flags[key]);
332
+ const question = async message => {
333
+ prompt ??= createInterface({ input, output });
334
+ return (await prompt.question(message)).trim();
335
+ };
336
+ const confirm = async () => {
337
+ prompt?.close(); prompt = undefined;
338
+ if (supportsTerminalPrompts(input, output)) return (await selectOnePrompt({ input, output, message: 'Apply team connection plan?', choices: [{ value: 'no', label: 'No, cancel' }, { value: 'yes', label: 'Yes, apply' }], initialValue: 'no' })) === 'yes';
339
+ return ['y', 'yes'].includes((await question('Apply team connection plan? [y/N] ')).toLowerCase());
340
+ };
341
+ let plan;
342
+ if (command === 'connect') {
343
+ const get = async (name, message) => flags[name] ?? (interactive ? await question(message) : fail(`Missing --${name}; use team connect --help.`));
344
+ Object.assign(options, { url: await get('url', 'Team URL (loopback HTTP or HTTPS): '), organization: await get('organization', 'Organization ID: '), project: await get('project', 'Team project ID: '), tokenFile: await get('token-file', 'Private credential file (outside source tree): '), access: flags.access ?? 'read-only' });
345
+ if (flags.harness) options.harnesses = flags.harness.split(',');
346
+ else if (supportsTerminalPrompts(input, output)) {
347
+ prompt?.close(); prompt = undefined;
348
+ options.harnesses = await selectManyPrompt({ input, output, message: 'Add team MCP to project harnesses', choices: Object.keys(ADAPTERS).map(value => ({ value, label: value })) });
349
+ } else options.harnesses = (await get('harness', 'Harnesses (claude,opencode,qwen,codex): ')).split(',');
350
+ prompt?.close(); prompt = undefined;
351
+ plan = planTeamConnection(options);
352
+ output.write(`${JSON.stringify({ planned: true, project_root: plan.target, project: plan.connection.project, organization: plan.connection.organization, access: plan.connection.access, files: plan.files.map(row => row.path), personal_memory: 'unchanged' }, null, 2)}\n`);
353
+ } else output.write(`${JSON.stringify(await disconnectTeamConnection({ ...options, dryRun: true }), null, 2)}\n`);
354
+ const apply = flags.apply || wizard;
355
+ if (!apply) return 0;
356
+ if (!flags.yes && !(interactive && await confirm())) fail('Team connection cancelled. No harness configuration changed.');
357
+ const result = command === 'connect' ? await applyTeamConnection(plan) : await disconnectTeamConnection(options);
358
+ output.write(`${JSON.stringify(result, null, 2)}\n`); return 0;
359
+ } catch (cause) {
360
+ const messages = { unauthorized: 'Team credential rejected (expired, revoked or unknown). Ask the operator to issue a fresh private credential file.', denied: 'Team project access denied. Check organization/project IDs and explicit reader membership with the operator.', integrity: 'Team service unavailable or response invalid. Check the tunnel/TLS, server version and operator status; no personal-memory fallback is used.', invalid: 'Team request rejected. Verify connection fields and upgrade the team API to a version supporting connection-check (2.26 or newer).' };
361
+ const message = cause instanceof TeamError ? (messages[cause.code] ?? cause.message)
362
+ : cause instanceof ConnectionError || cause instanceof TeamClientError ? cause.message
363
+ : 'Team connection failed safely; no credential or configuration content is logged.';
364
+ error.write(`${message}\nRecovery: cairn team doctor --client --project-root PATH; see docs/team-onboarding.md.\n`); return cause?.status ?? 1;
365
+ } finally { prompt?.close(); if (typeof input.pause === 'function') input.pause(); }
366
+ }
@@ -17,6 +17,10 @@ assert.equal(flattened.length, smokeChecks.length);
17
17
  assert.equal(new Set(flattened).size, smokeChecks.length);
18
18
  assert.deepEqual([...flattened].sort(), smokeChecks.map(({ name }) => name).sort());
19
19
  assert.ok(plan.every(({ checks }) => checks.length > 0));
20
+ const teamContracts = ['team', 'team-review', 'team-http', 'team-mcp', 'team-restore', 'team-cli', 'team-onboarding'];
21
+ for (const name of teamContracts) assert.ok(flattened.includes(name), `missing team contract: ${name}`);
22
+ assert.ok(plan.every(({ checks }) => checks.some(({ name }) => teamContracts.includes(name))),
23
+ 'native ACL-heavy team contracts must be distributed across all Windows shards');
20
24
  const weights = plan.map(({ weight }) => weight);
21
25
  assert.ok(Math.max(...weights) - Math.min(...weights) <= 5,
22
26
  `native Windows shards are imbalanced: ${weights.join(", ")}`);
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict';
2
+ import { validateCodeqlCohort } from './verify-security-baseline.mjs';
3
+
4
+ const sha = 'a'.repeat(40), other = 'b'.repeat(40);
5
+ const workflows = [
6
+ { jobs: { scan: { steps: [{ uses: `github/codeql-action/init@${sha}` }, { uses: `github/codeql-action/analyze@${sha}` }] } } },
7
+ { jobs: { score: { steps: [{ uses: `github/codeql-action/upload-sarif@${sha}` }] } } },
8
+ ];
9
+ const dependabot = { version: 2, updates: [{ 'package-ecosystem': 'github-actions', directory: '/', groups: { 'codeql-action': { patterns: ['github/codeql-action/*'] } } }] };
10
+ validateCodeqlCohort(workflows, dependabot);
11
+ assert.throws(() => validateCodeqlCohort([], dependabot), /cohort/);
12
+ assert.throws(() => validateCodeqlCohort(workflows.slice(0, 1), dependabot), /missing upload-sarif/);
13
+ assert.throws(() => validateCodeqlCohort(workflows, {}), /group/);
14
+ const duplicateUpdates = structuredClone(dependabot);
15
+ duplicateUpdates.updates.push(structuredClone(dependabot.updates[0]));
16
+ assert.throws(() => validateCodeqlCohort(workflows, duplicateUpdates), /group/);
17
+ for (let index = 0; index < 3; index++) {
18
+ const changed = structuredClone(workflows);
19
+ const target = index < 2 ? changed[0].jobs.scan.steps[index] : changed[1].jobs.score.steps[0];
20
+ target.uses = target.uses.replace(sha, other);
21
+ assert.throws(() => validateCodeqlCohort(changed, dependabot), /same.*commit|cohort/);
22
+ }
23
+ const unpinned = structuredClone(workflows);
24
+ unpinned[0].jobs.scan.steps[0].uses = 'github/codeql-action/init@v4';
25
+ assert.throws(() => validateCodeqlCohort(unpinned, dependabot), /pinned|commit/);
26
+ for (const mutate of [
27
+ value => { delete value.updates[0].groups; },
28
+ value => { value.updates[0].groups['codeql-action'].patterns = ['github/codeql-action/init']; },
29
+ value => { value.updates[0].groups['codeql-action']['exclude-patterns'] = ['github/codeql-action/analyze']; },
30
+ value => { value.updates[0].groups['codeql-action']['update-types'] = ['patch']; },
31
+ ]) {
32
+ const changed = structuredClone(dependabot); mutate(changed);
33
+ assert.throws(() => validateCodeqlCohort(workflows, changed), /group|cohort/);
34
+ }
35
+ console.log('PASS: CodeQL same-commit cohort and grouped dependency-update mutation controls');
@@ -17,6 +17,14 @@ eval_flags='--task-set --adapter --output --repetitions --seed --json --yes --di
17
17
  skill_commands='harvest list show review propose evaluate apply rollback'
18
18
  for shell in bash zsh fish; do
19
19
  grep -q 'capabilities' "$tmp/$shell"
20
+ grep -q 'team' "$tmp/$shell"
21
+ grep -q 'credential create set remove issue revoke' "$tmp/$shell"
22
+ for team_command in connect disconnect init project member credential serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete; do
23
+ grep -qF "$team_command" "$tmp/$shell"
24
+ done
25
+ for team_flag in connection client url token-file harness access apply yes; do
26
+ grep -q -- "--$team_flag\|-l $team_flag" "$tmp/$shell"
27
+ done
20
28
  grep -q 'list.*status.*enable.*disable.*reset.*logging\|list status enable disable reset logging' "$tmp/$shell"
21
29
  grep -q -- '--json\|-l json' "$tmp/$shell"
22
30
  for capability_id in $capability_ids; do
@@ -152,6 +160,9 @@ for shell in bash zsh fish; do
152
160
  fi
153
161
  done
154
162
  "$ROOT/bin/cairn" completion powershell >"$tmp/powershell"
163
+ grep -qF "'team'" "$tmp/powershell"
164
+ grep -qF "'credential','create','set','remove','issue','revoke'" "$tmp/powershell"
165
+ grep -qF "'memory-delete'" "$tmp/powershell"
155
166
  grep -qF "'protocol'" "$tmp/powershell"
156
167
  grep -qF "'--trajectory'" "$tmp/powershell"
157
168
  grep -qF "'--codex-jsonl'" "$tmp/powershell"
@@ -60,6 +60,16 @@ env -u CAIRN_NOTE_DISTILLATION cairn notes --help >/dev/null \
60
60
  || fail "installed package omitted the notes command"
61
61
 
62
62
  installed_root="$tmp/prefix/lib/node_modules/@cairnkeep/cli"
63
+ cairn team --help >"$tmp/installed-team-help"
64
+ grep -qF 'experimental token-first team continuity' "$tmp/installed-team-help" || fail "installed package omitted team preview"
65
+ node "$ROOT/scripts/test-team-installed.mjs" "$installed_root" || fail "installed team onboarding failed"
66
+ CAIRN_TEAM=1 cairn team init --organization demo-org --data "$tmp/installed-team" >"$tmp/team-init.json"
67
+ CAIRN_TEAM=1 cairn team project create alpha --data "$tmp/installed-team" >/dev/null
68
+ CAIRN_TEAM=1 cairn team backup --data "$tmp/installed-team" --output "$tmp/team-snapshot.json" >"$tmp/team-backup.json"
69
+ snapshot_digest=$(node -p 'require(process.argv[1]).snapshot_digest' "$tmp/team-backup.json")
70
+ CAIRN_TEAM=1 cairn team restore --input "$tmp/team-snapshot.json" --confirm "$snapshot_digest" --data "$tmp/installed-team-restored" >"$tmp/team-restore.json"
71
+ CAIRN_TEAM=1 cairn team doctor --data "$tmp/installed-team-restored" >"$tmp/team-doctor.json"
72
+ node -e 'if(!require(process.argv[1]).credentials_revoked||!require(process.argv[2]).ok)process.exit(1)' "$tmp/team-restore.json" "$tmp/team-doctor.json" || fail "installed team restore contract failed"
63
73
  cairn skill --help >"$tmp/installed-skill-help"
64
74
  cmp -s "$tmp/source-skill-help" "$tmp/installed-skill-help" || \
65
75
  fail "source and installed skill help differ"