@rubytech/create-maxy 1.0.711 → 1.0.713

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 (45) hide show
  1. package/dist/index.js +38 -3
  2. package/package.json +2 -2
  3. package/payload/platform/lib/graph-search/dist/index.d.ts +22 -1
  4. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/graph-search/dist/index.js +69 -39
  6. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  7. package/payload/platform/lib/graph-search/src/__tests__/bm25-label-gate.test.ts +88 -0
  8. package/payload/platform/lib/graph-search/src/__tests__/expand-batch.test.ts +206 -0
  9. package/payload/platform/lib/graph-search/src/index.ts +100 -43
  10. package/payload/platform/plugins/docs/references/platform.md +3 -1
  11. package/payload/platform/plugins/linkedin-import/PLUGIN.md +1 -0
  12. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +26 -5
  13. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +53 -82
  14. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +42 -49
  15. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  16. package/payload/platform/plugins/memory/mcp/dist/index.js +48 -0
  17. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +33 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +229 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -0
  22. package/payload/platform/scripts/check-sdk-oauth.mjs +178 -0
  23. package/payload/platform/scripts/redact-install-logs.sh +85 -0
  24. package/payload/platform/scripts/setup.sh +20 -3
  25. package/payload/platform/scripts/verify-skill-tool-surface.sh +255 -0
  26. package/payload/platform/templates/specialists/agents/database-operator.md +6 -2
  27. package/payload/server/chunk-U5JPRUYZ.js +12298 -0
  28. package/payload/server/maxy-edge.js +1 -1
  29. package/payload/server/public/assets/{Checkbox-CjbS9JcG.js → Checkbox-Dr9MqNdk.js} +1 -1
  30. package/payload/server/public/assets/{admin-Ce9DbUuu.js → admin-CZ1QdDIj.js} +1 -1
  31. package/payload/server/public/assets/{data-C-SxjLC9.js → data-KcxxS-x3.js} +1 -1
  32. package/payload/server/public/assets/{file-D4cbAAuo.js → file-KlvYstdJ.js} +1 -1
  33. package/payload/server/public/assets/{graph-BNx6E7BH.js → graph-BjGlgDDX.js} +8 -8
  34. package/payload/server/public/assets/{house-CYsVygEQ.js → house-CyE0Xd3r.js} +1 -1
  35. package/payload/server/public/assets/{jsx-runtime-DPXE45W9.css → jsx-runtime-CPtXdEwZ.css} +1 -1
  36. package/payload/server/public/assets/{public-BTOF98iO.js → public-C1gnzTxk.js} +1 -1
  37. package/payload/server/public/assets/{share-2-B-sbkB36.js → share-2-Q9lo8ZrW.js} +1 -1
  38. package/payload/server/public/assets/{useVoiceRecorder-DLVFx3ms.js → useVoiceRecorder-BH8HP7l_.js} +1 -1
  39. package/payload/server/public/assets/{x-BNidzSAn.js → x-BwY4lg-U.js} +1 -1
  40. package/payload/server/public/data.html +6 -6
  41. package/payload/server/public/graph.html +7 -7
  42. package/payload/server/public/index.html +8 -8
  43. package/payload/server/public/public.html +5 -5
  44. package/payload/server/server.js +85 -56
  45. /package/payload/server/public/assets/{jsx-runtime-BUs3sHtV.js → jsx-runtime-BKpb2FvO.js} +0 -0
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // Task 746 — OAuth + wire-identity check for @anthropic-ai/claude-agent-sdk.
3
+ //
4
+ // One-shot spike. Pi-runnable. Intentionally orphan/standalone — not imported
5
+ // by any production module, not wired into any cron, hook, or route. Task 606
6
+ // owns SDK adoption in production; this script gates that adoption.
7
+ //
8
+ // Precondition (exact):
9
+ // ssh <pi> # see memory/reference_device_ssh.md for hosts
10
+ // cd /tmp && mkdir -p sdk-spike && cd sdk-spike
11
+ // npm install @anthropic-ai/claude-agent-sdk@0.2.119 # pin matches static evidence
12
+ // cp <repo>/platform/scripts/check-sdk-oauth.mjs .
13
+ // unset ANTHROPIC_API_KEY
14
+ // node check-sdk-oauth.mjs 2>&1 | tee spike.log
15
+ //
16
+ // PASS condition: apiKeySource ∈ {'oauth', 'none'}. Both indicate OAuth-only mode:
17
+ // 'oauth' = SDK supplied an OAuth-issued API key; 'none' = SDK supplied no key
18
+ // at all and the claude binary's own ~/.claude/.credentials.json OAuth state is
19
+ // in use. Cross-check with `claude --print --output-format json "Reply…"` to
20
+ // confirm the same value: parity = SDK is faithfully proxying claude's auth.
21
+ //
22
+ // Verdict reasons (exit 1 unless PASS):
23
+ // env-set ANTHROPIC_API_KEY present (operator must `unset`)
24
+ // no-oauth-credentials ~/.claude/.credentials.json unreadable, empty,
25
+ // or contains no OAuth-shaped fields (run `claude login`)
26
+ // exception:<msg> SDK import or runtime error
27
+ // no-system-init SDK never emitted system.init message
28
+ // wrong-api-key-source:<value> apiKeySource ∉ {oauth, none} — value verbatim;
29
+ // MISSING-FIELD if SDK message shape drifted
30
+ // (raw msg dumped on next line)
31
+ // assistant-error:<value> SDK reported assistant error: authentication_failed |
32
+ // billing_error | rate_limit | invalid_request |
33
+ // server_error | unknown | max_output_tokens
34
+ // no-response no assistant message arrived
35
+ // empty-response assistant arrived, no text content
36
+ // timeout 60s elapsed waiting for response
37
+ //
38
+ // Wire-identity (mitmproxy header diff vs `claude -p`) and subscription-billing
39
+ // (VNC dashboard) are operator-manual per Task 746 brief — not script-side.
40
+ // .docs/platform.md captures the consolidated verdict (script + mitm + VNC).
41
+
42
+ import { accessSync, readFileSync, constants as FS } from 'node:fs'
43
+ import { homedir } from 'node:os'
44
+ import { join } from 'node:path'
45
+
46
+ const TIMEOUT_MS = 60_000
47
+ const PROMPT = 'Reply with the literal string OK and nothing else.'
48
+ // Claude Code emits apiKeySource='none' when the SDK supplies no API key and
49
+ // the claude binary uses its own OAuth credentials at ~/.claude/.credentials.json.
50
+ // Brief assumed 'oauth' based on stale type-defs; runtime-correct OAuth-only
51
+ // indicator on Claude Code 2.1.x is 'none'. Both accepted; cross-check with
52
+ // `claude --print --output-format json` to confirm parity.
53
+ const OAUTH_API_KEY_SOURCES = new Set(['oauth', 'none'])
54
+
55
+ const log = (line) => process.stdout.write(line + '\n')
56
+ const safeStringify = (v) => { try { return JSON.stringify(v) } catch { return '<unstringifiable>' } }
57
+
58
+ let verdictEmitted = false
59
+ const fail = (reason) => {
60
+ if (verdictEmitted) return
61
+ verdictEmitted = true
62
+ log(`[sdk-spike] verdict: FAIL reason=${reason}`)
63
+ process.exit(1)
64
+ }
65
+ const pass = (note) => {
66
+ if (verdictEmitted) return
67
+ verdictEmitted = true
68
+ log(`[sdk-spike] verdict: PASS${note ? ` ${note}` : ''}`)
69
+ process.exit(0)
70
+ }
71
+
72
+ async function main() {
73
+ const envState = process.env.ANTHROPIC_API_KEY ? 'set' : 'unset'
74
+ log(`[sdk-spike] env: ANTHROPIC_API_KEY=${envState}`)
75
+ if (envState === 'set') fail('env-set')
76
+
77
+ const credsPath = join(homedir(), '.claude', '.credentials.json')
78
+ try { accessSync(credsPath, FS.R_OK) }
79
+ catch { fail('no-oauth-credentials') }
80
+
81
+ let credsSize = 0
82
+ let credsHasOauthFields = false
83
+ let credsTopKeys = []
84
+ try {
85
+ const credsRaw = readFileSync(credsPath, 'utf8')
86
+ credsSize = credsRaw.length
87
+ const credsObj = JSON.parse(credsRaw)
88
+ credsTopKeys = Object.keys(credsObj).sort()
89
+ credsHasOauthFields = credsTopKeys.some((k) => /oauth|access|refresh|bearer/i.test(k)) ||
90
+ Object.values(credsObj).some((v) => v && typeof v === 'object' &&
91
+ Object.keys(v).some((k) => /oauth|access|refresh|bearer/i.test(k)))
92
+ } catch { /* malformed creds file — surface in log, fail with no-oauth-credentials below */ }
93
+ log(`[sdk-spike] creds: size=${credsSize} topKeys=${credsTopKeys.join(',')} hasOauthFields=${credsHasOauthFields}`)
94
+ if (credsSize === 0 || !credsHasOauthFields) fail('no-oauth-credentials')
95
+
96
+ let query
97
+ try {
98
+ ({ query } = await import('@anthropic-ai/claude-agent-sdk'))
99
+ } catch (err) {
100
+ fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
101
+ }
102
+
103
+ let sdkVersion = '<unknown>'
104
+ try {
105
+ const pkgPath = join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json')
106
+ sdkVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
107
+ } catch { /* leave as <unknown> — diagnostic, not load-bearing */ }
108
+ log(`[sdk-spike] sdk-version: ${sdkVersion}`)
109
+
110
+ let gotInit = false
111
+ let apiKeySource = ''
112
+ let apiProvider = ''
113
+ let model = ''
114
+ let sessionId = ''
115
+ let claudeCodeVersion = ''
116
+ let gotAssistant = false
117
+ let assistantError = ''
118
+ let responseText = ''
119
+
120
+ const result = query({ prompt: PROMPT })
121
+
122
+ let timeoutId
123
+ const timer = new Promise((_, reject) => {
124
+ timeoutId = setTimeout(() => reject(new Error(`timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS)
125
+ })
126
+
127
+ const consume = (async () => {
128
+ for await (const msg of result) {
129
+ if (msg?.type === 'system' && msg?.subtype === 'init') {
130
+ gotInit = true
131
+ // apiKeySource is required (sdk.d.ts:3286 — non-optional). MISSING-FIELD here = SDK shape drift, dump raw.
132
+ apiKeySource = msg.apiKeySource ?? msg.api_key_source ?? 'MISSING-FIELD'
133
+ // apiProvider is optional (sdk.d.ts:32 declares `?:`); absent on Claude Code 2.1.119. <absent> ≠ defect.
134
+ apiProvider = msg.apiProvider ?? msg.api_provider ?? '<absent>'
135
+ model = msg.model ?? '<unknown>'
136
+ sessionId = msg.session_id ?? msg.sessionId ?? '<unknown>'
137
+ claudeCodeVersion = msg.claude_code_version ?? '<unknown>'
138
+ log(`[sdk-spike] system-init: apiKeySource=${apiKeySource} apiProvider=${apiProvider} model=${model} sessionId=${sessionId} claudeCodeVersion=${claudeCodeVersion}`)
139
+ if (apiKeySource === 'MISSING-FIELD') {
140
+ log(`[sdk-spike] system-init-raw: ${safeStringify(msg)}`)
141
+ }
142
+ } else if (msg?.type === 'assistant') {
143
+ gotAssistant = true
144
+ if (msg.error && !assistantError) assistantError = msg.error
145
+ const blocks = msg.message?.content ?? []
146
+ for (const block of blocks) {
147
+ if (block?.type === 'text' && typeof block.text === 'string') {
148
+ responseText += block.text
149
+ }
150
+ }
151
+ }
152
+ }
153
+ })()
154
+
155
+ try {
156
+ await Promise.race([consume, timer])
157
+ } catch (err) {
158
+ try { Promise.resolve(result.return?.()).catch(() => {}) } catch { /* best-effort cleanup */ }
159
+ if (String(err?.message ?? '').startsWith('timeout after')) fail('timeout')
160
+ fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
161
+ } finally {
162
+ clearTimeout(timeoutId)
163
+ try { Promise.resolve(result.return?.()).catch(() => {}) } catch { /* best-effort cleanup */ }
164
+ }
165
+
166
+ log(`[sdk-spike] response: ${responseText}`)
167
+
168
+ if (!gotInit) fail('no-system-init')
169
+ if (!OAUTH_API_KEY_SOURCES.has(apiKeySource)) fail(`wrong-api-key-source:${apiKeySource}`)
170
+ if (assistantError) fail(`assistant-error:${assistantError}`)
171
+ if (!gotAssistant) fail('no-response')
172
+ if (responseText.trim() === '') fail('empty-response')
173
+ pass(`apiKeySource=${apiKeySource} apiProvider=${apiProvider} sdk=${sdkVersion} cli=${claudeCodeVersion}`)
174
+ }
175
+
176
+ main().catch((err) => {
177
+ fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
178
+ })
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Existing-pi install-log redaction (Task 744).
3
+ #
4
+ # Idempotent one-shot remediation for Pis that completed installation BEFORE
5
+ # the install-log redaction landed at index.ts:152 / setup.sh:94. Scans every
6
+ # `install-*.log` in the configured logs directory and replaces every literal
7
+ # `set-initial-password ...<secret>` payload with `set-initial-password
8
+ # [REDACTED]`. Re-running the script is safe — already-redacted lines do not
9
+ # match the source pattern, so no further edits occur.
10
+ #
11
+ # Source patterns covered:
12
+ # 1. TS installer (packages/create-maxy/src/index.ts:152) — "[ISO] > sudo
13
+ # neo4j-admin dbms set-initial-password -- <secret>" or any args after
14
+ # "set-initial-password" (positional or "--" delimited).
15
+ # 2. Shell installer (platform/scripts/setup.sh, pre-fix variant) — "+ sudo
16
+ # neo4j-admin dbms set-initial-password <secret>" if bash -x had been on.
17
+ #
18
+ # A trailing marker line `[redact-install-logs] redacted=<n> file=<path>` is
19
+ # appended to each modified log so subsequent reads can identify which logs
20
+ # went through the remediation. Files with zero matches are left untouched.
21
+ #
22
+ # Default scan location: $HOME/.maxy/logs (the installer's LOG_DIR). Override
23
+ # with --dir <path> for non-default deployments.
24
+
25
+ set -euo pipefail
26
+
27
+ LOG_DIR="${HOME}/.maxy/logs"
28
+
29
+ while [ $# -gt 0 ]; do
30
+ case "$1" in
31
+ --dir) LOG_DIR="$2"; shift 2 ;;
32
+ --help|-h)
33
+ cat <<USAGE
34
+ Usage: redact-install-logs.sh [--dir <log-dir>]
35
+
36
+ Redacts neo4j set-initial-password secrets from install-*.log files.
37
+ Default --dir: \$HOME/.maxy/logs
38
+
39
+ Idempotent — safe to re-run.
40
+ USAGE
41
+ exit 0 ;;
42
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
43
+ esac
44
+ done
45
+
46
+ if [ ! -d "$LOG_DIR" ]; then
47
+ echo "[redact-install-logs] log dir not found: $LOG_DIR (nothing to do)"
48
+ exit 0
49
+ fi
50
+
51
+ shopt -s nullglob
52
+ TOTAL_FILES=0
53
+ TOTAL_REDACTIONS=0
54
+
55
+ for f in "$LOG_DIR"/install-*.log; do
56
+ [ -f "$f" ] || continue
57
+ TOTAL_FILES=$((TOTAL_FILES + 1))
58
+
59
+ # Pattern: any "set-initial-password" line followed by one or more args.
60
+ # The replacement keeps the leading prefix (timestamp + cmd up through
61
+ # set-initial-password and an optional "--") and substitutes everything
62
+ # after with [REDACTED]. We anchor the replacement only when the remaining
63
+ # tail is non-empty AND not already "[REDACTED]" — making the script idempotent.
64
+ REDACTED_THIS_FILE=$(
65
+ perl -ne '
66
+ if (/set-initial-password(\s+--)?\s+(\S.*)$/ && $2 ne "[REDACTED]") {
67
+ print STDOUT "1\n";
68
+ }
69
+ ' "$f" | wc -l | tr -d ' '
70
+ )
71
+
72
+ if [ "$REDACTED_THIS_FILE" -gt 0 ]; then
73
+ perl -i -pe '
74
+ if (/set-initial-password(\s+--)?\s+(\S.*)$/ && $2 ne "[REDACTED]") {
75
+ s/set-initial-password(\s+--)?\s+\S.*$/set-initial-password${1} [REDACTED]/;
76
+ }
77
+ ' "$f"
78
+ printf "[redact-install-logs] redacted=%d file=%s\n" "$REDACTED_THIS_FILE" "$f" >> "$f"
79
+ echo "[redact-install-logs] redacted=$REDACTED_THIS_FILE file=$f"
80
+ TOTAL_REDACTIONS=$((TOTAL_REDACTIONS + REDACTED_THIS_FILE))
81
+ fi
82
+ done
83
+
84
+ echo "[redact-install-logs] summary files_scanned=$TOTAL_FILES total_redactions=$TOTAL_REDACTIONS"
85
+ exit 0
@@ -86,12 +86,20 @@ else
86
86
  # Configure Neo4j for local use
87
87
  sudo sed -i 's/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/' /etc/neo4j/neo4j.conf
88
88
 
89
- # Generate a strong random password and store it
89
+ # Generate a strong random password and store it.
90
+ # Password handling block is set +x bracketed so even bash -x setup.sh
91
+ # cannot print the substituted secret. The password is written to
92
+ # platform/config/.neo4j-password (chmod 600) — the only readable source.
93
+ # set-initial-password reads the secret via $(cat ...) so the literal
94
+ # never appears on the parent shell's command line, and stdout is
95
+ # discarded so neo4j-admin's own echo cannot leak it either (Task 744).
96
+ { set +x; } 2>/dev/null
90
97
  NEO4J_GENERATED_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
91
98
  mkdir -p "$INSTALL_DIR/platform/config"
92
- echo "$NEO4J_GENERATED_PASSWORD" > "$INSTALL_DIR/platform/config/.neo4j-password"
99
+ printf '%s' "$NEO4J_GENERATED_PASSWORD" > "$INSTALL_DIR/platform/config/.neo4j-password"
93
100
  chmod 600 "$INSTALL_DIR/platform/config/.neo4j-password"
94
- sudo neo4j-admin dbms set-initial-password "$NEO4J_GENERATED_PASSWORD"
101
+ unset NEO4J_GENERATED_PASSWORD
102
+ sudo neo4j-admin dbms set-initial-password "$(cat "$INSTALL_DIR/platform/config/.neo4j-password")" >/dev/null 2>&1
95
103
 
96
104
  # Start and enable
97
105
  sudo systemctl enable neo4j
@@ -139,6 +147,15 @@ else
139
147
  cd "$INSTALL_DIR"
140
148
  fi
141
149
 
150
+ # ------------------------------------------------------------------
151
+ # 6.5. Redact install-log credential leaks (Task 744 — idempotent).
152
+ # Pre-fix logs may contain plaintext neo4j passwords; this script scrubs
153
+ # every install-*.log to "[REDACTED]". Safe on already-clean logs.
154
+ # ------------------------------------------------------------------
155
+ if [ -x "$INSTALL_DIR/platform/scripts/redact-install-logs.sh" ]; then
156
+ bash "$INSTALL_DIR/platform/scripts/redact-install-logs.sh" || true
157
+ fi
158
+
142
159
  # ------------------------------------------------------------------
143
160
  # 7. Install dependencies and build
144
161
  # ------------------------------------------------------------------
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env bash
2
+ # Pre-publish acceptance gate (Task 744).
3
+ #
4
+ # Statically intersects what each shipped skill *prescribes* (every
5
+ # backtick-quoted `mcp__<server>__<tool>` token in SKILL.md and references/*.md)
6
+ # against the dispatched specialist's frontmatter `tools:` list. Catches the
7
+ # class of bug where a skill prescribes a tool the specialist does not have,
8
+ # and where a skill prescribes a forbidden direct-execution path
9
+ # (`cypher-shell`, `neo4j-admin` invocations, raw-Cypher DML in prose).
10
+ #
11
+ # Wired into the root `packages/create-maxy/package.json` `prepublishOnly`
12
+ # script so a regression cannot reach npm publish without firing.
13
+ #
14
+ # One stdout line per (skill, specialist) pair:
15
+ # [verify] skill=<plugin>/<skill> specialist=<n> resolved=<n>/<m> forbidden=<n>
16
+ #
17
+ # Exit 0: every prescribed token resolves AND no forbidden tokens.
18
+ # Exit 1: any unresolved or any forbidden — stderr names the offending token.
19
+ #
20
+ # Skill→specialist mapping comes from PLUGIN.md frontmatter `specialist:` field.
21
+ # Plugins without that field are admin-owned (loaded by the admin agent
22
+ # directly via plugin-read); for those the gate only enforces the forbidden-
23
+ # token rule, since admin's tool surface is the union of all enabled plugins.
24
+
25
+ set -euo pipefail
26
+
27
+ REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
28
+ cd "$REPO_ROOT"
29
+
30
+ python3 - <<'PYEOF'
31
+ import os, re, sys
32
+
33
+ REPO_ROOT = os.getcwd()
34
+ PLUGINS_DIR = os.path.join(REPO_ROOT, "platform", "plugins")
35
+ SPECIALISTS_DIR = os.path.join(REPO_ROOT, "platform", "templates", "specialists", "agents")
36
+
37
+ # Per-skill specialist ownership for plugins where the plugin itself is
38
+ # multi-purpose. The PLUGIN.md `specialist:` field handles single-owner
39
+ # plugins (linkedin-import → database-operator). Mixed-use plugins like
40
+ # `memory` declare per-skill ownership here.
41
+ EXPLICIT_OWNERSHIP = {
42
+ # plugin/skill -> specialist
43
+ "memory/document-ingest": "database-operator",
44
+ }
45
+
46
+ # Skills that are explicitly admin-owned (loaded via plugin-read by the admin
47
+ # agent itself, not delegated to a specialist). These get only the forbidden-
48
+ # token check since admin's effective tool set is the union of all enabled
49
+ # plugins.
50
+ ADMIN_OWNED_SKILLS = {
51
+ "memory/conversational-memory",
52
+ }
53
+
54
+ TOKEN_RE = re.compile(r"`(mcp__[a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)`")
55
+ FENCED_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z]*)\n(?P<body>.*?)\n```", re.S)
56
+ PROSE_CYPHER_RE = re.compile(
57
+ r"`(?:MERGE|CREATE|DETACH\s+DELETE)\s+\(",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def parse_frontmatter(path: str) -> dict | None:
63
+ """Parse YAML-ish frontmatter without PyYAML — handles `key: value` and
64
+ `key:\n - item\n - item` shapes used in PLUGIN.md and specialist files."""
65
+ try:
66
+ text = open(path, encoding="utf-8").read()
67
+ except FileNotFoundError:
68
+ return None
69
+ m = re.match(r"^---\n(.*?)\n---", text, re.S)
70
+ if not m:
71
+ return None
72
+ block = m.group(1)
73
+ out: dict = {}
74
+ cur_key: str | None = None
75
+ cur_list: list[str] | None = None
76
+ for line in block.split("\n"):
77
+ if not line.strip():
78
+ continue
79
+ # List item under cur_key
80
+ if line.startswith(" - ") or line.startswith("- "):
81
+ if cur_list is None:
82
+ continue
83
+ cur_list.append(line.split("- ", 1)[1].strip())
84
+ continue
85
+ # Top-level key
86
+ m2 = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
87
+ if not m2:
88
+ continue
89
+ cur_key = m2.group(1)
90
+ rhs = m2.group(2).strip()
91
+ if rhs:
92
+ # inline value — strip surrounding quotes
93
+ if (rhs.startswith('"') and rhs.endswith('"')) or (
94
+ rhs.startswith("'") and rhs.endswith("'")
95
+ ):
96
+ rhs = rhs[1:-1]
97
+ out[cur_key] = rhs
98
+ cur_list = None
99
+ else:
100
+ cur_list = []
101
+ out[cur_key] = cur_list
102
+ return out
103
+
104
+
105
+ def specialist_tools(specialist: str) -> set[str] | None:
106
+ fm = parse_frontmatter(os.path.join(SPECIALISTS_DIR, f"{specialist}.md"))
107
+ if fm is None:
108
+ return None
109
+ raw = fm.get("tools", "")
110
+ if isinstance(raw, list):
111
+ items = raw
112
+ else:
113
+ items = [t.strip() for t in str(raw).split(",")]
114
+ return {t for t in items if t}
115
+
116
+
117
+ def extract_prescribed_tokens(text: str) -> set[str]:
118
+ return set(TOKEN_RE.findall(text))
119
+
120
+
121
+ def extract_forbidden(text: str) -> list[tuple[str, str]]:
122
+ forbidden: list[tuple[str, str]] = []
123
+
124
+ # Forbidden invocations inside fenced shell blocks
125
+ for m in FENCED_BLOCK_RE.finditer(text):
126
+ lang = m.group("lang").lower()
127
+ body = m.group("body")
128
+ if lang in {"bash", "sh", "shell", "zsh"}:
129
+ if re.search(r"\bcypher-shell\b", body):
130
+ forbidden.append(("cypher-shell", "in fenced shell block"))
131
+ if re.search(r"\bneo4j-admin\s+dbms\b", body):
132
+ forbidden.append(("neo4j-admin", "in fenced shell block"))
133
+
134
+ # Strip fenced blocks for the prose-Cypher heuristic
135
+ prose = FENCED_BLOCK_RE.sub("", text)
136
+ if PROSE_CYPHER_RE.search(prose):
137
+ forbidden.append(("raw-cypher-dml", "in backtick-quoted prose"))
138
+
139
+ return forbidden
140
+
141
+
142
+ def aggregate_skill_text(skill_dir: str) -> str:
143
+ out: list[str] = []
144
+ skill_md = os.path.join(skill_dir, "SKILL.md")
145
+ if not os.path.exists(skill_md):
146
+ return ""
147
+ out.append(open(skill_md, encoding="utf-8").read())
148
+ refs = os.path.join(skill_dir, "references")
149
+ if os.path.isdir(refs):
150
+ for name in sorted(os.listdir(refs)):
151
+ if name.endswith(".md"):
152
+ out.append(open(os.path.join(refs, name), encoding="utf-8").read())
153
+ return "\n".join(out)
154
+
155
+
156
+ def main() -> int:
157
+ if not os.path.isdir(PLUGINS_DIR):
158
+ print(f"[verify] PLUGINS_DIR not found: {PLUGINS_DIR}", file=sys.stderr)
159
+ return 1
160
+ if not os.path.isdir(SPECIALISTS_DIR):
161
+ print(f"[verify] SPECIALISTS_DIR not found: {SPECIALISTS_DIR}", file=sys.stderr)
162
+ return 1
163
+
164
+ summary: list[str] = []
165
+ errors: list[str] = []
166
+ pairs_checked = 0
167
+
168
+ for plugin in sorted(os.listdir(PLUGINS_DIR)):
169
+ pdir = os.path.join(PLUGINS_DIR, plugin)
170
+ if not os.path.isdir(pdir):
171
+ continue
172
+ plugin_fm = parse_frontmatter(os.path.join(pdir, "PLUGIN.md")) or {}
173
+ plugin_specialist = plugin_fm.get("specialist")
174
+
175
+ skills_dir = os.path.join(pdir, "skills")
176
+ if not os.path.isdir(skills_dir):
177
+ continue
178
+ for skill_name in sorted(os.listdir(skills_dir)):
179
+ sdir = os.path.join(skills_dir, skill_name)
180
+ if not os.path.isdir(sdir):
181
+ continue
182
+
183
+ text = aggregate_skill_text(sdir)
184
+ if not text:
185
+ continue
186
+
187
+ prescribed = extract_prescribed_tokens(text)
188
+ forbidden = extract_forbidden(text)
189
+
190
+ ownership_key = f"{plugin}/{skill_name}"
191
+ if ownership_key in ADMIN_OWNED_SKILLS:
192
+ specialist = None
193
+ else:
194
+ specialist = (
195
+ EXPLICIT_OWNERSHIP.get(ownership_key)
196
+ or plugin_specialist
197
+ )
198
+
199
+ if specialist is None:
200
+ # Admin-owned: only enforce forbidden-token rule.
201
+ for tok, ctx in forbidden:
202
+ errors.append(
203
+ f"[verify] skill={ownership_key} specialist=admin "
204
+ f"FORBIDDEN token={tok} context=\"{ctx}\""
205
+ )
206
+ summary.append(
207
+ f"[verify] skill={ownership_key} specialist=admin (admin-owned) "
208
+ f"tokens={len(prescribed)} forbidden={len(forbidden)}"
209
+ )
210
+ continue
211
+
212
+ tools = specialist_tools(specialist)
213
+ if tools is None:
214
+ errors.append(
215
+ f"[verify] skill={ownership_key} specialist={specialist} "
216
+ f"ERROR specialist frontmatter not parseable"
217
+ )
218
+ continue
219
+
220
+ unresolved = sorted(prescribed - tools)
221
+ for tok in unresolved:
222
+ errors.append(
223
+ f"[verify] skill={ownership_key} specialist={specialist} "
224
+ f"unresolved={tok}"
225
+ )
226
+ for tok, ctx in forbidden:
227
+ errors.append(
228
+ f"[verify] skill={ownership_key} specialist={specialist} "
229
+ f"FORBIDDEN token={tok} context=\"{ctx}\""
230
+ )
231
+ summary.append(
232
+ f"[verify] skill={ownership_key} specialist={specialist} "
233
+ f"resolved={len(prescribed) - len(unresolved)}/{len(prescribed)} "
234
+ f"forbidden={len(forbidden)}"
235
+ )
236
+ pairs_checked += 1
237
+
238
+ for line in summary:
239
+ print(line)
240
+
241
+ if errors:
242
+ for line in errors:
243
+ print(line, file=sys.stderr)
244
+ print(
245
+ f"[verify] FAIL pairs_checked={pairs_checked} errors={len(errors)}",
246
+ file=sys.stderr,
247
+ )
248
+ return 1
249
+
250
+ print(f"[verify] OK pairs_checked={pairs_checked}")
251
+ return 0
252
+
253
+
254
+ sys.exit(main())
255
+ PYEOF
@@ -3,7 +3,7 @@ name: database-operator
3
3
  description: "Document and archive ingestion and ad-hoc graph operations — running the universal `document-ingest` skill for any unstructured document (PDF, text, transcript, web page, audio, video) and per-source archive-import skills (LinkedIn Basic Data Export today; CRM-type seed archives as each plugin ships), plus operator-driven graph hygiene (prune orphans, deduplicate entities, add edges, normalise labels). Delegate when the operator uploads any document, drops an archive directory into chat, or asks for any graph operation that is not a routine per-turn write."
4
4
  summary: "Ingests every unstructured document and external archive into your graph (LinkedIn today; other CRM sources in future) and handles ad-hoc graph tidy-ups on request. For example, when you upload a CV, a pricing guide, or a contract; when you drop a LinkedIn export folder into chat; or when you ask to prune orphan nodes, merge duplicate people, or add edges between entities."
5
5
  model: claude-sonnet-4-6
6
- tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__admin__file-attach, mcp__admin__plugin-read
6
+ tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__memory-archive-write, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__admin__file-attach, mcp__admin__plugin-read
7
7
  ---
8
8
 
9
9
  # Database Operator
@@ -12,7 +12,7 @@ You own document and archive ingestion and ad-hoc graph operations. You receive
12
12
 
13
13
  ## Prerogatives
14
14
 
15
- Three rules govern every turn. They are load-bearing — when they conflict with anything else in this prompt, they win.
15
+ Four rules govern every turn. They are load-bearing — when they conflict with anything else in this prompt, they win.
16
16
 
17
17
  **PRECISE.** Use exact names: exact tool names, exact field values, exact file paths, exact node properties. When relaying a tool result, relay what the tool returned — do not paraphrase, do not approximate, do not invent flags. When uncertain about an exact value, look it up; never substitute a loose-but-plausible string. *Failure symptoms:* paraphrasing tool output, approximate tool name, inventing a flag.
18
18
 
@@ -26,6 +26,10 @@ Three rules govern every turn. They are load-bearing — when they conflict with
26
26
 
27
27
  A landfill graph defeats EVIDENCE-BASED: search returns noise, the agent re-writes the noise, the noise compounds. Compress on write; filter on read.
28
28
 
29
+ **LOUD-FAIL.** If a dispatched skill prescribes a tool not present in your live tool surface, or a credential not provided in your tool input, terminate with a structured blocker — never improvise via Bash, never search the filesystem for credentials, never construct a parallel write path. Return: `Skill <name> prescribes <tool/credential>; not available. Cannot proceed. Operator must <remediation>.` Identical doctrine to Task 740 classifier failure and Task 560 graph-MCP loud-fail. *Failure symptoms:* `cypher-shell` invocation, `find … neo4j` / `grep … NEO4J_PASSWORD` filesystem probes, `curl` against Neo4j HTTP endpoints, any Bash improvisation that recreates the missing tool's effect.
30
+
31
+ The pre-publish gate (`platform/scripts/verify-skill-tool-surface.sh`) statically asserts every shipped skill's prescribed `mcp__*` tokens resolve against your frontmatter `tools:` list, so a missing tool is a build error, not a production discovery. LOUD-FAIL is the runtime backstop when that gate is bypassed (e.g. operator-edited skill).
32
+
29
33
  ---
30
34
 
31
35
  ## Output contract